MENU

問い合わせ


    【Nuxtで作るPDF管理システム 第9回】PDFフォーム(AcroForm)に入力する

    「役所や取引先の申請書PDFに、毎回同じ項目を手で入力している」「入力欄のあるPDFなのに、印刷して手書きしている」。入力欄付きのPDFは身近ですが、システムから入力するとなると、日本語が表示されない・入力後に書き換えられてしまう、といった壁があります。今回は、PDFに定義された入力欄(フォーム)の一覧を取り出し、画面から日本語で入力して、新しい版として保存する機能を作ります。

    前回の第8回:注釈・スタンプ・署名画像を貼るでは、クリックした位置に日本語のテキストや日付印を焼き込みました。今回は「位置」ではなく、PDFがあらかじめ用意している「入力欄」に値を入れます。

    「PDFのフォームに入力するのと、前回の書き込みは何が違うの?」

    結論から言うと、入力欄(AcroForm)が定義されているPDFなら、欄の名前を指定するだけで、決められた位置・大きさに値を入れられます。座標を合わせる必要がなく、値の読み出しもできるため、業務データとの連携に向いています。ただし、日本語の表示にはフォントの指定が必要で、入力後に書き換えられたくない場合は「フラット化」という処理を加えます。

    目次

    PDFフォーム(AcroForm)とは

    AcroForm は、PDFの中に入力欄を定義する仕組みです。ビューアで開くと、欄をクリックして文字を入力したり、チェックを付けたりできます。

    欄の種類pdf-lib のクラスこのサンプルでの入力
    テキスト(1行・複数行)PDFTextField文字列。最大文字数(MaxLen)があれば守る
    チェックボックスPDFCheckBoxオン/オフ
    ドロップダウンPDFDropdown選択肢から1つ
    ラジオボタンPDFRadioGroup選択肢から1つ
    リスト・ボタン・電子署名欄などその他対象外(一覧に「入力できない欄」として表示)

    それぞれの欄には名前が付いています。システムからは「applicant_name に『佐藤 花子』を入れる」のように、名前で指定して値を入れます。欄の名前は、PDFを作った人が付けたものなので、英数字のこともあれば日本語のこともあります。

    📰 出典:pdf-lib API ドキュメント PDFForm(getFields / updateFieldAppearances / flatten)

    入力欄が「ある」PDFと「ない」PDF

    紙をスキャンしただけのPDFや、Word から普通に書き出したPDFには、入力欄はありません。見た目に枠があっても、それはただの線です。入力欄のないPDFに書き込む場合は、第8回の方法(座標を指定した書き込み)を使います。また、第7回で実測したとおり、このサンプルで結合したPDFは入力欄が失われています(今回も、入力欄のあるPDFを結合すると一覧が空になることを確認しました)。

    入力欄の一覧を取り出す

    server/utils/pdf/form.ts(抜粋)

    /** 入力欄1つを、画面に渡す形に変換する */
    function describeField(field: PDFField): FormFieldInfo {
      const name = field.getName()
      const readOnly = field.isReadOnly()
      if (field instanceof PDFTextField) {
        return { type: 'text', name, readOnly, value: field.getText() ?? '', multiline: field.isMultiline(), maxLength: field.getMaxLength() ?? null }
      }
      if (field instanceof PDFCheckBox) {
        return { type: 'checkbox', name, readOnly, value: field.isChecked() }
      }
      if (field instanceof PDFDropdown) {
        return { type: 'dropdown', name, readOnly, value: field.getSelected()[0] ?? '', options: field.getOptions() }
      }
      if (field instanceof PDFRadioGroup) {
        return { type: 'radio', name, readOnly, value: field.getSelected() ?? '', options: field.getOptions() }
      }
      return { type: 'unsupported', name, readOnly, kind: field.constructor.name }
    }
    
    /** PDFに定義されている入力欄の一覧(入力欄が無ければ空配列) */
    export async function listFormFields(data: Uint8Array): Promise<FormFieldInfo[]> {
      const { form } = await loadForm(data)
      return form.getFields().map(describeField)
    }

    getFields() で全部の欄を取り出し、種類ごとに「現在の値」「選択肢」「複数行か」「最大文字数」をまとめます。返す形(FormFieldInfo)は shared/utils/form-fields.ts に型として定義し、画面とサーバーで共通にしました。

    server/api/files/[id]/fields.get.ts

    /** PDFフォームの入力欄の一覧(現在の版)。viewer 以上 */
    export default defineEventHandler(async (event) => {
      await requireRole(event, 'viewer')
      const id = await getFileIdParam(event)
      const file = getEditableFile(id)
      const fields = await listFormFields(await readCurrentPdf(file))
      return { id, versionNo: file.currentVersion, fields }
    })

    値を入れて、日本語フォントで見た目を作り直す

    server/utils/pdf/form.ts(抜粋)

    export async function fillForm(data: Uint8Array, values: FormValues, flatten: boolean): Promise<Uint8Array> {
      const { doc, form } = await loadForm(data)
      const font = await embedJapaneseFont(doc)
    
      for (const [name, value] of Object.entries(values)) {
        const field = form.getFieldMaybe(name)
        if (!field) badRequest(`入力欄「${name}」がありません`)
        if (field.isReadOnly()) badRequest(`入力欄「${name}」は読み取り専用です`)
    
        if (field instanceof PDFTextField && typeof value === 'string') {
          const max = field.getMaxLength()
          if (max !== undefined && [...value].length > max) badRequest(`「${name}」は${max}文字以内で入力してください`)
          const missing = findUnsupportedChars(font, value)
          if (missing.length > 0) badRequest(`「${name}」にフォントに無い文字が含まれています:${missing.join(' ')}`)
          // 1行の欄に改行が来たら空白にする(pdf-lib は1行の欄の改行をエラーにする)
          const text = field.isMultiline() ? value : value.replace(/\r?\n/g, ' ')
          field.setText(text === '' ? undefined : text)
          fixAutoFontSize(field)
        } else if (field instanceof PDFCheckBox && typeof value === 'boolean') {
          if (value) field.check()
          else field.uncheck()
        } else if ((field instanceof PDFDropdown || field instanceof PDFRadioGroup) && typeof value === 'string') {
          if (value === '') field.clear()
          else if (field.getOptions().includes(value)) field.select(value)
          else badRequest(`「${name}」の選択肢に「${value}」はありません`)
        } else {
          badRequest(`入力欄「${name}」には、この形式の値を入れられません`)
        }
      }
    
      // 既定のフォント(Helvetica)は日本語を描けないため、日本語フォントで全入力欄の外観を作り直す
      form.updateFieldAppearances(font)
      if (flatten) {
        form.flatten()
        removeDanglingAnnots(doc)
      }
      return doc.save()
    }

    「値」と「見た目(外観)」は別に保存されている

    PDFの入力欄には、入力された「値」と、ページ上にどう見えるかを描いた「見た目(外観)」が別々に保存されています。pdf-lib は値を変えると外観も作り直しますが、既定では標準フォント(Helvetica)を使うため、日本語の値を入れて保存すると「WinAnsi cannot encode」というエラーになります(筆者の環境で確認)。そこで、第8回で用意した日本語フォントを updateFieldAppearances(font) に渡し、すべての欄の外観を日本語フォントで作り直しています。

    入力値は必ずサーバーで確かめる

    画面では最大文字数や選択肢を部品で制限していますが、APIは画面を通さなくても呼べます。サーバー側で、欄の存在・読み取り専用かどうか・種類と値の型・最大文字数・選択肢にある値か・フォントに無い文字を、すべて確認しています。PDFに「読み取り専用」と設定された欄(受付番号など)は、画面でも入力部品を出さず、APIでも 400 にします。

    文字サイズ「自動」の複数行の欄に注意

    PDFの入力欄には文字サイズを「自動」(0)にできる設定があります。pdf-lib は自動のとき「枠に収まる最大の大きさ」で文字を描くため、複数行の欄に短い文を入れると、極端に大きな文字になりました。そこで、自動の複数行の欄だけ 10pt に固定しています。

    /** DA(既定の見た目)の文字サイズが 0=「自動」 */
    const AUTO_FONT_SIZE = /(^|\s)0(\.0+)?\s+Tf\b/
    
    function fixAutoFontSize(field: PDFTextField): void {
      const da = field.acroField.getDefaultAppearance()
      if (field.isMultiline() && da && AUTO_FONT_SIZE.test(da)) field.setFontSize(10)
    }

    📰 出典:pdf-lib API ドキュメント PDFTextField(setText / getMaxLength / setFontSize)

    フラット化:入力欄をなくして内容を固定する

    フラット化とは、入力欄の見た目をページの内容として焼き付け、入力欄そのものを取り除く処理です。フラット化したPDFは、どのビューアで開いても値を書き換えられません。提出用・保管用の「確定版」を作るときに使います。

    画面ではチェックボックスで選べるようにし、選んだときだけ form.flatten() を呼びます。ここで1つ問題が見つかりました。pdf-lib 1 系の flatten() は、入力欄の部品を消したあとも、ページの「注釈の一覧」(/Annots)に消した部品への参照を残します。表示には影響しませんでしたが、MuPDF で開くと「参照先が見つからない」という警告が出ました。そこで、参照先の無いものを一覧から取り除く処理(removeDanglingAnnots)を足しています。

    function removeDanglingAnnots(doc: PDFDocument): void {
      for (const page of doc.getPages()) {
        const annots = page.node.Annots()
        if (!annots) continue
        const kept = annots.asArray().filter((a) => !(a instanceof PDFRef) || doc.context.lookup(a) !== undefined)
        if (kept.length === 0) page.node.delete(PDFName.of('Annots'))
        else page.node.set(PDFName.of('Annots'), doc.context.obj(kept))
      }
    }

    入力APIの本体

    server/api/files/[id]/fill.post.ts(抜粋)

    export default defineEventHandler(async (event) => {
      const user = await requireRole(event, 'editor')
      const id = await getFileIdParam(event)
      const body = await readValidatedBody(event, (b) => fillBodySchema.safeParse(b))
      if (!body.success) {
        throw createError({ statusCode: 400, statusMessage: '入力内容が正しくありません' })
      }
      const { values, flatten } = body.data
    
      const file = getEditableFile(id)
      const result = await fillForm(await readCurrentPdf(file), values, flatten)
      assertResultSize(result)
    
      const note = `フォーム入力:${Object.keys(values).length}項目${flatten ? '(入力欄をなくして固定)' : ''}`
      const { versionNo } = await addVersion({
        fileId: id, data: result, pageCount: file.pageCount, operation: 'fill', note, userId: user.id,
      })
      return { id, versionNo }
    })

    結果は第7回からの版管理に乗せ、版の種類に 'fill' を追加しました(第8回と同じく、マイグレーションは不要でした)。フラット化しても元の版は残るので、入力し直したいときは過去の版から取り出せます。

    画面:欄の種類に合わせて入力部品を並べる

    詳細画面(app/pages/files/[id]/index.vue)に「フォームに入力する」リンクを足し、入力画面 app/pages/files/[id]/fill.vue を作りました。Nuxt では pages/files/[id].vue と pages/files/[id]/ フォルダを両方置くと親子のページ(入れ子のルート)として扱われるため、詳細画面を [id]/index.vue に移動しています。

    app/pages/files/[id]/fill.vue(抜粋)

    <script setup lang="ts">
    definePageMeta({ requiredRole: 'editor' })
    const { data, error } = await useFetch(() => `/api/files/${id.value}/fields`)
    
    /** 入力できる欄(読み取り専用・未対応の種類を除く) */
    const editable = computed(() => (data.value?.fields ?? []).filter(
      (f): f is Exclude<FormFieldInfo, { type: 'unsupported' }> => f.type !== 'unsupported' && !f.readOnly,
    ))
    // 画面の入力値(初期値はPDFに入っている値)
    const values = ref<FormValues>({})
    </script>
    
    <template>
      <div v-for="f in editable" :key="f.name" class="fill__row">
        <label :for="`f-${f.name}`">{{ f.name }}</label>
        <template v-if="f.type === 'text'">
          <textarea v-if="f.multiline" :id="`f-${f.name}`" v-model="values[f.name] as string" rows="4" :maxlength="f.maxLength ?? 2000" />
          <input v-else :id="`f-${f.name}`" v-model="values[f.name] as string" type="text" :maxlength="f.maxLength ?? 2000">
        </template>
        <input v-else-if="f.type === 'checkbox'" :id="`f-${f.name}`" v-model="values[f.name] as boolean" type="checkbox">
        <!-- ドロップダウンは select、ラジオボタンは選択肢ごとの radio(省略) -->
      </div>
      <label class="fill__flatten">
        <input v-model="flatten" type="checkbox">
        入力欄をなくして固定する(フラット化。以後はどのビューアでも入力し直せません)
      </label>
    </template>

    保存すると詳細画面に戻り、第6回のプレビューに入力後の版が表示されます。入力画面をもう一度開くと、PDFに入っている値が初期値として表示されます。

    動作確認の方法

    npm run build
    node .output/server/index.mjs

    入力欄付きのPDFを2種類(別々のツールで作成。欄の名前が英数字のものと日本語のもの)用意し、curl と自動操作のブラウザ(ヘッドレスの Chrome)で確認しました。出来上がったPDFは pdf.js と MuPDF で開いています。

    操作結果
    入力欄の一覧テキスト・複数行・ドロップダウン・ラジオ・チェックボックス・読み取り専用の欄を取得。入力欄の無いPDFは空の一覧
    日本語(「髙﨑」を含む)・改行・選択肢・チェックを入力新しい版ができ、2種類のビューアで同じ内容が表示。値もPDFに保存されている
    日本語の欄名・日本語の選択肢のフォーム同様に入力・表示できる
    フラット化して保存入力欄が0件になり、値はページの文字として残る。警告も出ない
    読み取り専用の欄/無い欄/型違い/選択肢に無い値/最大文字数超え/絵文字いずれも 400
    viewer で入力/未ログイン/パスワード付きPDF403/401/400
    1行の欄に改行を含む値空白に置き換えて保存
    文字サイズ「自動」の複数行の欄対処前は極端に大きな文字、対処後は 10pt で表示
    入力欄のあるPDFを結合した後の一覧空(第7回の結合で入力欄が失われる)
    入力済みのフォームに第8回の日付印を押す成功し、入力欄と値はそのまま残る
    画面:入力 → 保存詳細画面に戻り、履歴に「フォーム入力」、プレビューに入力内容。viewer が入力画面を開くと 403

    Acrobat Reader などほかのビューアでの表示、フォームの計算式(合計欄の自動計算など)や入力チェックのスクリプトを持つPDFは確認していません。

    つまずきやすい点・注意点

    • 欄の名前が分かりにくい:実際の申請書PDFでは、欄の名前が「Text1」「Text2」のような機械的なものだったり、画面の見出しと一致しなかったりします。業務で使うなら、欄の名前と画面の見出しの対応表を用意する必要があります。
    • 計算式やスクリプトは動かない:PDFの入力欄には、合計を自動計算するなどのスクリプトを持たせられますが、サーバーで値を入れてもスクリプトは実行されません。合計欄なども、システム側で計算して入れる必要があります。
    • 電子署名欄のあるPDF:署名済みのPDFに入力すると、署名の検証が通らなくなるのが一般的です。このサンプルでは電子署名欄は入力対象外にしています。
    • ビューアで開き直すと見た目が変わることがある:フラット化しないPDFは、ビューアが自分で見た目を作り直す場合があります。提出用には、フラット化した版を使うのが確実です。
    • フォントの埋め込み:入力のたびに、日本語フォントのサブセット(数万バイト)が追加されます。

    発注者向けメモ:「PDFに入力できるか」は、PDFの作り次第

    「この申請書PDFにシステムから入力したい」という要望は、PDFに入力欄が定義されているかどうかで難易度が大きく変わります。入力欄があれば、今回のように名前を指定して入力できます。入力欄がなければ、項目ごとに座標を調べて書き込む(第8回の方法)ことになり、様式が変わるたびに調整が必要です。

    確認すること影響
    対象のPDFに入力欄があるか無ければ座標指定での書き込みになり、様式ごとの調整作業が増える
    様式がどのくらいの頻度で変わるか役所や取引先の様式が改定されるたびに、欄の対応を直す必要がある
    提出用に書き換え不可にする必要があるかフラット化の有無、保存する版の運用が変わる
    合計の自動計算・入力チェックがあるかスクリプトは動かないため、同じ処理をシステム側で作る必要がある
    署名済みのPDFに入力するか署名が無効になるため、運用の見直しが必要
    • 実物のPDFを渡す:「申請書」と言っても作りはさまざまです。実際に使うPDFを全種類渡し、入力欄の有無を開発会社に確認してもらいます。
    • 欄と業務データの対応を決める:どの欄に、どのデータ(社員マスタ・案件情報など)を入れるかの対応表は、業務を知っている発注側が作るとスムーズです。
    • 様式改定時の担当を決める:様式が変わったとき、誰がいつ対応するか(保守契約の範囲に入るか)を決めておきます。

    打ち合わせでは、次のように聞いてみてください。

    • 「このPDFに入力欄は定義されていますか?無い場合はどう対応しますか?」
    • 「日本語で入力した内容が、Acrobat Reader やスマホのビューアでも正しく表示されますか?」
    • 「提出用に、入力後に書き換えられない形(フラット化)で保存できますか?」
    • 「様式が改定されたとき、対応にどのくらいの作業が必要ですか?」
    • 「PDFの中の自動計算や入力チェックは、システム側でどう再現しますか?」

    まとめと次回予告

    第9回では、PDFフォーム(AcroForm)の入力欄を一覧にし、画面から日本語で入力して保存する機能を作りました。

    • getFields() で欄の種類・現在の値・選択肢・最大文字数を取り出し、欄の種類に合わせた入力部品を並べる
    • 日本語の値は、日本語フォントを updateFieldAppearances(font) に渡して見た目を作り直す
    • 文字サイズ「自動」の複数行の欄は、pdf-lib では文字が大きくなりすぎるため 10pt に固定した
    • フラット化で入力欄をなくして確定版にできる。pdf-lib の flatten() が残す不要な参照は取り除く

    次回は「見積書・請求書をデータから生成する」です。宛先・明細・税率などのデータ(JSON)から、日本語の帳票PDFを一から作ります。明細が多いときの改ページや、合計・消費税の計算もあわせて扱います。

    この連載の記事一覧

    この記事は連載「Nuxtで作るPDF管理システム」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次