PDFのページ順を入れ替えたい、不要なページだけ消したい、という場面はよくある。ぱんだツールズに「PDFページ並び替え・削除」ツールを作った。
この記事は実装の中心にある2つの設計判断について書く。プレビュー表示と実際の編集処理でライブラリを分けたこと、そして**「削除」を独立した処理にせず「並び替え」の一部として扱ったこと**。
プレビューはpdf.js、実編集はpdf-lib
ページをドラッグで並び替えるUIを作るには、まず各ページのサムネイル画像が必要になる。PDFの内部構造をそのまま弄るpdf-libにはページを画像として描画する機能がないので、ここはpdf.js(pdfjs-dist)に任せている。
const pdfjsLib = await import('pdfjs-dist')
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
).toString()
const pdfDoc = await pdfjsLib.getDocument({ data: buf.slice(0) }).promise
for (let i = 1; i <= numPages; i++) {
const page = await pdfDoc.getPage(i)
const viewport = page.getViewport({ scale: 0.4 })
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.floor(viewport.width))
canvas.height = Math.max(1, Math.floor(viewport.height))
await page.render({ canvas, viewport: viewport }).promise
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('サムネイル生成に失敗しました'))), 'image/jpeg', 0.7)
})
// ...thumbnailUrl として保持...
}
スケールは0.4、書き出しはJPEGの品質0.7に固定している。並び替え用のサムネイルは文字が読める程度で十分で、ページ数が多いPDFを読み込んだときにCanvas生成・Blob変換のコストが積み重なるのを抑える狙いがある。等倍でPNG出力すると、50ページのPDFで50枚の高解像度画像を同時に抱えることになり、メモリと処理時間の両方で割に合わない。
実際の並び替え・削除処理はpdf-libに切り替える。
import { PDFDocument } from 'pdf-lib'
export async function reorderPdf(buffer: ArrayBuffer, order: number[]): Promise<ArrayBuffer> {
const srcDoc = await PDFDocument.load(buffer)
const newDoc = await PDFDocument.create()
const copied = await newDoc.copyPages(srcDoc, order)
for (const page of copied) {
newDoc.addPage(page)
}
const saved = await newDoc.save()
return saved.buffer.slice(saved.byteOffset, saved.byteOffset + saved.byteLength) as ArrayBuffer
}
copyPagesでページを「オブジェクトとして」コピーし、画像やフォントを再エンコードしない実装になっている。この仕組み自体(copyPagesを経由しないと内部参照が壊れる話やsaved.bufferの扱い)はPDF結合・分割の記事で詳しく書いたので、ここでは繰り返さない。
ポイントは、表示用の描画(pdf.js)と構造の編集(pdf-lib)という別の関心を、別のライブラリに素直に分けていること。1つのライブラリで両方をやろうとすると、どちらかが無理な使い方になる。pdf-libにサムネイル描画をさせようとすると結局Canvas APIを別途呼ぶことになるし、pdf.jsでページの並び替え・保存をしようとするとそもそもその機能がない。役割がはっきり分かれているので、素直に2本立てにした。
「削除」を独立処理にせず、並び替えの一部にする
もう1つの判断は、ページ削除を専用の削除関数として実装しなかったこと。reorderPdfが受け取るorder: number[]は「出力PDFに含めたい元ページのインデックスを、出力したい順番で並べた配列」でしかない。並び替えも削除も、この1つの配列を作る操作に帰着させている。
UI側では各サムネイルにenabledフラグを持たせ、「×」ボタンで無効化・再度押せば復元できるようにしている。
interface PageThumb {
originalIndex: number
thumbnailUrl: string
enabled: boolean
}
async function handleGenerate() {
const enabledPages = pages.filter((p) => p.enabled)
const order = enabledPages.map((p) => p.originalIndex)
const result = await reorderPdf(buffer, order)
// ...
}
ドラッグで並び替えたpages配列から、enabledなものだけを残してoriginalIndexを取り出す。これだけで「並び替え済みで、かつ一部ページを除外した」順序リストができる。削除フラグのオンオフは表示順そのものには影響しないので、「並び替えてから消す」でも「消してから並び替える」でも結果は同じになる。
専用の削除関数を別に用意する設計も当然できるが、そうすると「並び替えた後に削除した場合」と「削除した後に並び替えた場合」で処理の順序やインデックスのずれを気にする必要が出てくる。orderという1つの配列に両方の操作を落とし込んでおけば、UIの操作順序を気にせず、最後にenabledなものだけを拾えばよい状態になる。
同じ考え方はCSVの列並び替え・削除ツールで先に使っていて、今回はそれをPDFのページ単位に転用した形になる。対象がCSVの列でもPDFのページでも、「並び替え済みのインデックス配列から、有効なものだけを取り出す」という骨格は変わらない。
ドラッグ中の位置はrefで持つ
ドラッグ&ドロップの実装では、ドラッグ元とドラッグ先のインデックスをuseStateではなくuseRefで持っている。
const dragIndexRef = useRef<number | null>(null)
const dragOverIndexRef = useRef<number | null>(null)
const handleDragStart = (idx: number) => {
dragIndexRef.current = idx
}
const handleDragOver = (e: React.DragEvent, idx: number) => {
e.preventDefault()
dragOverIndexRef.current = idx
}
const handleDrop = () => {
const from = dragIndexRef.current
const to = dragOverIndexRef.current
if (from === null || to === null || from === to) return
setPages((prev) => {
const next = [...prev]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved)
return next
})
dragIndexRef.current = null
dragOverIndexRef.current = null
}
dragoverイベントはドラッグ中のマウス移動に合わせて高頻度で発火する。ここをuseStateにすると、サムネイルの上を通過するたびに再レンダリングが走ることになる。実際に並び替えの結果として配列を更新したいのはdropが起きた瞬間だけなので、それまでの「今どこの上にいるか」は再レンダリングを起こさないrefに持たせて、drop時にまとめて1回だけsetPagesを呼ぶ形にしている。
サムネイルのBlob URLは明示的に破棄する
サムネイルはcanvas.toBlob()からURL.createObjectURL()で生成したURLを<img src>に渡している。これはメモリ上に参照が残り続けるため、不要になったタイミングでrevokeObjectURLを呼ばないとページを離れるまで解放されない。
アンマウント時のクリーンアップ関数は空の依存配列(マウント時に1回だけ登録)なので、そのままだとpagesが更新されても古い値を参照し続けてしまう。そこで最新のpagesを別のrefに同期させておき、クリーンアップ関数はそのrefを読む。
const pagesRef = useRef<PageThumb[]>([])
useEffect(() => {
pagesRef.current = pages
}, [pages])
useEffect(() => {
return () => {
pagesRef.current.forEach((p) => URL.revokeObjectURL(p.thumbnailUrl))
}
}, [])
コンポーネントのアンマウント時と、新しいPDFがアップロードされて古いサムネイル一式が不要になるタイミングの両方で、保持している全URLを破棄している。1ファイルあたりのページ数が多いPDFを何度も読み込んで試すような使い方でも、古いサムネイルのURLが溜まり続けない。
まとめ
- ページのプレビュー表示(pdf.js)と実際の構造編集(pdf-lib)は関心が異なるので、素直に別のライブラリに任せる設計にした
- 削除を独立した処理にせず、「出力したい元ページのインデックスを並べた配列」という1つのデータ構造に並び替えと削除の両方を落とし込んだ
- ドラッグ中の一時的な位置はrefに持たせて、確定時だけstateを更新することで不要な再レンダリングを避けた
- サムネイルのBlob URLは明示的に
revokeObjectURLで破棄し、メモリに溜め込まない
ぱんだツールズ では他にもPDF・画像・CSV・テキスト処理などの開発者向けツールを100個以上公開中。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。