1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PDFに手書きサイン・印影をブラウザだけで入れる。pdf-lib + Canvas で作るオフライン署名ツール

1
Last updated at Posted at 2026-05-11

契約書・申請書・社内承認書類を PDF でやり取りするとき、「ここにサインしてください」と言われる場面がある。Adobe Acrobat Pro のような有料ツールを使えば本格的な署名管理ができるが、契約形態によって月額数千円かかる。たまにしか使わない人にとっては割に合わない。

無料の代替として DocuSign やクラウドサインがあるが、書類をクラウドにアップロードするのが嫌なケースもある(社外秘・個人情報・契約相手先情報など)。

そこで、ぱんだツールズにブラウザ完結の PDF 署名ツール を作った。PDF を開いて、サインを描いて(または印影画像をアップロードして)、ダウンロードする。サーバー処理ゼロ。

この記事は、その実装を pdf-lib + Canvas + Pointer Events で組んだ話。実装上のハマりどころと、地味だけど効くUXの工夫を書く。

このツールは「視覚的な署名画像をPDFに挿入する」もので、電子署名法(電子署名及び認証業務に関する法律)に基づく 「電子署名」ではない。法的効力のある電子契約には DocuSign / クラウドサイン / Adobe Sign など、電子証明書とタイムスタンプに対応した専用サービスを使ってほしい。

構成:3つのモードを1ツールに統合

サインの入力は3モードを用意した。

モード 入力 用途
draw(手書き) Canvas に直接描く 個人の手書きサイン
text(タイプ) 文字列 + フォント選択 名前を活字で入れる
image(画像) PNG / JPEG をアップロード スキャン済み朱肉印影・電子印影

3モードに分ける必要があったのは、ユースケースが3種類に綺麗に分かれるから。

  • 個人の契約書 → draw
  • 海外の英文書類で「Type your full name as signature」 → text
  • 日本の請求書・社内稟議書(押印が必要) → image

これを単一の UI に統合してしまうと、「自分のケースでは何を入力すればいいかわからない」状態を生む。3つのタブで明示的に切り替えるのが、迷子を作らない一番シンプルな解法だった。

pdf-lib:ブラウザ内で PDF を編集する

PDF の編集には pdf-lib を使った。WebAssembly ではなく純 TypeScript 実装で、ブラウザでもそのまま動く。

import { PDFDocument } from 'pdf-lib'

const pdfDoc = await PDFDocument.load(pdfBytes)
const image = options.imageFormat === 'png'
  ? await pdfDoc.embedPng(options.imageBytes)
  : await pdfDoc.embedJpg(options.imageBytes)

const pages = pdfDoc.getPages()
for (const idx of targetIndices) {
  const page = pages[idx]
  page.drawImage(image, { x, y, width: sigWidthPt, height: sigHeightPt })
}

const saved = await pdfDoc.save()

pdf-lib の制約として、画像埋め込みは PNG と JPG のみ対応embedPng / embedJpg の2メソッドしかない。WebP・GIF・SVG はそのままでは入れられない)。これは型レベルで明示しておくのが扱いやすい:

export type SignatureImageFormat = 'png' | 'jpg'

export interface SignPdfOptions {
  imageBytes: Uint8Array
  imageFormat: SignatureImageFormat
  // ...
}

現状の UI は <input accept="image/png,image/jpeg"> で WebP を弾く実装にしているが、将来 WebP に対応するなら、Canvas に一度描画して toBlob('image/png') で PNG に変換してから pdf-lib に渡す形になる。

座標系のハマり:mm × pt × PDF原点

PDF の世界には独特の座標系がある。これを綺麗に整理しないと、「ボタンを押したら署名が画面外に飛んでいく」事態が起きる。

1. 単位変換:mm → pt

PDF の長さの単位は ポイント(pt)。1pt = 1/72インチ = 25.4/72 mm。

ユーザーには mm 単位で入力させたい(A4 の寸法は 210mm × 297mm のように mm で覚えている人が多い)。なので変換定数を1個だけ持つ:

/** mm → PDFポイント(1ポイント = 1/72インチ、1インチ = 25.4mm) */
export const MM_TO_PT = 72 / 25.4

すべての mm 入力は API 境界でこの定数で pt に変換する。UI では mm、内部では pt、と層を分けると混乱しない。

2. PDF の原点は左下

ブラウザ Canvas や CSS と違い、PDF の Y 座標は左下原点。Y を増やすと上に行く。

ここを間違えると署名がページ最上部に貼り付くか、画面外に消えるかのどちらか。プリセット位置(9箇所)の計算は、Y 軸の向きを考慮した実装に統一した:

// Y 座標(PDF は左下原点)
if (placement.startsWith('top-')) {
  y = pageHeightPt - sigHeightPt - marginPt   // 上端 = pageHeight - sigHeight - margin
} else if (placement.startsWith('middle-')) {
  y = (pageHeightPt - sigHeightPt) / 2
} else {
  // bottom-
  y = marginPt                                // 下端 = margin
}

top- を選んだら pageHeight - sigHeight - margin で計算する、という1行を間違えるとバグる。座標系のドキュメントコメントは関数の上に必ず書いておくと、3ヶ月後の自分が助かる。

3. 画像のアスペクト比を維持する

ユーザーは「幅40mm」と指定したいが、高さは画像のアスペクト比で自動算出してほしいケースが多い。

const sigWidthPt = options.widthMm * MM_TO_PT
const aspect = image.width / image.height
const sigHeightPt = options.heightMm !== undefined
  ? options.heightMm * MM_TO_PT
  : sigWidthPt / aspect

heightMmundefined にできるようにして、未指定時はアスペクト比から自動計算。印鑑(正円)や長方形枠への正確な収め方が必要な人は、明示的に heightMm を指定して上書きできる。

手書き署名の実装:Pointer Events で指・ペン・マウスを統一

draw モードの実装で重要なのは、1つのコードで指・Apple Pencil・マウスのすべてを動かすこと。

PDF署名ツールの「手書き」モード。Canvas に「テスト」と手書きされ、線の太さ(細・中・太)と色(黒・青・赤)の選択肢がある。下に配置プリセット9箇所(左上・中央上・右上・左中央・中央・右中央・左下・中央下・右下)が並び、「右下」が選択されている

UIには3モード(手書き / テキスト / 画像)の切り替えタブと、配置プリセット9箇所、幅・高さ(mm)の指定欄が並ぶ。1画面でサインの作成と配置までが完結する設計。

これを満たすのが Pointer Events APImousedown / mousemove / touchstart / touchmove を全部別々に書く必要はない。

const handlePointerDown = useCallback(
  (e: React.PointerEvent<HTMLCanvasElement>) => {
    const canvas = canvasRef.current
    if (!canvas) return
    canvas.setPointerCapture(e.pointerId)  // ← 重要
    drawingRef.current = true
    lastPointRef.current = getPoint(e)
  },
  [getPoint]
)

setPointerCapture を呼ぶのが地味なポイント。これがないと、Canvas の外にポインタが出た瞬間に pointermove が来なくなって、線が途切れる。pointerId を明示的にキャプチャしておくと、Canvas 外でも継続して座標が取れる。

線の補間は最後の点と現在の点を結ぶシンプルな実装:

const handlePointerMove = useCallback(
  (e: React.PointerEvent<HTMLCanvasElement>) => {
    if (!drawingRef.current) return
    const ctx = canvasRef.current?.getContext('2d')
    if (!ctx) return
    const last = lastPointRef.current
    const cur = getPoint(e)
    if (!last) {
      lastPointRef.current = cur
      return
    }
    ctx.strokeStyle = color
    ctx.lineWidth = lineWidth
    ctx.lineCap = 'round'
    ctx.lineJoin = 'round'
    ctx.beginPath()
    ctx.moveTo(last.x, last.y)
    ctx.lineTo(cur.x, cur.y)
    ctx.stroke()
    lastPointRef.current = cur
  },
  [color, lineWidth, getPoint]
)

lineCap = 'round'lineJoin = 'round' を入れておくと、線の繋ぎ目が滑らかになって手書き感が出る。

Canvas → PNG → pdf-lib の流れ

描画した Canvas はそのままでは PDF に入れられない。canvas.toBlob で PNG Blob にしてから、Uint8Array に変換して pdf-lib に渡す。

async function canvasToPngBytes(canvas: HTMLCanvasElement): Promise<Uint8Array> {
  const blob = await new Promise<Blob | null>((resolve) => {
    canvas.toBlob(resolve, 'image/png')
  })
  if (!blob) throw new Error('Canvas の PNG 化に失敗しました')
  const buf = await blob.arrayBuffer()
  return new Uint8Array(buf)
}

途中で透過処理が必要な場合は、Canvas に背景色を塗らずに描画し、書き出し時に透過 PNG として保存する。手書きサインを契約書の上に重ねるなら透過必須。

実際に「手書き署名 → 配置プリセット右下 → 出力」したPDFの一部がこちら。テキスト・表組みが詰まった既存PDFの右下に、Canvasで描いた手書き署名(透過PNG)が重なって表示される。

既存PDFの右下に「テスト」と手書きされた署名画像が透過状態で重なっている。元のレイアウトを崩さず、署名だけが右下にぴたりと収まっている

透過PNGで書き出しているため、PDF側の罫線や文字が署名画像の下にもそのまま透けて見える。背景なしで署名だけが乗る、という想定通りの挙動。

「対象ページ指定」をテキストで受け取る

3つのページ指定モードを用意した。

export type SignatureTargetPages =
  | { mode: 'all' }
  | { mode: 'last' }
  | { mode: 'pages'; pages: number[] }

pages モードでは、ユーザーに 1,3,5-7 のような形式で入力させる。これを配列にパースする関数を別ファイルに切り出した:

export function parsePageSpec(input: string, maxPage: number): number[] {
  const parts = input.split(',').map((s) => s.trim()).filter(Boolean)
  if (parts.length === 0) throw new Error('ページ番号を入力してください')

  const pages = new Set<number>()
  for (const part of parts) {
    if (part.includes('-')) {
      const [s, e] = part.split('-').map((n) => parseInt(n.trim(), 10))
      if (isNaN(s) || isNaN(e)) throw new Error(`無効な範囲: "${part}"`)
      if (s < 1 || e < 1 || s > maxPage || e > maxPage) {
        throw new Error(`ページ番号が範囲外: "${part}"`)
      }
      if (s > e) throw new Error(`start が end より大きい: "${part}"`)
      for (let i = s; i <= e; i++) pages.add(i)
    } else {
      const n = parseInt(part, 10)
      if (isNaN(n)) throw new Error(`無効なページ番号: "${part}"`)
      if (n < 1 || n > maxPage) throw new Error(`ページ番号が範囲外: ${n}`)
      pages.add(n)
    }
  }
  return Array.from(pages).sort((a, b) => a - b)
}

Set で重複排除して、最後にソート。エラーメッセージを場合分けで丁寧に出すと、ユーザーが何を間違えたかすぐわかる。

現状は signPdf.ts 内にエクスポートしているが、PDF 分割ツールでも使い回せる構造なので、いずれ src/lib/pdf/parseRanges.ts のような共通モジュールに切り出す余地がある。

ハマりどころまとめ

実装中にハマった3点を残しておく。

1. パスワード保護PDFは pdf-lib が読めない

PDFDocument.load がパスワード保護PDFで失敗する。エラーは出るが、ユーザーに「パスワードを外してください」と明示しないと混乱する。FAQ とツールページ内の注意書きで「パスワード保護された PDF は読み込めないため、先にパスワードを解除してから利用してください」と明示している。

2. mm 入力の四捨五入で1ピクセルずれる

widthMm * MM_TO_PT で計算した pt は浮動小数点。プレビューと出力結果で1ピクセル単位のズレが出ることがある。許容範囲なので無視しているが、「ピクセル単位で正確に揃えたい」要件があれば pt 直接入力モードを足す必要がある。

3. iPhone Safari の Pointer Events で setPointerCapture の挙動

Safari 14 以前では setPointerCapture の挙動が一部安定しないことがあった。Safari 15 以降は問題なく動作するため、今は Safari 15+ を前提にしている。古いバージョン対応が必要なら Polyfill を入れる選択肢もある。

まとめ

PDF にサインを入れるという日常的な作業を、ブラウザ完結 + サーバー処理ゼロで成立させた。

  • pdf-lib で PDF を読み込み・編集・保存
  • Canvas + Pointer Events で手書きサイン入力
  • 3モード(draw / text / image)に分けてユースケースを綺麗に分割
  • mm × pt × 左下原点の座標系を MM_TO_PT で1箇所に集約

機密性の高い書類でも、サーバーに送らずに署名を完結できる。

PDF系の他のツール(結合 / 分割 / 圧縮 / 透かし追加 など)も、同じく pdf-lib ベースでブラウザ完結で動く。


ぱんだツールズ では他にもPDF・画像・CSV・テキスト処理など80以上のツールを公開中。すべて無料・登録不要・ブラウザ完結で動く。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

1
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?