0
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のサムネイルを生成し、ドラッグ&ドロップで並べ替えて結合する

0
Posted at

サーバーにファイルを送らない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 が別要素と誤認し、サムネイルがちらつく。ファイルごとに安定した idcrypto.randomUUID())を振る。
  • onDragOverpreventDefault() しないと 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 は一度も呼ばない。ファイルは FileArrayBuffer → Canvas / pdf-lib と端末内だけを流れる。ページを一度読み込めば、以降はオフラインでも動く。

まとめ

  • 「PDF結合」の体験は、pdf-lib のコアより**「順番を目で見て確定できるUI」**でほぼ決まる。
  • サムネイルは pdf.js で1ページ目を Canvas 描画。worker のバージョン一致Canvas / ObjectURL の明示的な解放が事故りどころ。
  • 並べ替えは DnD API だけで足りるが、key は安定 id、onDragOverpreventDefault、スマホ用フォールバックの3点を外さない。
  • サーバーに送らない設計にすると依存を増やせないぶん実装は増えるが、pdf-lib + pdfjs-dist + Canvas で実用レベルまで到達できる。

同じ「ブラウザ完結・サーバーに送らない」方針で作った他のツールも公開しています。

関連資料:

0
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
0
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?