「役所や取引先の申請書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 で入力/未ログイン/パスワード付きPDF | 403/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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Nuxtで作るPDF管理システム 第0回】全体像とNuxt 4プロジェクトの土台づくり
- 【Nuxtで作るPDF管理システム 第1回】PDFをアップロードして安全に保存する
- 【Nuxtで作るPDF管理システム 第2回】ファイル情報をDBに持ち、一覧を表示する
- 【Nuxtで作るPDF管理システム 第3回】検索・絞り込み・ページングをつける
- 【Nuxtで作るPDF管理システム 第4回】ログイン機能を入れる
- 【Nuxtで作るPDF管理システム 第5回】権限管理と「認可つきダウンロード」
- 【Nuxtで作るPDF管理システム 第6回】ブラウザでPDFをプレビューする
- 【Nuxtで作るPDF管理システム 第7回】PDFの結合と分割
- 【Nuxtで作るPDF管理システム 第8回】注釈・スタンプ・署名画像を貼る
- 【Nuxtで作るPDF管理システム 第9回】PDFフォーム(AcroForm)に入力する(この記事)
- 【Nuxtで作るPDF管理システム 第10回】見積書・請求書をデータから生成する
- 【Nuxtで作るPDF管理システム 第11回】本番運用へ:S3への切り替えとデプロイ










コメント
コメント一覧 (1件)
[…] 前回の第9回:PDFフォーム(AcroForm)に入力するでは、既存のPDFの入力欄に値を入れました。今回は既存のPDFを使わず、白紙から帳票を組み立てます。 […]