📄

pdf-libでPDF分割・結合・ページ番号挿入をブラウザだけで実装する — copyPagesと座標計算の現実

に公開

PDF を「ブラウザだけで」編集するライブラリの定番が pdf-lib。ファイルをサーバーに送らず、その場で結合・分割・編集ができる。ぱんだツールズではこれまで PDF 圧縮・PDF サインの記事を書いてきたが、今回は PDF 結合・分割・ページ番号挿入 の3兄弟の実装を整理する。

それぞれ単体は数十行で済むが、copyPages の挙動・PDF の座標系(左下原点)・標準フォントの ASCII 縛りなど、最初に踏みがちな罠がいくつかある。まとめておく。

https://sakutto-panda.com/tools/pdf-merge

共通の前提: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)) はNGcopyPages を経由しないと内部参照が壊れる

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 / 1 / 10 / Page 1 の3種類) — ぱんだツールズはこれ
  2. 日本語フォントを 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 編集系ツールは「サーバー送信なし」がそのままユーザーへの安心材料になる。社外秘の契約書や報告書でも気兼ねなく使ってもらえるので、ブラウザ完結という設計選択そのものが価値になっている。

https://sakutto-panda.com/tools/pdf-split

Discussion