Googleドキュメントとスプレッドシートで差し込み印刷

Googleドキュメントは非常に便利で使いやすく、Microsoft Wordに匹敵するツールだと思いますが、Wordではメジャーな機能である差し込み印刷がありません。

そこで、GoogleドキュメントとGoogleスプレッドシートを使って差し込み印刷相当のことを行うツールを作ってみましたので、参考までに、この記事で紹介させていただきます。

* この記事で紹介した方法を使用したことにより、ご利用者様または第三者に損害・トラブル等が発生した場合でも、当サイト運営者は一切の責任を負いません。出力結果を十分ご確認いただき、自己責任でご利用をお願いいたします。

ツールについて

ツールの概要

こちらの動画をご覧いただくと、このツールの利用イメージがわかると思います。

雛形となるドキュメントと、差し込みデータとなるスプレッドシートを指定して差し込み実行ボタンを押すと、差し込みが反映されたPDFドキュメントがマイドライブに作成されます。

差し込みデータのスプレッドシートの例。1行目は項目名とし、ドキュメントに記載した項目名と一致させる。
なお、スプレッドシートの方は、項目名に二重の波括弧は不要。
雛形(差し込み先)となるドキュメントの例。データを差し込みたい箇所には {{ 会社名 }} のように、二重の波括弧で項目名を囲んで入力する。
①雛形ドキュメントのURL、②差し込みデータ(スプレッドシート)のURL、③シート名を入力した上で、「差し込み実行」を押す。
新規の出力フォルダが作成され、差込反映後のドキュメントとPDFが格納される。
出力されたPDFのサンプル。スプレッドシートのデータが差し込まれている。

なお、プリントアウトはこのツールでは対応していません。PDFファイルをダウンロードして印刷するなどしてご対応ください。

ツールの利用

下記リンク先からコピーを作成をクリックすると、マイドライブにツールがコピーされます。

  • Googleドキュメント差込PDF作成(ファイル分割版)
    差込データ1行ごとに、ドキュメント/PDFが1つずつ出力されます。
    まれにファイルの作成が失敗することがあるようなので、処理終了後のメッセージ(成功・失敗件数)や、出力されたファイルの数、連番などを確認したほうが良さそうです。
    (先述の説明動画はこちらのツールの動画になります)
  • Googleドキュメント差込PDF作成(ファイル結合版)
    差込データ全行分まとめて、1つのドキュメント/1つのPDFが出力されます。
    動作が速く安定していますが、ヘッダー/フッターのページ番号が全ページ通しの番号になってしまうなどの欠点があります。また、出力ドキュメントが雛形ドキュメントを忠実に再現しているか十分な検証ができていないため、ご利用の際は出力ファイルをよく確認してください。

初めて差し込み実行を行う際は、承認作業が必要になります。下記を参考に承認作業を進めてください。

「承認が必要です」というダイアログが表示されますので、権限を確認 をクリックします。
* この部分の解説画像は、他の記事から流用しているため、実際のものと若干異なる場合があります。

このスクリプトを使うGoogleアカウントを選択します。

(この画面が表示された場合のみ)左下の 詳細 をクリックします。

(この画面が表示された場合のみ)左下の プロジェクト名(安全ではないページ)に移動 をクリックします。

右下の 許可 をクリックします。

参考:開発メモ・コード(ファイル分割版)

Googleドキュメントに差し込みを行うにあたって、できれば、
複製Docを作成 > 差込1を実施 > PDF出力 > 差込前の状態に戻す > 差込2を実施 > PDF出力 > 差込前の状態に戻す …というように、複製ドキュメントを1つだけ作って、それを何度も書き換え(差し込み)したかったのですが、思ったより難しそうで断念しました・・・。

そのため、当ツールでは、
複製Doc1を作成 > 差込1を実施 > PDF出力 > 複製Doc2を作成 > 差込2を実施 > PDF出力 > 複製Doc3を作成 … のように、差し込みする回数分のドキュメントを作成するフローにしています。

ファイルの作成(Docの複製やPDF出力)を毎回行うため処理時間が長く、大量に差込するのは現実的ではなくなってしまいました。(GASの実行時間6分の制限も気になりました)

よって、その対策としてHtmlService(モーダル)を使用してメイン処理をクライアント側で行うことで、実行時間6分制限への対応と、処理時間の短縮(30並列で処理する)をしています。

特に非同期処理の理解が不十分で拙いですが、参考にコードを掲載させていただきます。
なお、下記コード中のgasRun関数については、こちらの記事のコードを使用させていただきました。

/**
 * 処理状況モーダルを表示する
 */
function showModal() {
  const ui = SpreadsheetApp.getUi();
  // ダイアログで実行確認
  const response = ui.alert("差込PDF作成を実行しますか?", ui.ButtonSet.OK_CANCEL);
  
  // モーダル(modal.html)を表示
  if (response === ui.Button.OK){
    const html = HtmlService.createHtmlOutputFromFile('modal').setHeight(270);
    ui.showModalDialog(html,"差込PDF作成");
  }
}

/**
 * 差し込みしたドキュメント・PDFを作成する
 */
function createFile(params, replaceList, outputFolderId, index) {
  // 参照するドキュメント・スプレッドシートを取得
  const templateDoc = DocumentApp.openByUrl(params.templateDocUrl);
  const templateFile = DriveApp.getFileById(templateDoc.getId());
  const templateFileName = templateFile.getName();
  const folder = DriveApp.getFolderById(outputFolderId);
  
  // ドキュメント・PDFの作成
  const replacedDoc = createReplacedDoc(templateFile, replaceList, folder)
    .setName(`DOC_${templateFileName}_${index + 1}`);
  const pdfFile = replacedDoc.getAs('application/pdf')
    .setName(`PDF_${templateFileName}_${index + 1}.pdf`);
  folder.createFile(pdfFile);
}

/** 
 * 雛形ファイルと差込リストから差込PDFファイルを作成する
 */
function createReplacedDoc(templateFile, replaceList, folder) {
  // 作業用の複製ファイルを作成
  const file = templateFile.makeCopy(folder);
  const doc = DocumentApp.openById(file.getId());
  const body = doc.getBody();
  
  // 差込リストを元に差込実行
  replaceList.forEach(item => {
    body.replaceText(`\{\{ *${item.before} *\}\}`, item.after);
  })

  // 保存してからドキュメントを返す
  doc.saveAndClose();
  return doc;
}

/** 
 * パラメーター(ユーザー入力値など)を取得する
 */
function getParams() {
  // ツールシートを取得
  const toolSheet = SpreadsheetApp.getActiveSheet();
  
  // ドキュメント名を取得
  const templateDocUrl = toolSheet.getRange("C3").getValue();
  const templateDocName = DocumentApp.openByUrl(templateDocUrl).getName();

  return {
    templateDocUrl: templateDocUrl,
    templateDocName: templateDocName,
    dataSheetUrl: toolSheet.getRange("C6").getValue(),
    dataSheetName: toolSheet.getRange("C7").getValue(),
  };
}

/** 
 * 差込データシートから差込リスト(before/after)を作成する
 */
function getReplaceLists(dataSheetUrl, dataSheetName) {
  // 差込データシートの表を取得
  const sheet = SpreadsheetApp.openByUrl(dataSheetUrl).getSheetByName(dataSheetName);
  const table = sheet.getDataRange().getDisplayValues();
  if (table.length < 2) throw new Error("シートにデータが存在しません");

  // 表をもとに差込前後情報をもつオブジェクトを作成する
  const keys = table[0];
  const rows = table.slice(1);
  const replaceLists = rows.map(row => {
    const replaceList = row.map((cell, index) => {
      return { before: keys[index], after: cell };
    });
    return replaceList;
  })
  return replaceLists;
}

/** 
 * フォルダを作成する
 */
function createFolder(folderName) {
  const now = Utilities.formatDate(new Date(), "JST", "yyyy-MM-dd_HH:mm:ss");
  const folder = DriveApp.createFolder(`${folderName}_${now}`);
  return [ folder.getId(), folder.getName() ];
}
<html>
<head>
  <base target="_top">
  <style>
    #icon-area {
      width: 100%;
      height: 120px;
      display: flex;
      justify-content: center;
      align-items: center;      
    }

    #spinner {
      width: 70px;
      height: 70px;
      border: 6px #ddd solid;
      border-top: 6px #2e93e6 solid;
      border-radius: 50%;
      animation: sp-anime 1.0s infinite linear;
    }

    @keyframes sp-anime {
      100% { transform: rotate(360deg); }
    }

    #success-icon {
      width: 60px;
      height: 30px;
      border-left: solid 6px #00d1b2;
      border-bottom: solid 6px #00d1b2;
      transform: rotate(-45deg);
    }

    #fail-icon {
      width: 60px;
      height: 60px;
      position: relative;
    }
    #fail-icon:before, #fail-icon:after {
      content: "";
      position: absolute;
      background-color: #f14668;
      top: calc((60px - 6px) / 2);
      width: 60px;
      height: 6px;
    }
    #fail-icon:before { transform: rotate(-45deg); }
    #fail-icon:after { transform: rotate(45deg); }
  </style>
</head>
<body>
  <div id="icon-area">
    <div id="spinner"></div>
    <div id="success-icon" style="display: none"></div>
    <div id="fail-icon" style="display: none"></div>
  </div>
  <p id="message" style="text-align: center;">message</p>

  <script>
    // dom要素を取得
    const spinner = document.getElementById("spinner");
    const successIcon = document.getElementById("success-icon");
    const failIcon = document.getElementById("fail-icon");
    const message = document.getElementById("message");

    /**
     * GAS呼び出し関数
     */
    function gasRun(func, ...args){
      return new Promise(function(resolve, reject){
        google.script.run.withSuccessHandler(function(...e){
          resolve(...e);
        }).withFailureHandler(function(...e){
          reject(...e);
        })[func](...args);
      });
    }
    
    window.onload = async function(){
      let params;                 // 処理に使うパラメーター(ユーザーの入力内容等)
      let replaceLists;           // 差込リスト
      let outputFolderId;         // 出力フォルダのID
      let outputFolderName;       // 出力フォルダ名
      const limit = 30;           // 並列処理をする上限数
      let lastIndex;              // 差込データの最終インデックス
      let index = 0;              // 次に処理される差込データのインデックス
      let successCount = 0;       // 処理成功件数
      let failCount = 0;          // 処理失敗件数

      /**
       * 差込ファイル作成処理
       */ 
      const createOutputFile = () => {
        // 未処理のデータが残っていれば差込ファイル作成を呼び出す
        if (index > lastIndex) return;
        google.script.run
          .withSuccessHandler(() => { onProcessFinish("Success") })
          .withFailureHandler(() => { onProcessFinish("Fail") })
          .createFile(params, replaceLists[index], outputFolderId, index);
        index += 1
      }

      /**
       * 差込ファイル作成 終了時処理
       */ 
      const onProcessFinish = (result) => {
        // カウントアップ、メッセージ更新
        result === "Success" ? successCount += 1 : failCount += 1;
        message.innerHTML = `PDFファイル作成中 ... ( ${successCount + failCount} / ${replaceLists.length} 件 作成済 )`;

        // 最終データを処理した場合は終了処理, それ以外の場合は再度ファイル作成処理を呼び出す
        if (successCount + failCount === replaceLists.length){
          onComplete();
        } else {
          createOutputFile();
        }
      }

      /**
       * 終了処理
       */
      const onComplete = async function(){
        spinner.style.display = "none";
        successIcon.style.display = "block";
        message.innerHTML = 
          `差込PDF作成が完了しました。(成功: ${successCount}件, 失敗: ${failCount}件)<br />` +
          "<br />" +
          "< 出力先 ><br />" +
          `マイドライブ > ${outputFolderName}`;
      }

      /**
       * メイン処理
       */ 
      try {
        // ツールシートの入力値取得
        message.innerHTML = "パラメーターを取得しています ..."
        params = await gasRun("getParams"); 

        // 差し込みデータの取得
        message.innerHTML = "差し込みデータを取得しています ..."
        replaceLists = await gasRun("getReplaceLists", params.dataSheetUrl, params.dataSheetName);

        // 出力先フォルダの作成
        message.innerHTML = "出力先フォルダを作成しています ...";
        [outputFolderId, outputFolderName] = await gasRun("createFolder", params.templateDocName);
        
        // 差込ファイル作成処理を並列で呼び出す
        lastIndex = replaceLists.length - 1;  // 差込データの最終インデックス
        message.innerHTML = `PDFファイル作成中 ... ( 0 / ${replaceLists.length} 件 作成済 )`
        for (let i = 1; i <= limit; i++){
          createOutputFile();
        }  
      } catch(e) {
        console.log(e.message);
        // エラーアイコン・エラーメッセージを表示し終了
        spinner.style.display = "none";
        failIcon.style.display = "block";
        message.innerHTML = 
          "エラーが発生しました。<br />" + 
          "シートの設定内容を修正して再度実行してください。<br />";
        return;
      }
    }
  </script>
</body>
</html>

参考:開発メモ・コード(ファイル結合版)

2022年11月28日に「ファイル結合版」を追加しました。

こちらは、雛形ドキュメントを単純コピーするのではなく、雛形ドキュメントの本文の要素を1つずつ出力ドキュメントにコピーしていく形にしています。

それにより、ファイル分割版で課題となった、ファイル作成にかかる処理時間を気にする必要がなくなったため、HtmlService(モーダル)を使う必要もなくなり、コードは単純になっています。

一方で、雛形ドキュメントの単純なコピーではなくなるので、出力されたドキュメントが本当に雛形ドキュメントと同じ(差込箇所は除いて)なのか、いまいち自信が持てません・・・。

function createMergedFile(){
  // ダイアログで実行確認
  const ui = SpreadsheetApp.getUi();
  const response = ui.alert("差込PDF作成を実行しますか?", ui.ButtonSet.OK_CANCEL);
  if (response !== ui.Button.OK) return

  // ツールシートの入力値を取得
  const toolSheet = SpreadsheetApp.getActiveSheet();
  const templateDocUrl = toolSheet.getRange("C3").getValue();
  const dataSheetUrl = toolSheet.getRange("C6").getValue();
  const dataSheetName = toolSheet.getRange("C7").getValue();

  // 参照するドキュメント・スプレッドシートを取得
  const templateDoc = DocumentApp.openByUrl(templateDocUrl);
  const templateFile = DriveApp.getFileById(templateDoc.getId());
  const templateFileName = templateFile.getName();
  const dataSheet = SpreadsheetApp.openByUrl(dataSheetUrl).getSheetByName(dataSheetName);

  // 出力フォルダの作成
  const now = Utilities.formatDate(new Date(), "JST", "yyyy-MM-dd_HH:mm:ss");
  const outputfolder = DriveApp.createFolder(`${templateFileName}_${now}`);

  // 差し込みリストの作成
  const table = dataSheet.getDataRange().getDisplayValues();
  const keys = table[0];
  const rows = table.slice(1);
  const replaceLists = rows.map(row => {
    const replaceList = row.map((cell, index) => {
      return { before: keys[index], after: cell };
    });
    return replaceList;
  })

  // 新規ドキュメントの作成(本文は空白)
  const mergedFile = templateFile.makeCopy(outputfolder);
  const mergedDoc = DocumentApp.openById(mergedFile.getId());
  const mergedDocBody = mergedDoc.getBody();
  mergedDocBody.clear();

  // 作成したドキュメントに差込を反映した本文を追加していく
  replaceLists.forEach(replaceList => {
    // 作業用Body(本文の複製)を作成する
    const tmpBody = templateDoc.getBody().copy();
    
    // 作業用Bodyに差込を適用
    replaceList.forEach(item => {
      tmpBody.replaceText(`\{\{ *${item.before} *\}\}`, item.after);
    })

    // 新規ドキュメントに作業用Bodyの全ての子要素を挿入
    for (let i = 0; i < tmpBody.getNumChildren(); i++) {
      const child = tmpBody.getChild(i);
      switch(child.getType()) {
        case DocumentApp.ElementType.LIST_ITEM:
          mergedDocBody.appendListItem(child.asListItem().copy());
          break;
        case DocumentApp.ElementType.PARAGRAPH:
          mergedDocBody.appendParagraph(child.asParagraph().copy());
          break;
        case DocumentApp.ElementType.TABLE:
          mergedDocBody.appendTable(child.asTable().copy());
          break;
      }
    }

    // 末尾に改行を挿入
    mergedDocBody.appendPageBreak();
  })

  // Bodyはclearしても先頭に空の要素が残ってしまっているため削除する
  mergedDocBody.getChild(0).removeFromParent();
  
  // 新規ドキュメントの保存・リネーム・PDF作成
  mergedDoc.saveAndClose();
  mergedDoc.setName(`DOC_${templateFileName}`);
  const pdfFile = mergedDoc.getAs('application/pdf')
    .setName(`PDF_${templateFileName}.pdf`);
  outputfolder.createFile(pdfFile);

  // 終了メッセージ
  ui.alert(
    "差込PDF作成が完了しました。" + "\n" +
    "\n" +
    `出力先: マイドライブ > ${outputfolder.getName()}` + "\n" +
    `URL: ${outputfolder.getUrl()}`
  ); 
}

コメント

  1. ドイ より:

    差し込み印刷について調べており、こちらのサイトに行きつきました。

    やりたいこと、90%できました!本当にありがとうございます。

    残りの10%としてお伺いしたいことがあります。
    もしお時間あればご教示いただけますと幸いです。

    差し込みパーツとして、スプレッドシートにQRコードをイメージ関数で生成しています。
    こちらのQRコードがドキュメントには差し込まれなくて・・・
    QRコードも差し込まれるようにするためには、どのような方法が考えられますでしょうか。
    当方、システム関連素人です。

    アドバイスを頂ければ幸いです。

  2. ichi3270 より:

    このツールは文字列をそのまま差込むだけのツールなので、image関数で取得した画像を差し込むことができません。
    スクリプトを作り込めば対応できるかもしれませんが・・・

    ご要望を満たすかどうかわかりませんが、下記URLのツールでしたら、帳票がスプレッドシートですのでQRコードも差し込み可能だと思います。(QRコード化するURLをどこかのセルに差し込んで、image関数でそのセルを参照すれば良いです)
    https://web-breeze.net/spreadsheet-merge-print/

  3. 阿部秀嗣 より:

    活用させていただきました。
    とても役に立つツールを公開していただきありがとうございます。