1
0

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

1
Posted at

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)) は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 編集系ツールは「サーバー送信なし」がそのままユーザーへの安心材料になる。社外秘の契約書や報告書でも気兼ねなく使ってもらえるので、ブラウザ完結という設計選択そのものが価値になっている。

ぱんだツールズ ではこの他にも PDF 圧縮・サイン・透かし、画像・CSV処理などブラウザ完結ツールを80本以上公開している。全部無料・登録不要・サーバー送信なし。
https://sakutto-panda.com


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

1
0
0

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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?