Google Apps ScriptでPDFフォームを操作する【GAS】
以前紹介した、Google Apps ScriptでPDFを作成するエントリーにて利用したライブラリを使って、フォーム付PDFこと、Fillable PDFのテキストフィールド等の値を集計したり、逆にテキストフィールドに値をセットして保存といった事ができるようになりました。
Excelとは違う、 入力値の集計や自動入力が難しいこのPDFを今回はGASを使って操作してみたいと思います。フォームPDFを配布して回収したデータの集計の自動化や、役場の申請用PDF(就労証明書)等への入力の自動化などができる為、非常に事務作業にとって有用なテクニックです。
目次
今回利用するスプレッドシート等
- フォームPDF操作 - Google Spreadsheet
- サンプルPDF
- PDF-LIB
- @pdf-lib/fontkit
今回はJavaScript用のライブラリであるPDF-LIBをGoogle Apps Scriptで使える形にて修正して取り込み、操作できるようにしてから、フォームPDFの値を取得してみたいと思います。おまけとしてフォームPDFのテキストボックスに値を入れたり、新規にページを追加して日本語文字列を追加するなどをチャレンジしてみています。
事前準備
PDF-LIB等を取り込む
PDFを作成するエントリーでも紹介していますが、GASのスクリプトを一個用意して以下の処置をしておきます。
- こちらのライブラリをスクリプトファイルを用意してコピペ
- コードの中のsetTimeoutはGASでは使えないので、return t()だけを残して削除
- V8ランタイムはオンにしています
また、PDFに日本語を入力する際にはfontkitを利用しますが、こちらについても、
- こちらのスクリプトをそのままGASのスクリプトとして貼り付けてあげる(今回はv1.1.1を利用)
- fontkitについても、2506行目付近のrunTimeout関数冒頭のtry{}catch(e){}手前のコードはsetTimeoutが入ってるためコメントアウト。
- fontkitのSubset指定のバグがあると言うので、4万940行辺りにあるコードに対して、修正を加えています。
PDF-LIB用に改造されたfontkitというものもあります。そちらはバージョン1.8.1が最終ですが、CDN配布されてるのは前述の1.1.1が最終。こちらのサイトのが本家のサイト。
フォームPDFを用意する
今回はフリーで利用できるLibreOfficeにてフォームPDFを作成したものをサンプルPDFとして用意しています。
- LibreOfficeで作成済みの様式ファイルを読み込ませる。
- メニューより「フォーム」→「デザインモード」を選択する
- メニューより「フォーム」⇒「テキストボックス」を選択する。
- 入力欄を設けたい場所に、範囲指定すると、ボックスが出来る。
- 出現したテキストボックスを右クリックして、コントロールのプロパティを開く
- コントロールの名前に今回は「username」と付けました。このコントロール名を元にGASから値を取得します。
- 設定が終わったら、メニューより「ファイル」⇒「エクスポート」を選択する。
- 保存する場所を指定し、ファイルの種類をPDFにしてあげる
今回は1個のコントロールしか作っていませんが、作り込んでコントロール名を元に処理を行います。
図:テキストボックス挿入画面
その他の準備
GAS側で、以下の項目用にIDやフォントファイルを準備しておく
- サンプルPDFをアップして、ファイルのIDをfillpdf変数に格納しておきます。【読込・書込みで必要】
- 値をセットしたPDFを保存するフォルダのID(targetfolder変数)【書込みのみ】
- 日本語フォントで文字を埋め込む場合に利用するフォントファイルのID(今回はへた字95というTTFをアップして、ファイルのIDを取得しておきました)=> fonts変数に格納【書込みのみ】
これでコードを動かす準備が出来ました。
問題点と解決策
PDF-LIBやfontkitのJSライブラリですが、GASのスクリプトに貼り付けて利用すると結構なファイルサイズである為か、他のコードを編集して保存するのに時間が掛かるようになります。アプリの実行速度には影響はそこまでじゃないのですが、毎回保存するのに時間が掛かるのは結構なストレスです。
これに対する解決策が、CDN等で直接配布されてる外部JSファイルをGoogle Apps ScriptにUrlfetchAppで取り込んで利用するというテクニックがあります。DriveAppを使ってドライブ上のJSファイルを取り込んでライブラリとして利用することも可能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
//eval関数で外部JSを取得させると導入できる eval(UrlFetchApp.fetch("https://unpkg.com/pdf-lib/dist/pdf-lib.js").getContentText()); //eval関数でドライブ上のJSを導入するパターン var jsfile = "ドライブ上のpdf-lib.jsのファイルIDをここに入れる" eval(DriveApp.getFileById(jsfile).getBlob().getDataAsString("UTF-8")); //setTimeoutをUtilities.sleepに置き換えてしまう setTimeout = (func, sleep) => (Utilities.sleep(sleep),func()) //asyncで関数を実行するようにする async function pdfmaker(){ //pdf-libs初期化 const { PDFDocument } = PDFLib //外部PDFを取り込む const url = 'https://www.soumu.go.jp/johotsusintokei/whitepaper/ja/r03/pdf/n0000000.pdf' const extpdf = new Uint8Array(UrlFetchApp.fetch(url).getContent()); //PDFを読込 const pdfDoc = await PDFDocument.load(extpdf) } |
- eval関数とUrlfetchAppで外部のJSをコードとして利用できるようにします。これをグローバルの変数としてロードしておく。
- DriveAppの場合はgetBlobしてgetDataAsStringでテキストとして変数に格納するとコードとして利用可能になる。
- 実際にこのライブラリを使う関数はasyncを付けて実行する
- あとは通常通り利用でき、保存する度に時間が掛かるといったことが回避できる。
- 外部のPDFも、new Uint8ArrayとUrlfetchAppを組み合わせると読み込ませる事が可能です。
- この方法は、V8が有効でなければ利用することが出来ません。
- GASではsetTimeoutが利用出来ないので、Utilities.sleepに置き換えてしまうコードも併用します。
ソースコード
フォームPDFの値を取得する
今回の記事の一番の目的であり利用シーンが最も多いと思われるフォームPDFについて、テキストボックスに入ってる値を取り出すコードです。フォームPDFを配布してユーザに入力してもらい回収=>そのファイルをスプレッドシートに転記するのに手作業をせずにGASで処理をするというものになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
//pdf-libs初期化 const { PDFDocument } = PDFLib //フォーム付PDFのID var fillpdf = "ここにPDFファイルのIDをいれる" //フォント var fonts = "ここにフォントファイルのIDを入れる" //保存先フォルダID var targetfolder = "ここにPDFの保存先フォルダのIDを入れる" //フォーム付PDFの値を取得 async function getfillForm() { // pdfのファイル情報取得 var blob = DriveApp.getFileById(fillpdf).getBlob(); var bytes = blob.getBytes(); //pdfを読み込み var bytesMine = Utilities.base64Encode(bytes); var pdfDoc = await PDFDocument.load(bytesMine); //フォームを取得 const form = pdfDoc.getForm() //usernameフィールドを読み込み const userField = form.getTextField('username') //入力されている値を取得 var tomato = userField.getText() console.log(tomato); } |
- テキストの読み込みに関しては日本語が読み込めるので特に工夫は不要です。
- PDFファイルを取得しgetBytesで格納したものをBase64Encodeしたものが加工するデータとなります。
- 事前にスクリプトファイルに入れておいたPDF-LIBを用いてPDFDocumentとして初期化して読み込んでおきます。
- getFormでフォームを取得し、getTextField=>getTextにてusernameを指定して値を取得する事が可能です。
- 他にもDropdown, Button, RadioButton, Signature等様々なコントロールを取得可能です。
これをテキストボックスの数 + ファイルの数分だけ回して一括で取得する事が可能になり、手作業を自動化する事が可能です。少々動作が遅いので、あまり大量のフォームPDFを裁けないので、その場合は少し処理を工夫する必要があります。
フォームPDFに値をセットする
PDF-LIBはsetTextでは日本語を扱えないため、fontkitの力を借りてカスタムフォントを適用するのですが、pdf-libの問題点を回避するコードを追加する事で日本語でテキストフィールドにwinansiエラーを回避して挿入することが出来ました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
async function setfillForm(){ // PDFのファイル情報取得 var blob = DriveApp.getFileById(fillpdf).getBlob(); var bytes = blob.getBytes(); //pdfを読み込み var bytesMine = Utilities.base64Encode(bytes); var pdfDoc = await PDFDocument.load(bytesMine); //ttfを読み込み var fblob = DriveApp.getFileById(fonts).getBlob(); var fbytes = fblob.getBytes(); var fontbytes = Utilities.base64Encode(fbytes); pdfDoc.registerFontkit(fontkit) //サブセット指定で必要なフォントのみ埋め込む const customFont = await pdfDoc.embedFont(fontbytes); //フォームを取得 const form = await pdfDoc.getForm(); //usernameフィールドを読み込み const userField = form.getTextField('username') //テキストフィールドにカスタムフォントを適用 const rawUpdateFieldAppearances = form.updateFieldAppearances.bind(form); form.updateFieldAppearances = function () { return rawUpdateFieldAppearances(customFont); }; //値をセットする var str = "ブリジストン"; userField.setText(str) //値を保存する const pdfBytes = await pdfDoc.save(); //ファイルを生成する var pdf = Utilities.newBlob(pdfBytes, "application/pdf", "tomato2.pdf"); var pdfid = DriveApp.getFolderById(targetfolder).createFile(pdf).getId(); } |
- 途中まではテキストボックス読み込みのコードと同じです。
- getTextFieldで指定したコントロール名の場所に対して、setTextをすることで文字を入力可能です。
- 入力後は別のPDFファイルとして指定のフォルダにcreateFileで出力します。
- setImageというメソッドを利用すれば指定の図形コントロールに対して画像をはめ込むことも可能です。
日本語文字列をstrに入れてしまうと、現時点では「winansi cannot encode」とエラーが出てしまいます。そこでこちらの回避策であるrawUpdateFieldAppearancesを間に入れてからセットをすると見事に日本語を入れることが出来ました。回避策で作ったサンプルPDFはこちら。
図:日本語でフォーム入力出来ないのが玉に瑕
図:回避策で日本語書込み成功
日本語テキストを直接書き込む
前述のテキストコントロールは回避策を講じないと日本語入力が出来ませんでしたが、テキストコントロールでなければ日本語でラベルを埋め込むことは可能です。その為にはfontkitを利用してカスタムフォントを読み込む必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
async function setfillForm(){ // マージ元のファイル情報取得 var blob = DriveApp.getFileById(fillpdf).getBlob(); var bytes = blob.getBytes(); //pdfを読み込み var bytesMine = Utilities.base64Encode(bytes); var pdfDoc = await PDFDocument.load(bytesMine); //ttfを読み込み var fblob = DriveApp.getFileById(fonts).getBlob(); var fbytes = fblob.getBytes(); var fontbytes = Utilities.base64Encode(fbytes); //カスタムフォントを定義 pdfDoc.registerFontkit(fontkit) const customFont = await pdfDoc.embedFont(fontbytes,{subset:true}); //ページを追加する const page = pdfDoc.addPage(); const { width, height } = page.getSize(); //日本語で文字を挿入 const fontSize = 30; page.drawText('日本語で文字を挿入', { x: 50, y: height - 4 * fontSize, size: fontSize, font: customFont }); //値を保存する const pdfBytes = await pdfDoc.save(); //ファイルを生成する var pdf = Utilities.newBlob(pdfBytes, "application/pdf", "tomato.pdf"); var pdfid = DriveApp.getFolderById(targetfolder).createFile(pdf).getId(); } |
- TTFファイルを取得しfontkitをpdfDocに対して埋め込みます。この時、subset:trueのオプションを指定すると必要なフォントのデータだけに絞れるのでファイルサイズを低くすることが可能です。
- addPageでPDFに新しいページを追加する事が可能です。
- setTextではなくdrawTextで書き込みます。この時必ず用意しておいたcustomFontを指定する事が肝要です。
- 最期にファイルを出力する部分は前項と同じ。
図:へた字フォントで埋め込めた
画像を指定の座標に貼り付ける
判子などの画像を挿入する作業のために、Acrobat Proを社内で使ったりしてるケースがあるのですが、たったそれだけのために年間何万円もライセンスを払ってるのは実に馬鹿らしいことで。そこで、こういったちょっとした用途の代表例である判子の自動追加などについては、pdf-libの機能を使って自動化をすることが可能です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
//印鑑イメージ var human1 = "ここに印鑑.pngのファイルIDを入れる"; //画像ファイルを指定の位置に挿入する async function insertHanko(){ // マージ元のファイル情報取得 var blob = DriveApp.getFileById(fillpdf).getBlob(); var bytes = blob.getBytes(); //pdfを読み込み var bytesMine = Utilities.base64Encode(bytes); var pdfDoc = await PDFDocument.load(bytesMine); //画像データを読込 var img1 = Utilities.base64Encode(DriveApp.getFileById(human1).getBlob().getBytes()); //埋め込みとして定義(PNGを埋め込む) const hanko1 = await pdfDoc.embedPng(img1) //ページを取得 const page = pdfDoc.getPage(0) //hanko1を埋め込み page.drawImage(hanko1, { x: 450, y: 103, width: 50, height: 50, }) //値を保存する const pdfBytes = await pdfDoc.save(); //ファイルを生成する var pdf = Utilities.newBlob(pdfBytes, "application/pdf", "imageman.pdf"); var pdfid = DriveApp.getFolderById(targetfolder).createFile(pdf).getId(); } |
- この作業のポイントはpage.drawImageの際のx,y, width,heightの値の決定。widthやheightは画像ファイルの幅と高さ。x,yが座標になります。
- 問題は特にx,yの座標の指定ですが、対象のページの左下が0の起点となります。何度か辺りを付けて調整を今の所するしかありません。指定単位はピクセルとなります。
- こちらのファイルが右下の承認欄に印鑑を追加したPDFのサンプルとなります。
図:判子画像を自動追加できた
PDFの指定のページのみ取り出す
PDFの分割といった機能はPDF-LIBには無いのですが、指定のページのみを取り出して新しい別のPDFとして作成する機能があるため、実質的にPDFの分割的な機能を装備することが可能です。
実務で見積書と発注書が一緒になっていて、2ページ目の発注書のみ印鑑を押して送りたい場合、2ページ目のみを取り出してそのBlobデータに対して前述の処理をするといった場合、この処理だけ人力というのは非生産的です。故に以下のようなコードを持ってまず切り離して処理をするようにすると良いでしょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
//2ページ目だけ取り出す async function splitman(title, fileId, spnum){ //分割元のファイル情報取得 var blob = DriveApp.getFileById(fileId).getBlob(); var bytes = blob.getBytes(); //pdfを読み込み var bytesMine = Utilities.base64Encode(bytes); var pdfDoc = await PDFDocument.load(bytesMine); //PDFのページ数をカウント const numberOfPages = pdfDoc.getPages().length; //指定のページだけ抜き出し const subDocument = await PDFDocument.create(); var pdfBytes = ""; for (let i = 0; i < numberOfPages; i++) { //指定ページだけを取り出し if(i == spnum){ //新規PDFファイルにコピー const [copiedPage] = await subDocument.copyPages(pdfDoc, [i]) subDocument.addPage(copiedPage); pdfBytes = await subDocument.save(); break; } } //新規PDFファイルについてBlobのまま印鑑捺印処理 var ret = setInkan(title,pdfBytes); //HTML側へ処理を返す return ret; } |
- 途中まではこれまでのコードと同じです。
- copyPagesにてコピーし、新規に作成したsubDocumentにaddPageで追加します。
- saveにてBlobデータのByteコードを保存します。これはメモリ上の存在なのでそのまま印鑑処理に渡せば2ページ目だけ分割し、捺印したデータを新規に保存するといった一連の処理が作れます(いちいち2ページ目だけ新規にPDFとして保存してから読み込んで処理は不要)
- 同じ仕組みで複数のPDFを逆に結合して1枚のPDFにするといったこともこのコードで可能になります。
関連リンク
- PDFファイル上のフィールドの値を操作するVBAマクロ
- Error: WinAnsi cannot encode with some non-latin text dropdown value #1152
- Fill Form - JSFiddle
- DriveApp.createFile( e.parameter.blob); Not executing
- How to use a external Javascript library (PDF lib) in Apps script?
- Node.js + pdf-libで日本語を含むPDFファイルを生成する
- すぐに使える有給休暇届・申請書テンプレート(Excel・Word・PDF)
- 『PDF-LIB』でフォント サブセット(subset)化に失敗する現象の解決
- pdf-lib: how to add custom font
- Javascript Convert ansi to utf8
- へた字95 - Vector
- GAS の Blob とファイル変換まとめ
- PDFから特定の位置をターゲットに文字列を抽出する方法!
- PDF上のテキストとその座標
- PDFの座標系、ページの大きさ、ヘッダーとフッター、トンボ
- Split pdf into multiple pages preferably into its pages and save the various files in a folder using node js