Google Apps Scriptで差込で直接PDFを生成する【GAS】

以前、Google Apps Scriptで差込印刷的なものを実現し、その後これを元に一括でPDF化するものを開発しています。しかし、いくつかの理由でこのPDF化をする手順は結構複雑で時間も掛かります。

そこで、この過程の一つである「テンプレートを複製する」部分を削って時間を稼げないか?ということで今回挑戦してみることにしました。

今回利用するスプレッドシート

差し込み印刷的な何かでは、1枚のドキュメントにテンプレ項目を用意しておき、上から順番に文字列のリプレースを用いてスプレッドシートのデータを差込、ファイルとしていましたが、これはちょっと一部で問題がありました。そこで、テンプレートファイルをコピーしてそこに差し込みを実行する手法を取ることで問題は解決してるものの、今度は「複製で時間が掛かる」という問題があります。

これを今回解決できないか?というのが問題の趣旨です。

これまでの問題点

単純な差込時の問題点

実は差し込み印刷的な何かで実装した場合にはいくつかの問題点が生じます。

  1. パラグラフのコピーだと画像がコピーされない
  2. 表組みがあるファイルだと複製時に無駄な改行がテーブルの後に入ってきてしまう(これを特定して削るのが難しい)
  3. 1度はテンプレートから複製を行っている(1.の問題を回避するため)

単純な差込文書ならばこれでも問題ないものでも、日本の場合、表組みをするケースが非常に多いのでこの方法だと2.の問題でどんどんページがズレていくというちょっと問題の抱えている手法なのです。

Google Apps Scriptで差し込み印刷的な何か【GAS】

テンプレから複製する場合の問題点

一方、この差し込み印刷の手法を改良して、1枚ずつテンプレから複製する手法の場合には、前述の問題点2.は解消可能なのですが新たな問題が生じてしまいます。

  1. 1枚1枚テンプレートからファイルの複製して差込してるので、その分APIを実行してる為時間が掛かる(6分の制限に掛かりやすくなる)
  2. また、GASのQuotaで1日1アカウントが作成できるドキュメント数はGWSアカウントでは1500枚が上限である
  3. 複製に関してbatchRequestライブラリを利用していましたが、Drive APIのQuotaや503エラーが発生するケースがある

少しでも時間を稼ぐ為に合せ技でDocs APIを使った一括リプレースを装備して緩和しています。

特にファイルの複製が結構な時間を消費する為、複製せずにPDF化に持っていければ、処理出来る枚数が増える上に、DocumentのQuotaに引っかかる心配が無くなるわけです。また、Drive APIにリクエストを送ってまとめて複製も行っていないので、その他のエラーが大幅に回避出来るメリットもあります。

Google Apps Scriptでドキュメントを一括でPDFにする【GAS】

Google Apps Scriptで6分の壁(タイムアウト)を突破する【GAS】

Google Apps ScriptでDocs APIを使って文字を一括で置き換える【GAS】

コードと比較

通常の文書の差込PDFの場合

ソースコード

今回のスクリプトは新たにドキュメントを生成することなく、テンプレートファイル上だけで完結し、PDFだけを指定のディレクトリに出力するというスクリプトになります。

このスクリプトを実装するに当たってはいくつかの注意点がありました。

//差込元データ
let spread = "差込元のスプレッドシートのIDを指定する";

//PDF出力先
let exportdir = "PDFを生成する先のディレクトリのIDを指定する";

//差込実行
function insertDoc(){
  let ui = DocumentApp.getUi();
  let activedoc = DocumentApp.getActiveDocument();
  let did = activedoc.getId();

  //シートを取得
  let sheet = SpreadsheetApp.openById(spread);
  let data = sheet.getActiveSheet().getDataRange().getValues();   //ヘッダまで含めてる
  let datalength = data.length;
  let lastcolumn = sheet.getLastColumn();

  //このテンプレートのデータを取得しておく
  let sourceDoc = activedoc.getBody();
  let totalElements = sourceDoc.getNumChildren();
  let copydocs = sourceDoc.copy();

  //ヘッダを取得する
  let range = sheet.getActiveSheet().getRange(1,1,1,lastcolumn).getValues();

  //シートデータで差込する
  for(let z = 1;z<datalength;z++){
    //ワード置換ルーチン
    for(let p = 0;p<lastcolumn;p++){
      let choicecol = String("§" + range[0][p] + "§");
      let tempdata = data[z][p];

      sourceDoc.replaceText(choicecol, tempdata);
    }

    //ドキュメントを保存する(replaceが反映しない為)
    activedoc.saveAndClose();

    //再度開いて取得し直し
    activedoc = DocumentApp.getActiveDocument();
    sourceDoc = activedoc.getBody();

    //PDFに変換する
    let pdf = activedoc.getAs('application/pdf');
    
    //ドライブにファイルを生成する
    DriveApp.getFolderById(exportdir).createFile(pdf).setName("テスト生成PDF No:" + z);

    //初期化
    cleardocs(activedoc,copydocs,sourceDoc,totalElements);
  }

  //ドキュメントを初期化する
  cleardocs(activedoc,copydocs,sourceDoc,totalElements);

  //終了メッセージ
  ui.alert("作業終了")
}

//ドキュメントを初期化する
function cleardocs(activedoc,copydocs,sourceDoc,totalElements){
  //テンプレ内部をクリアする
  let body = activedoc.getBody().clear();

  //行カウンタ
  let count = 0;

  //copydocsを元に戻す
  for( let j = 0; j < totalElements; ++j ) {
    let element = copydocs.getChild(j).copy();
    let type = element.getType();

    if( type == "PARAGRAPH" ){
      sourceDoc.appendParagraph(element);

      //行カウンタをアップ
      count = count + 1;
    }
    else if( type == "TABLE"){
      sourceDoc.appendTable(element);
    }
    else if( type == "LIST_ITEM"){
      sourceDoc.appendListItem(element);
    }
  }

  //文頭の空行を削除する
  sourceDoc.removeChild(sourceDoc.getChild(0))

  //行カウンタを元に文末の改行を削除する
  sourceDoc.removeChild(count);

  //処理を返す
  return 0;
}

注意点

前述のスクリプトを実装するに当たってぶつかった壁やこれまでの課題が解決できたのでここにまとめておきます。

  • replaceTextにて差込文字を置き換えていますが、saveAndCloseメソッドを実行しないと反映しません。
  • またその直後でgetAsにてPDF化を実行していますが、前述のメソッドにて取得したドキュメントとの接続が切れてるので再度接続する為に、getActiveDocument等を実行しています。
  • PDF生成後に元の差込前の状態に戻す為に、cleardocsを実行させています。
  • cleardocs関数では、ファイル内部を全クリア後、copyDocsで取得しておいた内容を順次復元しています。
  • paragraphの時にだけカウンタを回していますがこれは後で末尾の空行を削除する為に利用します。
  • appendすると先頭と末尾に余計な空行が入ってしまうので、それぞれ削除します。また末尾の場合は空行のカウント値を元に削除指定します。

また、元になるテンプレートの最後がテーブルになってるため、敢えて空行を入れています。DocumentAppではこの空行は削除が出来ません。ゆえにテンプレートを作成する場合は最後にかならず空行が1行入った状態で作る必要があります。

ドキュメント生成はしていないのでQuotaに引っかかることもありません無駄な改行もこれでスッキリきれいに削れてるので、差込印刷的な何かがよりらしくなりました。

複雑なレイアウトの場合

ソースコード

前述のソースコードは一般的な案内文のレイアウトで、単純な文書の場合は有効です。しかし、画像を駆使したり表を駆使した領収書的な複雑なレイアウトの場合、cleardoc関数を実行した際に元のレイアウトに復元が出来ずに崩れたり、位置がズレたりするケースが生じます。そのような場合は次項の注意点を踏まえて、以下のようなコードで構築する必要があります。

insertdocの関数については同じものになるので省略します。

//テンプレートからコピーして中身を復元する
function mergeGoogleDocs() {
  //このドキュメントを取得する
  let baseDoc = DocumentApp.getActiveDocument();
  let body = baseDoc.getBody();

  //テンプレートファイルを取得する
  let docid = DocumentApp.openById("同じレイアウトの別のファイルのID").getBody();
  let otherBody = docid.copy();
  let totalElements = otherBody.getNumChildren();

  //テンプレ内部をクリアする
  let result = body.clear();

  //パラグラフカウント用カウンタ
  let count = 0;

  //中身を元に戻す
  for (let j = 0; j < totalElements; ++j) {
    //テンプレデータから要素を取得する
    let element = otherBody.getChild(j).copy();
    let type = element.getType().toString();

    //タイプ別に要素を貼り付ける
    if (type == "PARAGRAPH") {
      //通常のパラグラフの場合の処理
      //パラグラフデータを貼り付ける
      body.appendParagraph(element);
      //行カウンタをアップ
      count = count + 1;
    } else if (type == "TABLE") {
      //テーブルの場合の処理
      body.appendTable(element);
    } else if (type == "LIST_ITEM") {
      //リストアイテムの場合の処理
      body.appendListItem(element);
    } else if (type == "INLINE_IMAGE") {
      //インライン画像の場合の処理
      let image = element.asInlineImage();
      let blob = image.getBlob();
      let imageFile = folder.createFile(blob);
      body.appendImage(imageFile.getBlob());
    } else {
      //不明な要素の場合はエラーをスロー
      throw new Error('Unknown element type: ' + type);
    }
  }

  //文頭の改行を削除する
  baseDoc.removeChild(baseDoc.getChild(0))
  baseDoc.removeChild(baseDoc.getChild(count));

  //セーブする
  baseDoc.saveAndClose();

  return 0;
}
  • 自分自身を取得して、自分自身に貼り付けようとするとレイアウトがかなり崩れます。頑張りましたがこれはちょっと無理です。
  • 不思議なのが同じレイアウトの別のファイルから取得するとレイアウトの崩れが殆どなくできるため、テンプレートファイルでは同じ形式の別のファイルを指定して取得し、貼り付けて初期化しています。
  • それでも崩れる場合があるので改行や位置の調整などをしっかりやらないと別のファイルからの貼付け初期化でも崩れることがあります。

注意点

様々なパターンで最も現実的で問題が少なかったやり方が前述のコードになります。しかし、ここでも注意点がいくつかあります。

  • 画像を前述の方法でコピーして貼り付けるすると、ドキュメント上では正常に見えても、PDF出力すると壊れてる表示になります。
  • またドキュメント上でも正常ではない表示になることがあります。
  • 図形描画にして埋め込みにして貼り付けると上手くコピーが出来ますが、PDF出力では壊れてる表示になります。
  • 貼り付ける画像は、前面表示+ページに位置固定の設定でないとException: Service unavailable: Documentsエラーが出てスクリプトが停止します。
  • テーブルも同様の設定で貼り付けていますがこちらは正常にコピー出来ています。
  • 一方画像の場合、「行内」での設定だとレイアウトの自由度が消える反面、正しくコピーされます。PDF表示も問題ありません。但し、表の中で行内であっても表示やPDF上では壊れた表示になります。
  • 複数ページのテンプレの場合はさらに複雑で余計な改行が結構入ることでこの調整が死ぬほど大変。表も位置がズレたりする。
  • Drive APIにて過去のRevision(版)から復元できないか?調べましたが現時点でそのようなAPIは用意されていません。
  • また、画像の置換も試みましたが、現時点ではインライン画像ならば置換する手段がありますが、前面表示の通常の画像は出来そうにないです。

この如何ともしがたい問題ですが、どうやら2021年にはもう報告されて修正されていないGoogle Apps Scriptのバグのようです。こちらの報告こちらの報告でIssue Trackerで報告されているにも関わらず放置されている問題です。

故に現在のこの手法を複雑なレイアウトのファイルに使った場合の初期化方法は、レイアウトでは原則「行内」にて画像は指定するようにし、テーブルなどでレイアウトをガッチリ組み込むしかありません。また、ドキュメントを使わず「スプレッドシートで代用」するほうがオカシナ表示も少なく、初期化についても、セル単位で簡単に書き換えが出来るので、オススメです(但し複数ページに渡る場合は改ページを入れたり、また印刷時の余白設定がこちらは自由が効かないので結局調整は必要です。)


図:画像の表示がこうなってしまう致命的問題

図:画像やテーブルはこの設定である必要がある

テーブルデータを書き換えて初期化する方法

複雑なレイアウトで差込データが表のみで構成されてるような場合は、テーブルデータを書き換える手法が使えることがわかりました。それらについては以下のエントリーに別に記述しています。この手法を使って、テーブル内のデータを予め書き出しておいて、差込後にもとに戻す初期化時に復元してあげれば、レイアウトを崩すことなく綺麗に元に戻ります。

少々複雑なコードではありますが、別のファイルからデータを復元するような手法よりも高速で確実に元に戻せますのでオススメです。

※25レコードデータでPDF連続生成⇒PDF結合までをこの技術で実践したところ、2分51秒で全PDF生成と結合PDFの生成が出来ました。それまでが6分超えであったのを考えるとなかなかの成果です。

Google Apps ScriptでDocumentのテーブルの値を読み書き【GAS】

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)