契約書・申請書・社内承認書類を 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
heightMm を undefined にできるようにして、未指定時はアスペクト比から自動計算。印鑑(正円)や長方形枠への正確な収め方が必要な人は、明示的に heightMm を指定して上書きできる。
手書き署名の実装:Pointer Events で指・ペン・マウスを統一
draw モードの実装で重要なのは、1つのコードで指・Apple Pencil・マウスのすべてを動かすこと。
UIには3モード(手書き / テキスト / 画像)の切り替えタブと、配置プリセット9箇所、幅・高さ(mm)の指定欄が並ぶ。1画面でサインの作成と配置までが完結する設計。
これを満たすのが Pointer Events API。mousedown / 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)が重なって表示される。
透過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 にも同じ内容を投稿しています。

