MENU

問い合わせ


    【Nuxtで作るPDF管理システム 第8回】注釈・スタンプ・署名画像を貼る

    「届いた書類に『確認しました』と一言書き込みたい」「承認欄に日付入りの印を押したい」「紙に印刷してサインしてスキャンし直す手間をなくしたい」。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 ピクセル(拡大率で変わる)
    PDF左下上向きに増えるポイント(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: truesubset: 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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

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


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

      この記事を書いた人

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

      コメント

      コメント一覧 (1件)

      目次