ごんれのラボ

iOS、Android、Adobe系ソフトの自動化スクリプトのことを書き連ねています。

InDesignのテキスト流し込みスクリプトの設計を見直してみた

概要

久しぶりにInDesignのテキスト(画像も含む)流し込みスクリプトを実装したときに、安易に過去のスクリプトを3行ぐらいコピペしたところでうんざりしたので、実装のしやすさと処理速度を両立できる設計を検討しつつ実装してみました。
いまさら?っていう内容かもしれないけど、そのときは読み流してそっと閉じてください。

環境

  • macOS 10.15.6
  • InDesign CC 2020

テキストデータの整形

Excel上でテキスト整形

今回の流し込みスクリプトはExcelファイルからテキストを生成することが要件でした。
私が流し込みスクリプトを設計するときはタブ区切りテキストを用意することが多いので、Excelはもってこい。
まず、支給されたExcelのシートとは別に流し込み用テキスト生成用のシートを追加して、そのシートに必要なセルを参照しました。
一行が1ページに流し込むテキストに相当します。
その際に、Excelのセル内改行を独自のメタ文字(適当な文字列)に置き換えたり、空白のセルは空文字になるようにしたり、ざっくりテキスト整形します。
実際は流し込みしてからセル内改行に気づいたんですが、数年ぶりに許しまじCRLFっていう気持ちになりました。許すまじ。

そんなこんなで書き出したテキストファイルをスクリプトで読み込みます。
特に工夫もなくすべての行数を一気に読み込んで、改行コードでsplitして配列にします。

ここまではみんな似たようなことをやりますよね。
ここから先が今回工夫したことです。

テキストの配列をオブジェクトに格納

まず、次のようにExcelの列番号を定数として定義します(ExtendScriptの場合、上書きできちゃうけど、気持ちは定数)。

var ID = 0;
var FULLNAME = 1;
var AGE = 2;
... // 他の列も定義する

続いて、次のようにオブジェクト(例ではdataArray)に格納します。
arrayがテキスト一行文、すなわち1ページに流し込むテキストのデータです。

// 流し込み用のデータを格納する配列
var dataArray = [];

// 1行目はヘッダーなので処理しない
for (var i = 0, iLen = array.length; i < iLen; i++) {
    var column = array[i].split('\t');
    var data = {
        ID: column[ID],
        fullName: column[FULLNAME],
        age: column[AGE],
        ...
    }
    // なにか加工する必要があれば、dataオブジェクトにアクセスして値を更新する

    // 新しくKeyValueを追加することも可能
    data.kind = 'Cat';

    // 配列に詰め込む
    dataArray.push(data);
}

流し込むときにこのオブジェクトから該当するテキストデータを取得すればよく、Excel上でセルの列の番号が変わっても、定数の列番号を新しいものに変更すれば対応可能です。

昔は array[0] で取り出していたんですが、さすがにそれはないだろうと思って、今回のやり方に変えました。

テキストデータの整形はこんなところです。

流し込みフォーマットの整形

流し込み対象のInDesignドキュメントにも工夫を施しました。
要件次第では必要のないものもありますが、一例として紹介します。

一点目は、各オブジェクトの名前をdataオブジェクトのKeyと同じ名前にしました。
これは単純に名前が一緒のほうがどのオブジェクトが対象かわかりやすくなるというだけでなく、流し込みロジックを簡略化できるという強力な効果があります。
流し込みロジックについては後述します。

二点目は、グループオブジェクトや線にも名前をつけました。
これは今回の要件として、以下にあげた項目があったからです。

  • フォーマットに配置されたフレームで足りないときは、スクリプトで必要数分フレームを増やす
  • ページによって必要のないフレームは削除する
  • 流し込み後にフレームの座標位置を上寄せにする
  • グループ内のテキストフレームにも流し込みを行う

名前をつけることにより、その名前をキーにベースとなるグループオブジェクトを複製して配置したり、上寄せのときにテキストフレームと一緒にグループオブジェクトや線の移動ができたりします。
グループ内のグループオブジェクトにも名前をつけると、より細かい制御ができ、おすすめです。

余談ですが、オブジェクトの名前つけにもスクリプトを使用しました。
簡単なものなのでコードは省略しますが、レイヤーパレットでトリプルクリックを繰り返す作業から解放されました。

処理対象のオブジェクト化

データの流し込みの工夫について説明する前に、よくある実装方法をおさらいします。

テキストフレームにテキストを流し込む一番簡単な方法として、以下のようなコードがあげられます。
私のブログを含む、初心者向けの記事によくある書き方です。
内容としては「1ページ目にあるテキストフレームに対して、この本文はダミーですという文字列を流し込む」ものです。

var doc = app.activeDocument;
var myPage = doc.pages[0];
for (var i = 0, iLen = myPage.textFrames.length; i < iLen; i++) {
    var textFrame = myPage.textFrames[i];
    textFrame.contens = 'この本文はダミーです';
}

また、以下のように書くと、指定した名前(この場合はsample)のテキストフレームのみ、テキストの流し込みが行われます。

var doc = app.activeDocument;
var myPage = doc.pages[0];
for (var i = 0, iLen = myPage.textFrames.length; i < iLen; i++) {
    var textFrame = myPage.textFrames[i];
    if (textFrame.name == 'sample') {
        textFrame.contens = 'この本文はダミーです';
    }
}

さて、このふたつのコードにはとある問題が潜んでおり、今回私が担当した案件では期待した結果になりません。
実際にテキスト流し込み用のスクリプトを開発した経験がある方にはすぐ答えがわかってしまう問題ですが、このコードではグループ化されたオブジェクト内のテキストフレームには流し込みが行われません。

正しく処理を行うためには以下のように書き換える必要があります。

var doc = app.activeDocument;
var myPage = doc.pages[0];
for (var i = 0, iLen = myPage.allPageItems.length; i < iLen; i++) {
    var pageItems = myPage.allPageItems[i];
    if (pageItem.constructor.name == 'TextFrame' && pageItem.name == 'sample') {
        textFrame.contens = 'この本文はダミーです';
    }
}

すべてのtextframesを取得する myPage.textFrames ではなく、すべてのpageItemsを取得する myPage.allPageItems に対してfor文を回し、取得したpageItemsに対して Textframe かつ 名前がsampleか というif文で合致するテキストフレームをフィルタリングして、流し込みを行います。

当然流し込みを行うテキストフレームはひとつだけではないですし、テキストフレーム以外のオブジェクトも多数あります。
また、流し込みではありませんが、座標調整のためにグループオブジェクトも取得する必要があります。
そのたびに myPage.allPageItems にアクセスしていたのでは、処理時間が膨大にかかってしまいます。

そこで上述した問題を解決するために、処理対象のオブジェクトをオブジェクトに格納する方法を試しました。

実装はとても簡単で、以下のようなコードを各ページの繰り返し処理の先頭に記述するだけです。

var allTextFramesInSpread = {};
var allGroupsInSpread = {};
var allGraphicLinesInSpread = {};
var allRectanglesInSpread = {};
for (var i = 0, iLen = myPage.allPageItems.length; i < iLen; i++) {
    var pageItem = myPage.allPageItems[i];
    if (pageItem.constructor.name == 'TextFrame' && pageItem.name != "") {
        // 名前のついたテキストフレームをオブジェクトに格納する
        allTextFramesInSpread[pageItem.name] = pageItem;
    } else if (pageItem.constructor.name == 'Group' && pageItem.name != "") {
        // 名前のついたグループをオブジェクトに格納する
        allGroupsInSpread[pageItem.name] = pageItem;
    } else if (pageItem.constructor.name == 'GraphicLine' && pageItem.name != "") {
        // 名前のついた線をオブジェクトに格納する
        allGraphicLinesInSpread[pageItem.name] = pageItem;
    } else if (pageItem.constructor.name == 'Rectangle' && pageItem.name != "") {
        // 名前のついた長方形をオブジェクトに格納する
        allRectanglesInSpread[pageItem.name] = pageItem;
    }
}

意図しない結果の例では個別のフレーム名を指定してオブジェクトを特定しましたが、実際に案件で使用したコードでは名前のついているオブジェクト名だけをオブジェクトに格納しました。

さて、オブジェクト化することの利点とはなんでしょうか。
一度だけ myPage.allPageItems にアクセスしてオブジェクト化してしまえば、ページ全体のオブジェクトの構造が変わらないという条件はありますが、以降は処理対象へのアクセスを作成したオブジェクト経由で行えます。
さらに、名前がついていないオブジェクトをオブジェクト化しないことで、処理を行わないオブジェクトは無視されます。
どちらも処理速度の向上に貢献します。

テキスト流し込み処理の簡略化

ここまで説明してきた「テキストの配列をオブジェクトに格納」「流し込みフォーマットの整形」「処理対象のオブジェクト化」の3つの工夫により、テキスト流し込み処理を簡略化できました。

まず、テキストを流し込めばよいだけのテキストフレームの名前を配列に格納します。

var normalFlowTextFrameNames = [
    "fullName", 
    "age", 
];

続いて、テキストフレームを格納したオブジェクトから、名前をキーに対象を抽出するメソッドを定義します。

function getTextFrameInPage(frameName) {
    return allTextFramesInPage[frameName];
}

最後に、流し込みを行います。

// dataのキーと同じ名前のテキストフレームに値を流し込む
for (var i = 0, iLen = normalFlowTextFrameNames.length; i < iLen; i++) {
    var targetName = normalFlowTextFrameNames[i];
    getTextFrameInPage(targetName).contents = data[targetName];
}

こうすることで、テキストフレームごとに流し込むテキストを指定することなく、一気に流し込みを行えます。
設計・実装してみれば単純な流れではありますが、実装の意識を上寄せ処理やフレーム増減処理に集中させることができて、なかなかよくできた仕組みだと思っています。

注意点

最後に注意点です。
「処理対象のオブジェクト化」のところでも触れましたが、ページの構造が変わると意図しない動作になります。
特に注意すべきはグループオブジェクトで、グループオブジェクトをグループ解除すると、インスタンスの参照が切れてしまい、そのグループ内に含まれていたオブジェクトが取得できなくなります。

具体的には以下のコードのように、isValidでfalseになります。

getGroupInSpread('sample').ungroup();
for (var i = 0, iLen = getGroupInSpread('sample').allPageItems.length; i < iLen; i++) {
    var pageItem = getGroupInSpread('sample').allPageItems[0];
    if (pageItem.isValid) {
        // ここにはこない
    } else {
        // こっちにくる
    }
}

この問題の対策は案外面倒くさいです。
今回は名前をキーにしてグループオブジェクトを取得するメソッドを定義して、その中で対象のオブジェクトに対してisValidで判定してfalseが返ってきたら、ページの第一階層のグループオブジェクトの中から該当する名前のオブジェクトを返すようにしました。
構造を理解しているから使える手段ですが、自分でフォーマットを作る利点でもありますね。

最後に

あまりテキスト流し込みに関する実践的な内容を紹介した記事がなかったので、実際の案件ベースに現時点の私なりの最適解を紹介しました。
これを機に、より有用な記事が公開され、私のスクリプト開発がはかどるようになるといいなぁ。