サーバーにファイルを送らないPDFツールを個人で作っている。「複数のPDFを1つに結合する」機能は pdf-lib の数行で書けるが、実際に使ってもらうと**「どの順番で結合されるのか分からない」「思っていた順と違う」**という詰まりがすぐ出る。
結合の本体コードより、サムネイルを見せて・ドラッグで並べ替えて・その順番で結合するというUI層のほうが実装の勘所が多い。本記事はそのクライアントサイド完結の実装ノートです。
実装が動いている例: https://mamepdf.jp/pdf-merge-offline-browser
なぜ「順番のUI」が要るのか
pdf-lib で複数PDFを結合するコア自体はこれだけで済む。
import { PDFDocument } from "pdf-lib";
async function mergePdfs(files: File[]): Promise<Uint8Array> {
const out = await PDFDocument.create();
for (const file of files) {
const src = await PDFDocument.load(await file.arrayBuffer());
const pages = await out.copyPages(src, src.getPageIndices());
pages.forEach((p) => out.addPage(p));
}
return out.save();
}
問題は files: File[] の並び順だ。<input type="file" multiple> や drag&drop で受け取ったファイルの順序は、ユーザーが「結合したい順」とは限らない。ファイル名の昇順でもない(OSやブラウザで挙動が違う)。
つまり「結合する」機能の体験は、コア処理ではなく**「ユーザーが順番を目で見て確定できるか」**でほぼ決まる。ここをサーバーに送らず、ブラウザ内だけで作る。
1. pdf.js で1ページ目をサムネイル化する
各PDFの1ページ目を Canvas に描画して、識別用のサムネイルにする。pdfjs-dist を使う。
Next.js(App Router)でSSR中に読み込むと DOMMatrix is not defined 等で落ちるので、クライアントでのみ dynamic import し、worker も明示的に指定する。
"use client";
import * as pdfjsLib from "pdfjs-dist";
// worker はビルド成果物のパスを指す(バージョン不一致に注意)
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
async function renderThumbnail(file: File, maxW = 160): Promise<string> {
const data = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data }).promise;
const page = await pdf.getPage(1);
const base = page.getViewport({ scale: 1 });
const scale = maxW / base.width;
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext("2d")!;
await page.render({ canvasContext: ctx, viewport }).promise;
const url = canvas.toDataURL("image/png");
// 明示的に破棄しないと大量ファイルでメモリを食う
page.cleanup();
await pdf.destroy();
return url;
}
踏んだ罠:worker のバージョン不一致
pdfjs-dist は API 本体と worker のバージョンが一致していないと The API version "x" does not match the Worker version "y" で無言で描画に失敗する。CDN の worker を指すと、npm update した瞬間にズレる。import.meta.url 経由でバンドルされた worker を指すのが一番事故らない。
踏んだ罠:toDataURL はメモリを持っていく
サムネイルを dataURL(base64文字列)で持つと、20ファイル×数百KBで state が重くなる。枚数が多いなら canvas.toBlob() → URL.createObjectURL() にして、アンマウント時に revokeObjectURL で必ず解放する。今回は最大数十件想定なので dataURL のままにしているが、上限は決めておく。
2. ドラッグ&ドロップで並べ替える
外部ライブラリを足さず、HTML の Drag and Drop API だけで並べ替えを実装する。バンドルを軽く保ちたいので依存は増やさない方針。
type Item = { id: string; file: File; thumb: string };
function ReorderableList({ items, setItems }: {
items: Item[];
setItems: (v: Item[]) => void;
}) {
const dragIndex = useRef<number | null>(null);
const onDrop = (to: number) => {
const from = dragIndex.current;
if (from === null || from === to) return;
const next = [...items];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
setItems(next);
dragIndex.current = null;
};
return (
<ul>
{items.map((it, i) => (
<li
key={it.id}
draggable
onDragStart={() => (dragIndex.current = i)}
onDragOver={(e) => e.preventDefault()} // これが無いと drop が発火しない
onDrop={() => onDrop(i)}
>
<img src={it.thumb} alt="" width={80} />
<span>{it.file.name}</span>
</li>
))}
</ul>
);
}
ポイントは3つ。
-
keyに配列 index を使わない。並べ替えで index が変わると React が別要素と誤認し、サムネイルがちらつく。ファイルごとに安定したid(crypto.randomUUID())を振る。 -
onDragOverでpreventDefault()しないとonDropが発火しない。DnD API の有名な罠。 - スマホは HTML DnD が効かないので、実運用では上下移動ボタン(▲▼)も併設してフォールバックする。タッチ対応を本気でやるなら Pointer Events に寄せる。
3. 確定した順番で結合する
あとは並べ替え後の items の順で最初の mergePdfs に渡すだけ。ここは順序が保証された配列を渡すだけなので単純だ。
const merged = await mergePdfs(items.map((it) => it.file));
const blob = new Blob([merged], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
// <a download> でダウンロードさせ、直後に revokeObjectURL
全工程で fetch は一度も呼ばない。ファイルは File → ArrayBuffer → Canvas / pdf-lib と端末内だけを流れる。ページを一度読み込めば、以降はオフラインでも動く。
まとめ
- 「PDF結合」の体験は、
pdf-libのコアより**「順番を目で見て確定できるUI」**でほぼ決まる。 - サムネイルは
pdf.jsで1ページ目を Canvas 描画。worker のバージョン一致とCanvas / ObjectURL の明示的な解放が事故りどころ。 - 並べ替えは DnD API だけで足りるが、
keyは安定 id、onDragOverのpreventDefault、スマホ用フォールバックの3点を外さない。 - サーバーに送らない設計にすると依存を増やせないぶん実装は増えるが、
pdf-lib+pdfjs-dist+ Canvas で実用レベルまで到達できる。
同じ「ブラウザ完結・サーバーに送らない」方針で作った他のツールも公開しています。
- PDF結合(オフライン対応・インストール不要): https://mamepdf.jp/pdf-merge-offline-browser
- PDFのパスワード解除・設定: https://mamepdf.jp/pdf-unlock-remove-password
- メール添付できるサイズにPDFを圧縮: https://mamepdf.jp/pdf-reduce-size-email-attach
関連資料:
- pdf-lib: https://pdf-lib.js.org/
- pdf.js(pdfjs-dist): https://mozilla.github.io/pdf.js/