AI Dev Lab
PDFAnvil

PDFAnvil ができるまで — ブラウザ内 pdf-lib で結合・分割・圧縮を成立させる設計

pdf-lib を使ってPDFの結合・分割・軽量化をブラウザ完結で実装したWebサービスの設計記録。並べ替え可能ファイルリスト、ページ範囲指定、メモリ上限の扱いまで解説します。

·decision改善·stage公開中

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.tsmergePdfs / 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 を実際に動かす。

この開発ログは PDFAnvil をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — 文章embeddingsで意味検索をブラウザ内に

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →