「届いた書類に『確認しました』と一言書き込みたい」「承認欄に日付入りの印を押したい」「紙に印刷してサインしてスキャンし直す手間をなくしたい」。PDFで書類を回している職場では、こうした要望が必ず出てきます。今回は、PDFのプレビュー上でクリックした位置に、日本語のテキスト・日付印・手書き署名の画像を書き込み、新しい版として保存する機能を作ります。
前回の第7回:PDFの結合と分割では、pdf-lib で結合・分割を行い、元のPDFを上書きしない版管理を作りました。今回もその仕組みに乗せて、「書き込んだら新しい版」にします。
「PDFに文字や印を入れるだけなら簡単そう。何か難しいところがあるの?」
結論から言うと、難しいのは「画面の座標とPDFの座標の変換」と「日本語フォントの埋め込み」の2点です。画面は左上が原点、PDFは左下が原点で、単位も違います。さらに、PDF編集ライブラリの標準フォントは日本語を描けないため、日本語フォントを自分で用意して埋め込む必要があります。今回はこの2点を実際に検証した結果とあわせて説明します。
今回作る書き込み機能の全体像
できるようになること
| 種類 | 内容 | 置き方 |
|---|---|---|
| テキスト | 日本語の文字(最大200文字・改行可)。大きさ9〜24pt、色は黒・赤・青 | クリックした位置が1行目の左上 |
| 日付印 | 丸枠の中に「承認」「確認」「受領」「却下」、日付、押した人の名前 | クリックした位置が印の中心 |
| 署名 | 画面上の枠に指やマウスで書いたサインを PNG 画像にして貼る | クリックした位置が画像の左上 |
複数の書き込みを「予定」として画面上に並べ、最後にまとめて保存します。保存すると、書き込みがページの内容として焼き込まれた(=ページの一部として描き込まれた)新しい版ができます。
画面とサーバーの役割分担
| 処理 | 場所 | 使うもの |
|---|---|---|
| プレビュー表示、クリック位置 → PDFの座標への変換 | ブラウザ | pdfjs-dist(第6回のビューア) |
| 手書き署名の入力 → PNG 化 | ブラウザ | Canvas |
| 権限確認、文字・印・画像の焼き込み、新しい版の保存 | サーバー | pdf-lib、@pdf-lib/fontkit(日本語フォント) |
第7回と同じく、画面からは「どのページの・どの座標に・何を書くか」だけを送り、PDFの加工はサーバーで行います。日付印の日付と名前も、画面から送らせずにサーバー側で入れます。画面から名前を送れる作りにすると、他人の名前の印を押せてしまうからです。
「注釈」と「焼き込み」の違い
PDFには、ビューアの機能で付け外しできる「注釈(コメント・付箋など)」という仕組みもあります。今回はそれを使わず、ページの内容として描き込む「焼き込み」にしました。焼き込んだ文字や印は、一般的なPDFビューアの注釈機能では消せません。消したいときは、第7回の版の履歴から書き込む前の版を取り出します。
画面の座標をPDFの座標に変換する
| 原点 | Y軸の向き | 単位 | |
|---|---|---|---|
| 画面(Canvas) | 左上 | 下向きに増える | CSS ピクセル(拡大率で変わる) |
| 左下 | 上向きに増える | ポイント(1/72インチ) |
この変換は、pdfjs-dist がページを描くときに使う viewport(表示の設定)が持っている convertToPdfPoint で行えます。逆向きの convertToViewportPoint を使うと、PDFの座標から画面上の位置を求められます。拡大縮小しても、ページに切り抜き範囲(CropBox)が設定されていても、同じ関数で正しく変換できます。
📰 出典:PDF.js ソースコード page_viewport.js(convertToPdfPoint / convertToViewportPoint)
第6回のビューア部品に、クリック位置を知らせる機能と、予定の位置に番号を重ねる機能を足しました。
app/components/PdfViewer.client.vue(追加部分)
<script setup lang="ts">
const props = defineProps<{
src: string
/** true のとき、クリックした位置をPDFの座標に変換して pick イベントで知らせる */
pickable?: boolean
markers?: ViewerMarker[]
}>()
const emit = defineEmits<{
pick: [point: { page: number, x: number, y: number }]
}>()
/** 表示中のページの viewport(画面の座標 ⇔ PDFの座標の変換に使う) */
const viewport = shallowRef<PageViewport | null>(null)
// render() の中で viewport.value = page.getViewport({ scale: scale.value }) を保存しておく
// クリック位置(Canvas 左上からの CSS ピクセル)→ PDFの座標(左下が原点・単位ポイント)
function onCanvasClick(e: MouseEvent) {
if (!props.pickable || !viewport.value) return
const [x, y] = viewport.value.convertToPdfPoint(e.offsetX, e.offsetY) as [number, number]
emit('pick', { page: pageNumber.value, x, y })
}
// 表示中のページの印を、PDFの座標 → 画面の座標に変換して重ねる(拡大縮小しても位置が合う)
const visibleMarkers = computed(() => {
const vp = viewport.value
if (!vp) return []
return (props.markers ?? []).filter((m) => m.page === pageNumber.value).map((m) => {
const [left, top] = vp.convertToViewportPoint(m.x, m.y) as [number, number]
return { ...m, left, top }
})
})
</script>
<template>
<div class="viewer__sheet">
<canvas ref="canvas" :class="{ 'viewer__canvas--pick': pickable }" @click="onCanvasClick" />
<span v-for="(m, i) in visibleMarkers" :key="i" class="viewer__marker"
:style="{ left: `${m.left}px`, top: `${m.top}px` }">{{ m.label }}</span>
</div>
</template>
座標の計算に使うのは、Canvas の実寸(高解像度ディスプレイ向けに大きく描いている)ではなく、CSS 上の大きさです。第6回で Canvas を devicePixelRatio 倍で描いていても、offsetX・offsetY と viewport はどちらも CSS ピクセルなので、そのまま変換できます。
日本語フォントを埋め込む:subset の実測結果
pdf-lib の標準フォントはラテン文字だけに対応しており、日本語を書くにはフォントファイルを用意し、公式のアドオン @pdf-lib/fontkit を登録して埋め込みます。埋め込みには、使った文字だけを入れる「サブセット」(subset: true)と、フォント全体を入れる方法があります。
📰 出典:pdf-lib README(Embed Font and Measure Text、fontkit)
サブセットには「文字が化ける」という話があるため、pdf-lib 1 系+ @pdf-lib/fontkit 1 系で、日本語の文章を実際に書き込み、2種類のビューア(pdf.js と MuPDF)で表示して確かめました(2026年9月、筆者環境)。
| フォント(形式) | subset: true | subset: false |
|---|---|---|
| Noto Sans JP(OpenType/CFF) | 文字が欠ける・別の字になる | pdf.js では正常、MuPDF では別の字になる。約4MB 増 |
| Noto Sans JP(Google Fonts 配布の可変フォント) | 文字が欠ける | 正常。ただし最も細い太さ(Thin)で描かれる。約6MB 増 |
| BIZ UDPゴシック(TrueType) | 文字が欠ける | 正常。約3MB 増 |
| IPAexゴシック(TrueType) | 正常。約1〜3万バイト | 正常。約4MB 増 |
| IPA明朝(TrueType) | 正常 | 正常。約6MB 増 |
どのフォントでも、ファイルの読み込み自体はエラーになりません。保存は成功するのに、開くと文字が抜けているという、気づきにくい壊れ方をします。サブセットで問題なく表示できたのは、試した中では IPAexゴシックと IPA明朝(同じ系統のフォント)だけでした。
そこでサンプルでは、IPAexゴシック(IPAフォントライセンス v1.0)をサブセットで使います。フォント全体を埋め込むと、書き込むたびに版が4MBずつ増え、アップロードの上限(20MB)にすぐ届いてしまうためです。ライセンスでは、PDFに埋め込んで配布することが認められています。フォントファイルはリポジトリに含めず、配布元から各自で入手して fonts/ に置く手順にしました(README に記載)。
📰 出典:IPAフォントライセンスv1.0(文字情報技術促進協議会)
server/utils/pdf/font.ts
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import fontkit from '@pdf-lib/fontkit'
import type { PDFDocument, PDFFont } from 'pdf-lib'
/** フォントファイルの中身(プロセス内で1回だけ読み込む) */
let fontBytes: Promise<Uint8Array> | undefined
function loadFontBytes(path: string): Promise<Uint8Array> {
fontBytes ??= readFile(resolve(path)).then((b) => new Uint8Array(b)).catch((err: unknown) => {
fontBytes = undefined
// 設定ミスは利用者のせいではないので 500。パスは応答に含めず、ログにだけ出す
console.error('日本語フォントを読み込めません:', path, err)
throw createError({ statusCode: 500, statusMessage: '日本語フォントが設定されていません' })
})
return fontBytes
}
export async function embedJapaneseFont(doc: PDFDocument): Promise<PDFFont> {
const { path, subset } = useRuntimeConfig().pdfFont
doc.registerFontkit(fontkit)
return doc.embedFont(await loadFontBytes(path), { subset })
}
/** フォントに無い文字(外字・一部の絵文字など)を返す。描くと空白や豆腐(□)になるため事前に弾く */
export function findUnsupportedChars(font: PDFFont, text: string): string[] {
const supported = new Set(font.getCharacterSet())
const missing = new Set<string>()
for (const ch of text) {
if (ch === '\n') continue
if (!supported.has(ch.codePointAt(0)!)) missing.add(ch)
}
return [...missing]
}
フォントの場所とサブセットの有無は nuxt.config.ts の runtimeConfig.pdfFont(環境変数 NUXT_PDF_FONT_PATH・NUXT_PDF_FONT_SUBSET)で変えられます。別のフォントに替える場合は、必ず実際のPDFを複数のビューアで開いて確認してください。
サーバー側:注釈を焼き込むAPI
入力の検証
server/utils/annotate-input.ts(抜粋)
/** 署名画像は PNG の data URL だけ受け付ける(base64 で最大約 300KB) */
const PNG_DATA_URL = /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/
export const annotateBodySchema = z.object({
items: z.array(z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), ...point, text: z.string().trim().min(1).max(200), size: z.number().min(6).max(48), color }),
z.object({ type: z.literal('stamp'), ...point, label: z.enum(STAMP_LABELS) }),
z.object({ type: z.literal('signature'), ...point, image: z.string().max(400_000).regex(PNG_DATA_URL), width: z.number().min(20).max(300) }),
])).min(1).max(MAX_ANNOTATIONS),
})
書き込みの種類ごとに項目が違うため、zod の discriminatedUnion(type の値で形を切り替える検証)を使いました。色と印の文言は、画面と共通の定数(shared/utils/annotation.ts)から選ぶ形にし、自由な値は受け付けません。
文字・日付印・署名を描く
server/utils/pdf/annotate.ts(抜粋)
export async function applyAnnotations(
data: Uint8Array,
items: Annotation[],
stamper: { displayName: string, now: Date },
): Promise<Uint8Array> {
let doc: PDFDocument
try {
doc = await PDFDocument.load(data, { updateMetadata: false })
} catch {
badRequest('このPDFは編集できません(パスワード付き、または壊れている可能性があります)')
}
// 文字を書くときだけ日本語フォントを埋め込む
const needsFont = items.some((i) => i.type !== 'signature')
const font = needsFont ? await embedJapaneseFont(doc) : undefined
const images = new Map<string, PDFImage>()
const date = stampDate(stamper.now)
for (const item of items) {
if (item.page > doc.getPageCount()) badRequest(`${item.page}ページ目はありません`)
const page = doc.getPage(item.page - 1)
if (page.getRotation().angle % 360 !== 0) {
badRequest(`${item.page}ページ目は回転しているため、このサンプルでは書き込めません`)
}
if (item.type === 'text') {
const missing = findUnsupportedChars(font!, item.text)
if (missing.length > 0) badRequest(`フォントに無い文字が含まれています:${missing.join(' ')}`)
const lineHeight = item.size * 1.3
const lines = item.text.split('\n')
const width = Math.max(...lines.map((l) => font!.widthOfTextAtSize(l, item.size)))
assertInside(page, item.page, item.x, item.y, width, lineHeight * lines.length)
const [r, g, b] = ANNOTATION_COLORS[item.color].rgb
// drawText の y は1行目の文字の下端(ベースライン)。クリック位置を左上にするため文字の高さ分下げる
const ascent = font!.heightAtSize(item.size, { descender: false })
page.drawText(item.text, { x: item.x, y: item.y - ascent, size: item.size, font, color: rgb(r, g, b), lineHeight })
} else if (item.type === 'stamp') {
// …(印がページ内に収まるかを確認し、名前にフォントに無い文字があれば「?」に置き換え)
drawStamp(page, font!, item.x, item.y, item.label, date, name)
} else {
// …(PNG の先頭8バイトを確認 → doc.embedPng。同じ画像は1回だけ埋め込む)
const height = item.width * (image.height / image.width)
assertInside(page, item.page, item.x, item.y, item.width, height)
// drawImage の y は画像の下端。クリック位置を左上にするため高さ分下げる
page.drawImage(image, { x: item.x, y: item.y - height, width: item.width, height })
}
}
return doc.save()
}
ポイントは3つです。
- 基準点のずれ:pdf-lib の
drawTextは文字の下端(ベースライン)、drawImageは画像の下端を基準に描きます。クリックした位置を左上にしたいので、文字の高さ・画像の高さの分だけ下にずらしています。 - はみ出しの確認:書き込み全体がページの表示範囲(CropBox)に収まるかを
assertInsideで確かめ、外なら 400 にします。範囲外に描いても保存は成功しますが、画面には何も出ず、利用者が混乱するためです。 - フォントに無い文字:外字や一部の絵文字は、描くと空白や「□」になります。保存前に
getCharacterSet()で調べて、どの文字が使えないかをエラーで返します。
日付印(drawStamp)は、drawCircle で丸枠、drawLine で2本の横線を引き、上段に文言、中段に日本時間の日付、下段に名前を中央ぞろえで描いています。名前が長いときは、枠に収まるまで文字を小さくします。
📰 出典:pdf-lib API ドキュメント PDFPage(drawText / drawImage / drawCircle / getCropBox / getRotation)
APIの本体
server/api/files/[id]/annotate.post.ts(抜粋)
export default defineEventHandler(async (event) => {
const user = await requireRole(event, 'editor')
const id = await getFileIdParam(event)
// 署名画像(base64)を含むため本文は大きめ。上限を超える送信は読み込む前に断る
const length = Number(getRequestHeader(event, 'content-length') ?? 0)
if (length > 2 * 1024 * 1024) {
throw createError({ statusCode: 413, statusMessage: '送信内容が大きすぎます' })
}
const body = await readValidatedBody(event, (b) => annotateBodySchema.safeParse(b))
if (!body.success) {
throw createError({ statusCode: 400, statusMessage: '書き込む内容が正しくありません' })
}
const { items } = body.data
const file = getEditableFile(id)
const data = await readCurrentPdf(file)
const result = await applyAnnotations(data, items, { displayName: user.displayName, now: new Date() })
assertResultSize(result)
// 履歴に残す内容:「注釈:テキスト2件・印1件(1, 3ページ)」
// …(summary と pages の組み立ては省略)
const { versionNo } = await addVersion({
fileId: id, data: result, pageCount: file.pageCount,
operation: 'annotate', note: `注釈:${summary}(${pages}ページ)`, userId: user.id,
})
return { id, versionNo }
})
印に入れる名前は、第5回で作った requireRole が DB から読んだ最新の表示名です。版の種類(VERSION_OPERATIONS)に 'annotate' を足しましたが、SQLite では文字列の列なので、drizzle-kit でマイグレーションの差分が出ないことも確認しました。
画面:書き込みパネルと署名欄
editor 以上の人には、第6回のプレビューの代わりに、書き込みパネル付きの部品(app/components/PdfAnnotator.client.vue)を表示します。種類(テキスト・日付印・署名)を選んで内容を決め、プレビューをクリックすると、その位置に番号付きの目印が出て「予定」の一覧に加わります。
app/components/PdfAnnotator.client.vue(抜粋)
function onPick(p: { page: number, x: number, y: number }) {
// …(件数の上限チェックは省略)
// 座標は小数第2位まで(1ポイント=約0.35mm なので十分)
const point = { page: p.page, x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 }
if (tool.value === 'text') {
if (!text.value.trim()) {
message.value = '書き込む文字を入力してから、ページをクリックしてください'
return
}
items.value.push({ type: 'text', ...point, text: text.value, size: size.value, color: color.value })
} else if (tool.value === 'stamp') {
items.value.push({ type: 'stamp', ...point, label: stampLabel.value })
} else {
if (!signature.value) {
message.value = '署名を書いてから、ページをクリックしてください'
return
}
items.value.push({ type: 'signature', ...point, image: signature.value, width: signatureWidth.value })
}
}
手書き署名の入力欄(app/components/SignaturePad.client.vue)は、Canvas にポインターイベント(マウス・ペン・指の操作をまとめて扱うイベント)で線を引き、書き終わるたびに toDataURL('image/png') で透明背景の PNG にします。スマホで署名中に画面がスクロールしないよう、CSS で touch-action: none を指定しています。
app/pages/files/[id].vue(変更部分)
<!-- editor 以上は書き込みパネル付きのプレビュー(第8回)、それ以外は表示だけ -->
<PdfAnnotator v-if="canEdit" :file-id="file.id" :src="previewUrl" @saved="refresh()" />
<PdfViewer v-else :src="previewUrl" />
動作確認の方法
# フォントを配置(README の手順。IPAexゴシックの ipaexg.ttf を fonts/ に置く)
npm install # @pdf-lib/fontkit を追加
npm run build
node .output/server/index.mjs
本番ビルドを起動し、curl と自動操作のブラウザ(ヘッドレスの Chrome)で確認しました。出来上がったPDFは、pdf.js と MuPDF の2種類で開いて見た目とテキストを確かめています。
| 操作 | 結果 |
|---|---|
| テキスト2件(改行・「髙﨑」を含む)、日付印、署名を送る | 第2版ができ、2種類のビューアで同じ位置に表示。テキストとして検索・コピーもできる |
| 切り抜き範囲(CropBox)がずれているページに書き込む | 画面でクリックした位置どおりに書き込まれる |
| 回転したページ/存在しないページ/ページの外/右端をはみ出す長い文字 | いずれも 400 |
| 「つちよし」(吉の上が土の字)や絵文字を含むテキスト | 400「フォントに無い文字が含まれています」 |
| PNG でない画像・壊れた PNG・印の文言や色が一覧にない値・31件以上 | いずれも 400 |
| viewer で送信/未ログイン/パスワード付きPDF/2MB 超の送信 | 403/401/400/413 |
| 第1版をダウンロード | アップロードした元のPDFとバイト単位で一致 |
| フォントのパスを誤って起動 | 500「日本語フォントが設定されていません」(パスはログのみ) |
サブセットを切って(NUXT_PDF_FONT_SUBSET=false)書き込む | 1行の書き込みで版の大きさが約1.9万バイトから約420万バイトに増える |
| 画面:テキスト・日付印・署名を置いて保存 | 「第N版として保存しました」、プレビューが新しい版に更新、履歴に「書き込み」 |
| 画面:予定を置いたまま125%→150%に拡大 | 目印の位置も1.2倍に移動し、ページ上の同じ場所を指す |
Acrobat Reader など、ほかのPDFビューアでの表示は確認できていません。
つまずきやすい点・注意点
- フォントとサブセットは組み合わせで結果が変わる:上の実測のとおり、同じ設定でもフォントによって壊れ方が違います。帳票に使うフォントを決めたら、必ず実データで確認します。
- 回転したページ:スキャンしたPDFには、ページに「90度回転」の設定が付いていることがよくあります。回転を考慮して座標と文字の向きを合わせる処理は、このサンプルでは入れずに 400 で断っています。
- 版が増えるたびにフォントも増える:書き込むたびに、使った文字のサブセットが新しく埋め込まれます(前の版のフォントも残る)。サブセットなら1回あたり数万バイトですが、全体埋め込みでは数MBずつ増えます。
- 同時編集:2人が同じファイルに同時に書き込むと、後から保存した人の版には、先の人の書き込みが入りません(どちらも元の版に書き込むため)。本番では、画面で開いた版の番号を送り、現在の版と違えば保存を断る仕組みを検討します。
- 署名画像の扱い:署名の PNG は本人の筆跡そのものです。ログやエラー通知に本文をそのまま出さないようにします。
発注者向けメモ:「署名画像」と「電子署名」は別物
画面でサインを書いてPDFに貼る機能は、見た目は署名ですが、「誰が署名したか」「署名後に改ざんされていないか」を技術的に証明する電子署名とは別物です。画像は誰でもコピーでき、PDFを編集すれば差し替えもできます。社内の確認・回覧の記録には十分役立ちますが、契約書など法的な効力が必要な書類に使えるかは、社内の法務部門や専門家に確認してください。必要な場合は、電子署名に対応したサービスとの連携を検討します。
- 目的を分ける:社内の確認印・回覧の記録が目的か、取引先との契約の証拠が目的かで、必要な仕組みが変わります。
- 印の仕様を決める:文言(承認・確認など)、日付の形式、名前の出し方、色と大きさ。紙の運用に合わせたい場合は、サンプルの印影を用意すると話が早く進みます。
- 対象のPDFを渡す:スキャンした書類(回転付きのものが多い)、フォームのある申請書など、実際に書き込むPDFを数種類用意してもらうと、見積もりの精度が上がります。
- フォントの選定とライセンス:日本語フォントは、PDFへの埋め込みが許されているか、表示の不具合がないかを確認して選びます。
打ち合わせでは、次のように聞いてみてください。
- 「この署名機能は、電子署名(改ざん検知・本人確認)にあたりますか?契約書に使ってよいですか?」
- 「書き込みを間違えたとき、どうやって元に戻しますか?」
- 「スキャンした(回転している)PDFにも、クリックした位置どおりに書き込めますか?」
- 「使う日本語フォントとライセンス、文字化けの確認方法を教えてください」
- 「2人が同時に同じPDFに書き込んだ場合はどうなりますか?」
まとめと次回予告
第8回では、プレビュー上でクリックした位置に、日本語テキスト・日付印・署名画像を焼き込む機能を作りました。
- 画面の座標とPDFの座標の変換は、pdfjs-dist の viewport(
convertToPdfPoint/convertToViewportPoint)で行う - 日本語は @pdf-lib/fontkit でフォントを埋め込む。サブセットで文字が欠けるかはフォント次第で、試した中では IPAexゴシック・IPA明朝だけが正常だった
- 日付と名前はサーバーで入れ、書き込みは新しい版として保存する
- 署名画像は、電子署名(改ざん検知・本人確認)とは別物
次回は「PDFフォーム(AcroForm)に入力する」です。入力欄が定義された申請書PDFから項目の一覧を取り出し、画面から日本語で入力して、入力欄をなくした(フラット化した)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件)
[…] 前回の第8回:注釈・スタンプ・署名画像を貼るでは、クリックした位置に日本語のテキストや日付印を焼き込みました。今回は「位置」ではなく、PDFがあらかじめ用意している「入力欄」に値を入れます。 […]