にせねこメモ

はてなダイアリーがUTF-8じゃないので移ってきました。

Inkscape でレイヤーを読み込める SVG をイラレから書き出す

Adobe Illustrator CS6 から SVG を出力したところ、 Inkscape で開いてもレイヤー構造が再現されなかった。(とはいえ、「Illustrator の編集機能を保持」しない場合は Illustrator でも再現できないのだが。)

これをどうにかしてイラレから Inkscape でレイヤー構造を再現できるようにしたい。そこでスクリプトの出番である。

Inkscape のレイヤーとは何か

そもそも、 SVG にレイヤーというものはない。 InkscapeSVGをネイティヴフォーマットとしてサポートしているが、 Inkscape の標準となっているのは Inkscape SVG といって、普通のSVGとしても読めるけれど、 Inkscape 独自のデータがごたごたついているもので、これによって Inkscape の編集機能のためのデータを保持している。

さて、 Inkscape のレイヤーというのは、この Inkscape 独自のデータによって実現されている。
グループオブジェクト(タグで言うと <g> ~ </g> タグ)のうち、属性に inkscape:groupmode="layer" 及び適当な*名前*で inkscape:label=*名前* という設定があると Inkscape でレイヤーとして認識される。

イラレSVG出力

イラレからSVGを出力するとレイヤだったものはどうなるのだろうか。実際に出力して試してみる。

これは、出力前にレイヤが1つであったか、2つ以上であったかによって出力が変わるようである。

1. レイヤ1つ

レイヤ構造は次図の様である。
f:id:nixeneko:20160123071528p:plain

さて、これを SVG で保存すると次の様になる。

<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="&#x30EC;&#x30A4;&#x30E4;&#x30FC;_1"
	 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="1000px" height="1000px"
	 viewBox="0 0 1000 1000" style="enable-background:new 0 0 1000 1000;" xml:space="preserve">
<rect x="205.909" y="300.955" style="fill:#009944;stroke:#000000;stroke-miterlimit:10;" width="547.728" height="279.545"/>
<rect x="363.864" y="161.182" style="fill:#1D2088;stroke:#000000;stroke-miterlimit:10;" width="488.636" height="262.5"/>
</svg>

レイヤ「レイヤー 1」は "&#x30EC;&#x30A4;&#x30E4;&#x30FC;_1"(レイヤー_1) だろう。それが id 属性の値としてルート要素の <svg> に指定されている。
一方で長方形オブジェクトはルート要素の直下に置かれている。

2. レイヤ2つ

さて次はレイヤが2つの時の場合である。構造は次図の通り。
f:id:nixeneko:20160123072613p:plain

この時、 SVG で保存すると次の様になる。

<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="1000px"
	 height="1000px" viewBox="0 0 1000 1000" style="enable-background:new 0 0 1000 1000;" xml:space="preserve">
<g id="&#x30EC;&#x30A4;&#x30E4;&#x30FC;_1">
	<rect x="205.909" y="300.955" style="fill:#009944;stroke:#000000;stroke-miterlimit:10;" width="547.728" height="279.545"/>
</g>
<g id="&#x30EC;&#x30A4;&#x30E4;&#x30FC;_2">
	<rect x="363.864" y="161.182" style="fill:#1D2088;stroke:#000000;stroke-miterlimit:10;" width="488.636" height="262.5"/>
</g>
</svg>

今度は2つのレイヤ「レイヤー 1」「レイヤー 2」が存在する。それぞれ "&#x30EC;&#x30A4;&#x30E4;&#x30FC;_1", "&#x30EC;&#x30A4;&#x30E4;&#x30FC;_2" となっている。
さて、今回はルート要素の <svg> には id は指定されていない。その代りに、ルート要素の直下にグループオブジェクト <g> が配置され、それぞれに id が指定されている。もちろん「レイヤー 1」「レイヤー 2」だったものであろう。
レイヤに含まれる長方形オブジェクトはそれぞれのレイヤだったグループオブジェクトの配下に置かれている。

なお、レイヤを3個にした場合も同様の結果になった。おそらくそれより増やしても同様となると推測される。

SVG出力されたレイヤまとめ

  • レイヤが1個の場合
    • ルート要素 svg に id=レイヤ名 属性
    • レイヤに含まれるオブジェクトはルート要様の直下に直に置かれる
  • レイヤが2個以上の場合
    • ルート要素 svg の直下にグループ g として置かれ id=レイヤ名 属性が指定される
    • レイヤに含まれるオブジェクトはグループに含まれる


このため、次のような処理が必要となるだろう。

レイヤ1つの場合、ルート要素 svg の id 属性を削除し、ルート要素 svg の内容物を全て包んだグループオブジェクト g をルート要素の直下に配置し、そのグループオブジェクトに削除した id 属性を指定する。
レイヤ1つの場合と2つ以上の場合で共通: ルート要素 svg の直下にあるグループオブジェクト g に inkscape:groupmode="layer" 及び inkscape:label="*id*" 属性を追加する。 *id* は同グループオブジェクトの id 属性で指定してあるものを指定するといいだろう。

llustrator スクリプトSVGの出力

さて、実際に llustrator スクリプトの作成にとりかかる。
サンプルとなるスクリプトがローカルに一緒にインストールされているので、それを元にする。
スクリプトファイルは、 Windows 7Adobe Illustrator CS6 をインストールしている自分の環境では、

"C:\Program Files\Adobe\Adobe Illustrator CS6 (64 Bit)\Presets\ja_JP\スクリプト\ドキュメントを SVG として保存.jsx"

にあった。これを元に編集する。


スクリプト編集においては、 Adobe ExtendScript Toolkit を使うとスクリプトの編集やデバッグに便利である。また、同ソフトのメニューのヘルプ→オブジェクトモデルビューアから開けるオブジェクトモデルビューアもスクリプトを書く際に重要な参考資料になる。色々試行錯誤してたがもっと早くに知りたかった……。


さて、スクリプトに追加した関数は次の3つである。

まずはテキストファイル読み書き用の関数。

function readFile(file){
    var f = (file instanceof File) ? file : File(file); 
    f.open('r'); 
    var text = f.read(); 
    f.close(); 
    return text; 
}

function saveFile(dest, str){ 
    var wf = (dest instanceof File) ? dest : File(dest); 
    wf.encoding = "UTF-8";
    try{ wf.open('w'); 
        wf.write(str); 
        wf.close(); 
    } catch(e) { 
        throw new Error(""+decodeURI(wf)+"の書き込みに失敗しました");
    } 
}

Illustratorの出力するSVGは iso-8859-1 なのだが、 IllustratorスクリプトからXMLを書き出すときにそれが使えない(空のファイルになる)ので、UTF-8で出力する様にする。(親切にも数値参照がデコードされちゃうから…。)


そして、今回の本体となる、SVGを読み込んで Inkscape のレイヤ情報を追加する関数。
引数 target で指定されたSVGファイルを読み込み、それに処理した内容を target に上書きする。

function addInkscapeLayerInfo(target){
    var txtSVG = this.readFile(target);
    
    // XML宣言やコメント部分がXMLの編集で消えてしまうので保存しておく
    var indexRoot = txtSVG.indexOf("<svg");
    if (indexRoot == -1){
        throw new Error('SVGタグが見つかりません');
    }
    var txtXMLDeclaration = txtSVG.substring(0, indexRoot);
    
    var xmlSVG = new XML(txtSVG);
 
    // デフォルトのXMLネームスペースにドキュメントのネームスペースを指定
    var nsURI = xmlSVG.namespace();
    var nsSVG = new Namespace(nsURI);
    setDefaultXMLNamespace(nsSVG);
    
    // Inkscape のネームスペースを追加。とりあえず動くけどこれでいいのか…?
    // xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
    var INKSCAPE_NS = "http://www.inkscape.org/namespaces/inkscape"
    xmlSVG.@inkscape = ("");
    xmlSVG.@inkscape.setNamespace(INKSCAPE_NS);
    
    // .ai ファイルのレイヤが1つだけの場合、ルート要素配下を <g> でまとめ、
    // それに id を設定する
    if (xmlSVG.@id.toString() != ""){ 
        var strId = xmlSVG.@id.toString();
        delete xmlSVG.@id;
        $.write("ID: " + strId + "\n");
        var group = new XML("<g />");
        group.@id = strId;
        var elems = xmlSVG.elements();
        for ( var i = xmlSVG.elements().length(); i > 0 ; i = xmlSVG.elements().length()){
            group.appendChild(elems[0].copy());
            delete elems[0];
        }
        delete xmlSVG.elements();
        xmlSVG.prependChild(group);
    } 

    // inkscape:groupmode 及び inkscape:label 属性をグループ <g> に追加
    var grouplen = xmlSVG.g.length();
    var elems = xmlSVG.elements();
    var elemlen = elems.length();
    for ( var i = 0; i < grouplen; i++ ){
        xmlSVG.g[i].@groupmode = "layer";
        xmlSVG.g[i].@groupmode.setNamespace(INKSCAPE_NS);
        xmlSVG.g[i].@label = xmlSVG.g[i].@id.toString();
        xmlSVG.g[i].@label.setNamespace(INKSCAPE_NS);
    }
    
    // IllustratorがSVGをiso-8859-1で出力できないのでutf-8に合わせる
    var strOutXML = txtXMLDeclaration.replace("iso-8859-1", "utf-8") + xmlSVG.toXMLString();
    saveFile(target, strOutXML);
}

なんかよく分からないけど動いてるってところもあるのであまり信用しないほうがいいかも。


後は、 addInkscapeLayerInfo() によって実際に処理をさせる部分を追加する。
スクリプトファイルの50行目あたりに、

                // Save as SVG
                sourceDoc.exportFile(targetFile, ExportType.SVG, options);
                // Note: the doc.exportFile function for SVG is actually a Save As
                // operation rather than an Export, that is, the document's name
                // in Illustrator will change to the result of this call.
                
                this.addInkscapeLayerInfo(targetFile);
            }
            alert( 'ドキュメントはSVG 形式として書き出されました' );

の様に sourceDoc.exportFile() でSVGに書き出した直後のところに this.addInkscapeLayerInfo(targetFile); を追加する。

以上。

実行結果

f:id:nixeneko:20160123131032p:plain
やったぜ。

コード

編集したスクリプトファイル全体を掲載しておく。

/**********************************************************

ADOBE SYSTEMS INCORPORATED 
Copyright 2005-2006 Adobe Systems Incorporated 
All Rights Reserved 

NOTICE:  Adobe permits you to use, modify, and 
distribute this file in accordance with the terms
of the Adobe license agreement accompanying it.  
If you have received this file from a source 
other than Adobe, then your use, modification,
or distribution of it requires the prior 
written permission of Adobe. 

*********************************************************/

/** Saves every document open in Illustrator
    as an SVG file in a user specified folder.
*/

// Main Code [Execution of script begins here]

// uncomment to suppress Illustrator warning dialogs
// app.userInteractionLevel = UserInteractionLevel.DONTDISPLAYALERTS;

try {
    if (app.documents.length > 0 ) {

        // Get the folder to save the files into
        var destFolder = null;
        destFolder = Folder.selectDialog( 'SVG ファイルの保存先フォルダーを選択してください。', '~' );

        if (destFolder != null) {
            var options, i, sourceDoc, targetFile;    
            
            // Get the SVG options to be used.
            options = this.getOptions();
            // You can tune these by changing the code in the getOptions() function.
            
            for ( i = 0; i < app.documents.length; i++ ) {
                sourceDoc = app.documents[i]; // returns the document object
                
                // Get the file to save the document as svg into
                targetFile = this.getTargetFile(sourceDoc.name, '.svg', destFolder);
                
                // Save as SVG
                sourceDoc.exportFile(targetFile, ExportType.SVG, options);
                // Note: the doc.exportFile function for SVG is actually a Save As
                // operation rather than an Export, that is, the document's name
                // in Illustrator will change to the result of this call.
                
                this.addInkscapeLayerInfo(targetFile);
            }
            alert( 'ドキュメントはSVG 形式として書き出されました' );
        }
    }
    else{
        throw new Error('ドキュメントが開かれていません。');
    }
}
catch(e) {
    alert( e.message, "スクリプト警告", true);
}


/** Returns the options to be used for the generated files.
    @return ExportOptionsSVG object
*/
function getOptions()
{
    // Create the required options object
    var options = new ExportOptionsSVG();
    // See ExportOptionsSVG in the JavaScript Reference for available options
    
    // Set the options you want below:
    
    // For example, uncomment to set the compatibility of the generated svg to SVG Tiny 1.1    
    // options.DTD = SVGDTDVersion.SVGTINY1_1;
    
    // For example, uncomment to embed raster images
    // options.embedRasterImages = true;
    
    return options;
}

/** Returns the file to save or export the document into.
    @param docName the name of the document
    @param ext the extension the file extension to be applied
    @param destFolder the output folder
    @return File object
*/
function getTargetFile(docName, ext, destFolder) {
    var newName = "";

    // if name has no dot (and hence no extension),
    // just append the extension
    if (docName.indexOf('.') < 0) {
        newName = docName + ext;
    } else {
        var dot = docName.lastIndexOf('.');
        newName += docName.substring(0, dot);
        newName += ext;
    }
    
    // Create the file object to save to
    var myFile = new File( destFolder + '/' + newName );
    
    // Preflight access rights
    if (myFile.open("w")) {
        myFile.close();
    }
    else {
        throw new Error('アクセスが拒否されました');
    }
    return myFile;
}

function addInkscapeLayerInfo(target){
    var txtSVG = this.readFile(target);
    
    // Store the XML Delcaration and comment part so as to be added to the modified XML.
    var indexRoot = txtSVG.indexOf("<svg");
    if (indexRoot == -1){
        throw new Error('SVGタグが見つかりません');
    }
    var txtXMLDeclaration = txtSVG.substring(0, indexRoot);
    
    var xmlSVG = new XML(txtSVG);

    // Set default XML namespace to the namespace of the document
    var nsURI = xmlSVG.namespace();
    var nsSVG = new Namespace(nsURI);
    setDefaultXMLNamespace(nsSVG);
    
    // Add inkscape namespace to the document. I'm not sure this is the right way but it works anyway...
    // xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
    var INKSCAPE_NS = "http://www.inkscape.org/namespaces/inkscape"
    xmlSVG.@inkscape = "";
    xmlSVG.@inkscape.setNamespace(INKSCAPE_NS);
    
    // when .ai file contains just one layer
    if (xmlSVG.@id.toString() != ""){ 
        var strId = xmlSVG.@id.toString();
        delete xmlSVG.@id;
        $.write("ID: " + strId + "\n");
        var group = new XML("<g />");
        group.@id = strId;
        var elems = xmlSVG.elements();
        for ( var i = xmlSVG.elements().length(); i > 0 ; i = xmlSVG.elements().length()){
            group.appendChild(elems[0].copy());
            delete elems[0];
        }
        delete xmlSVG.elements();
        xmlSVG.prependChild(group);
    } 
    
    // Add inkscape:groupmode and inkscape:label attributes.
    var grouplen = xmlSVG.g.length();
    var elems = xmlSVG.elements();
    var elemlen = elems.length();
    for ( var i = 0; i < grouplen; i++ ){
        xmlSVG.g[i].@groupmode = "layer";
        xmlSVG.g[i].@groupmode.setNamespace(INKSCAPE_NS);
        xmlSVG.g[i].@label = xmlSVG.g[i].@id.toString();
        xmlSVG.g[i].@label.setNamespace(INKSCAPE_NS);
    }
    
    // Illustrator cannot output a SVG file in iso-8859-1 encoding.
    var strOutXML = txtXMLDeclaration.replace("iso-8859-1", "utf-8") + xmlSVG.toXMLString();
    saveFile(target, strOutXML);
}

function readFile(file){
    var f = (file instanceof File) ? file : File(file); 
    f.open('r'); 
    var text = f.read(); 
    f.close(); 
    return text; 
}

function saveFile(dest, str){ 
    var wf = (dest instanceof File) ? dest : File(dest); 
    wf.encoding = "UTF-8";
    try{ wf.open('w'); 
        wf.write(str); 
        wf.close(); 
    } catch(e) { 
        throw new Error(""+decodeURI(wf)+"の書き込みに失敗しました");
    } 
}