pdf-libでPDF分割・結合・ページ番号挿入をブラウザだけで実装する — copyPagesと座標計算の現実
PDF を「ブラウザだけで」編集するライブラリの定番が pdf-lib。ファイルをサーバーに送らず、その場で結合・分割・編集ができる。ぱんだツールズではこれまで PDF 圧縮・PDF サインの記事を書いてきたが、今回は PDF 結合・分割・ページ番号挿入 の3兄弟の実装を整理する。
それぞれ単体は数十行で済むが、copyPages の挙動・PDF の座標系(左下原点)・標準フォントの ASCII 縛りなど、最初に踏みがちな罠がいくつかある。まとめておく。
共通の前提:pdf-lib のロード→保存パターン
pdf-lib では PDFDocument を中心に処理する。読み込みと保存の基本パターン:
import { PDFDocument } from 'pdf-lib'
const buffer = await file.arrayBuffer()
const doc = await PDFDocument.load(buffer)
// ...編集...
const saved = await doc.save() // Uint8Array
const blob = new Blob([saved.buffer as ArrayBuffer], { type: 'application/pdf' })
save() が返すのは Uint8Array で、Blob にするには中の ArrayBuffer を取り出す。Uint8Array の内部 ArrayBuffer は必ずしも先頭から始まらない(byteOffset > 0 のサブビューになっている可能性があり、saved.buffer をそのまま渡すと前後に余分なバイトが含まれることがある)ので、安全に書くなら:
const arrayBuffer = saved.buffer.slice(
saved.byteOffset,
saved.byteOffset + saved.byteLength
) as ArrayBuffer
ぱんだツールズの実装では純粋関数(src/lib/pdf/)に切り出してテスト可能にしている。
1. PDF結合:copyPages が肝
複数 PDF を1つにまとめる処理。copyPages で別ドキュメントから対象ページを取り込んで addPage する:
export async function mergePdf(buffers: ArrayBuffer[]): Promise<ArrayBuffer> {
const merged = await PDFDocument.create()
for (const buffer of buffers) {
const doc = await PDFDocument.load(buffer)
const indices = Array.from({ length: doc.getPageCount() }, (_, i) => i)
const copied = await merged.copyPages(doc, indices)
for (const page of copied) {
merged.addPage(page)
}
}
const saved = await merged.save()
return saved.buffer.slice(saved.byteOffset, saved.byteOffset + saved.byteLength) as ArrayBuffer
}
ポイントは copyPages(srcDoc, indices) の挙動。
-
0-indexed の配列を渡す(
getPageCount()は 1-indexed じゃなく要素数なので注意) -
srcDocが解放されないようループ内で生かしておく - 返ってくるのは
PDFPage[]で、これをmerged.addPage(page)で追加 -
直接
merged.addPage(srcDoc.getPage(0))はNG。copyPagesを経由しないと内部参照が壊れる
「addPage(page) だけで追加できるのに、なぜ copyPages が要るのか?」という疑問はもっとも。pdf-lib のページオブジェクトは元のドキュメントの内部リソース(フォント・画像など)への参照を持っているので、copyPages で「ターゲットドキュメントが所有する形に変換」しないと保存時に壊れる。
エラーハンドリング:
if (buffer.byteLength === 0) {
throw new Error('空のファイルが含まれています')
}
try {
doc = await PDFDocument.load(buffer)
} catch (err) {
throw new Error(`PDF の読み込みに失敗しました: ${err instanceof Error ? err.message : ''}`)
}
パスワード保護 PDF は load で例外を投げる。PDFDocument.load(buffer, { ignoreEncryption: true }) で読めることもあるが、暗号化されたコンテンツは正しく出力できない場合があるので、ユーザーにパスワード解除を促す方が確実。
2. PDF分割:ページ範囲文字列のパース
入力 1-3,5,7-9 のような文字列を [{start:1,end:3}, {start:5,end:5}, {start:7,end:9}] にパースする処理を別関数に切り出している:
export function parseRanges(input: string, maxPage: number): PageRange[] {
const parts = input.split(',').map((s) => s.trim()).filter(Boolean)
const ranges: PageRange[] = []
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}"`)
ranges.push({ start: s, end: e })
} else {
const n = parseInt(part, 10)
if (isNaN(n)) throw new Error(`無効なページ番号: "${part}"`)
if (n < 1 || n > maxPage) throw new Error(`ページ番号が範囲外: ${n}`)
ranges.push({ start: n, end: n })
}
}
return ranges
}
仕様として:
- カンマ区切りで複数範囲を指定
- ハイフン区切りで連続範囲
- 1-indexed(ユーザーが「1ページ目」と言ったら 1 で受ける)
- バリデーションで「全ての範囲がドキュメント内に収まっている」を保証
これを使った分割ロジック側では、各範囲ごとに新しいドキュメントを作って該当ページを copyPages:
export async function splitPdf(buffer: ArrayBuffer, ranges: PageRange[]): Promise<ArrayBuffer[]> {
const srcDoc = await PDFDocument.load(buffer)
const results: ArrayBuffer[] = []
for (const range of ranges) {
const newDoc = await PDFDocument.create()
const indices = Array.from(
{ length: range.end - range.start + 1 },
(_, i) => range.start - 1 + i // 1-indexed → 0-indexed 変換
)
const copied = await newDoc.copyPages(srcDoc, indices)
for (const page of copied) newDoc.addPage(page)
const saved = await newDoc.save()
results.push(saved.buffer.slice(saved.byteOffset, saved.byteOffset + saved.byteLength) as ArrayBuffer)
}
return results
}
返り値の ArrayBuffer[] を呼び出し側で JSZip にぶち込んで「分割PDFを ZIP でまとめてDL」みたいなUIにすれば良い。
3. ページ番号挿入:座標計算で詰む
PDFの中で最も理解しておくべきは「左下が原点」という座標系。Webや画像処理は左上原点が一般的だが、PDFは数学のXY座標と同じで左下が(0, 0)、右上に向かって増える。
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'
const pdfDoc = await PDFDocument.load(buffer)
const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
const pages = pdfDoc.getPages()
pages.forEach((page, index) => {
const { width, height } = page.getSize()
const text = String(index + 1)
const textWidth = font.widthOfTextAtSize(text, 12)
// 中央下に配置
const x = (width - textWidth) / 2
const y = 20 // 下マージン20pt
page.drawText(text, {
x,
y,
size: 12,
font,
color: rgb(0, 0, 0),
})
})
座標計算のポイント:
-
page.getSize()で{ width, height }を取る(pt 単位、A4 なら 595×842) -
下に置く →
yは小さい値(マージン分) -
上に置く →
y = height - margin - fontSize(fontSize 引かないとはみ出る) -
右に置く →
x = width - textWidth - margin(テキスト幅を引く) -
中央 →
(width - textWidth) / 2
font.widthOfTextAtSize(text, fontSize) でテキスト幅を測れる。これがないと右寄せ・中央寄せが計算できないので必須。
標準フォントは ASCII のみ。日本語は使えない
StandardFonts.Helvetica は PDF 仕様で全リーダーがサポートしている標準14フォントの1つ。ただしASCII文字のみ。「ページ1」「1ページ目」のような日本語ページ番号を入れたいと思ったら詰む。
選択肢:
-
英数字のフォーマットだけ提供する(
1/1 / 10/Page 1の3種類) — ぱんだツールズはこれ -
日本語フォントを embed する — Noto Sans JP の TTF を
pdfDoc.embedFont(fontBytes)で読み込む。ただし Noto Sans JP Regular だけでも約4-5MB、複数ウェイトを含めるとさらに増える。サブセット化(使う文字だけ抜き出す)すれば軽くなるが、その分実装が複雑
シンプルな実装で済ませたいなら ASCII フォーマットに絞る方が早い。
copyPages したPDFはサイズが大きくなる傾向
これは pdf-lib の仕様というか、PDF 全般の話だが、copyPages で結合・分割したPDFは最適化されないため、元ファイルの合計より大きくなることがある。
理由は「ファイル内のオブジェクトテーブルを再構築するが、未使用オブジェクトの除去が効かないケースがある」「フォントや画像が重複埋め込みされる」など。
ぱんだツールズでは結合・分割の後段に PDF 圧縮ツールを案内している。圧縮には compressPdf 別関数で useObjectStreams: true 等のオプションを使っている(こちらは別記事で解説済)。
まとめ:pdf-lib の3兄弟は copyPages と座標計算で押せる
-
結合:
merged.copyPages(srcDoc, indices)→merged.addPage(page)ループ -
分割: 範囲ごとに
PDFDocument.create()を作ってcopyPagesで抜く -
ページ番号:
page.getSize()から座標を計算、drawTextで書き込む(左下原点を忘れない)
PDF 編集系ツールは「サーバー送信なし」がそのままユーザーへの安心材料になる。社外秘の契約書や報告書でも気兼ねなく使ってもらえるので、ブラウザ完結という設計選択そのものが価値になっている。
Discussion