PDFAnvil ができるまで — ブラウザ内 pdf-lib で結合・分割・圧縮を成立させる設計
pdf-lib を使ってPDFの結合・分割・軽量化をブラウザ完結で実装したWebサービスの設計記録。並べ替え可能ファイルリスト、ページ範囲指定、メモリ上限の扱いまで解説します。
PDFAnvil ができるまで — ブラウザ内 pdf-lib で結合・分割・圧縮を成立させる設計
PDFAnvil は、PDFファイルをアップロードせずに 結合・分割・圧縮 できるWebサービスです。 voice-scribe / clip-cast / bg-snap / text-pluck と並ぶ、メディア処理ラボの 5本目の柱。今回は ML を使わない確実動作の領域で、ブラウザ完結の主張を補強しました。
なぜこの形にしたか
PDFツールは「結合」「分割」「圧縮」 で検索需要が極めて広く、 既存サービス (smallpdf / ilovepdf / Adobe online) はほぼ全部 アップロード前提 です。 契約書・領収書・本人確認書類・社外向け資料といった機密度の高いPDFを、 名前も知らない海外SaaSに上げ続けているのが現状。 これを「ブラウザだけで完結する代替」 で塗り替えられる可能性があるなら、ラボの 5本目として置く意味があります。
技術的にも、 pdf-lib という Pure JS のライブラリが結合・分割・再保存をすべて成立させてくれるので、 ML モデルや WASM コアファイルを別途ダウンロードする必要がなく、 初回ロードが軽い (ライブラリ自体 ~1MB)。 ラボの他4本がモデルDLを要する重量級ばかりだったので、 "軽くて即動く" を売りにできる5本目 という位置づけも兼ねています。
visual direction
エンジニアリングワークショップ × ブループリント。
- スレート系 (
#0d1014) を背景に、 ライムグリーン (#a3e635) を主アクセント - ヒーロー背景は 製図用方眼ライク な細い罫線で構成し、 シアン系の voice-scribe や暖色系 (clip-cast / bg-snap / text-pluck) と完全に別温度
- 3モードのタブを最上段に並べて、 「結合 / 分割 / 圧縮」 のサブラベル付きで一目で分かる構成
- ボタンとアクセントは黒文字 on ライム緑で、 工作機械の操作パネル感を出した
実装の見どころ
1. 単一エントリで 3 操作を扱う runner
pdfRunner.ts に mergePdfs / splitPdf / compressPdf を並べて、 すべて同じ PdfProgress コールバック型でUIにステータスを返します。 ライブラリの API はそれぞれ違うのに、UI側からは1本の関数に見える形にしました。
const out = await PDFDocument.create()
for (const file of files) {
const src = await PDFDocument.load(await file.arrayBuffer(), { ignoreEncryption: true })
const pages = await out.copyPages(src, src.getPageIndices())
pages.forEach((p) => out.addPage(p))
}
const bytes = await out.save({ useObjectStreams: true })
2. 並べ替え可能なファイルリスト (結合モード)
結合は順番が結果を決めるので、 ファイルカードに ↑↓ ボタンを置いて 1ステップで並べ替えられるようにしました。 ドラッグ&ドロップによる並べ替えも将来追加できる構造です。
3. ページ範囲パーサ (分割モード)
1-3,5,7-9 のような自然な範囲指定を受け取って、 それぞれ独立した PDFファイルに切り出します。 全角ハイフン (〜) もサポートし、 IME がうっかり日本語にしても通るようにしました。
const range = /^(\d+)\s*[-〜]\s*(\d+)$/.exec(part)
各グループは別ファイル名 (docname-p1-3.pdf, docname-p5.pdf...) で書き出されるので、 分割後の管理もそのまま意味が通ります。
4. 圧縮は誠実なベースラインだけ
PDFDocument.save({ useObjectStreams: true }) で オブジェクトストリーム + 圧縮XRef で再保存するだけ、 という最も誠実なベースラインで実装しました。 これは「FAT な手作りPDFを軽量化する」 ことには効くものの、 「画像PDFを劇的に小さくする」 ような期待にはほぼ応えません。
ここで嘘をつかないように、 圧縮率が 2% 以下のときは結果パネルに 「ほぼ変化なし」 の説明を表示し、 「画像PDFの強圧縮は今後の拡張」 と書いておく。 過剰な期待を煽らないUXにしています。
{compressResult.savingsRatio <= 0.02 ? (
<p className={styles.lowEfficiencyNote}>
ほぼ変化なし: 元のPDFが既に最適化済みだと判断しました。
本サービスはオブジェクトストリームでの再保存のみ行い、 画像の再エンコードはしません。
</p>
) : null}
5. メモリ上限の扱い
pdf-lib は全ページをメモリ上に展開するので、 100MB を超えるPDFは厳しい場面が出ます。 FAQ に上限の目安を明記し、 大きいPDFは 「先に分割」 → 個別処理 → 再結合のワークフローを提案しています。
苦労したところ
- Uint8Array の BlobPart 型衝突 (text-pluck / clip-cast でも経験済) を、 ヘルパー
blobOf(bytes)で内側でnew ArrayBufferにコピーして回避。 - モード切替時に古い結果が残る問題 を、 タブクリック時に
clearResults()で初期化することで解消。 - 圧縮の見かけ上の効果が薄い ことを UX 上ごまかしたくなる衝動を、 ラベルに「再保存」と書き、 効果が薄ければそれを明示する、という方向に倒した。
今後の拡張
- 画像PDFの強圧縮: ページごとに
Imageを取り出し → JPEG再エンコード → 埋め込み直し - ドラッグ&ドロップ並べ替え: ファイルリストを
@dnd-kit/sortable等で物理的に並び替え - ページ削除 / 抽出: 範囲指定の逆 (この範囲を除外したPDFを出す)
- OCR埋め込み: text-pluck の出力を invisible text として PDF に焼き込む → 検索可能PDFへ
- 暗号化PDF対応: パスワード入力でロック解除
- clip-cast との連携: 動画から切り出したフレーム集を1つのPDFに
このサービスから言える事
メディア処理ラボの5本柱が揃いました:
- voice-scribe (音声→テキスト)
- clip-cast (動画→トリム)
- bg-snap (画像→透過)
- text-pluck (画像→テキスト)
- pdf-anvil (PDF→PDF)
「重い処理は全部ブラウザで」 という主張を、 ML系4本 + Pure JS系1本という配分で実証しました。 軽い実装 (今回) と重い実装 (前4本) を並べることで、 ラボの方針が「重い処理が ブラウザでできる」 だけでなく 「軽く済む処理はもっと軽くやる」 までカバーしていることを示しています。
Next Action
読んだあとに、そのまま使って確かめる。
この開発ログは PDFAnvil をどう育てているかの記録です。読んだらそのままサービス本体へ戻って、価値を確かめられるようにしています。