PDF.jsでPDFを画像に変換するブラウザ完結ツールを作った(解像度3段階・JPEG/PNG対応)
何を作ったか
ぱんだツールズの一機能として「PDF→画像変換ツール」を作った。PDFをアップロードすると、各ページを JPEG または PNG に変換してダウンロードできるツール。解像度は低・中・高の3段階から選べる。
ファイルはサーバーに送らずブラウザ内ですべて処理する。Cloudflare Pages にデプロイしているが、Pages Functions も使わず純粋なクライアント処理だけで完結している。
ツールの概要

- 入力: PDF(最大20MB)
- 出力: JPEG または PNG(ページ単位、複数ページは1ページずつダウンロード)
- 解像度: 低(1倍 = 72dpi相当)・中(2倍 = 144dpi相当)・高(3倍 = 216dpi相当)
- 全ページを一括で変換し、ページごとにサムネイル + ダウンロードボタンを並べる
ページ単位でサムネイルが出るので、「3ページ目だけ欲しい」みたいなときも全部変換してからクリックするだけで済む。

技術スタック
- Next.js 16 (App Router) + TypeScript
-
pdfjs-distv5 系(Mozilla の PDF.js) - HTML5 Canvas +
canvas.toBlob - Cloudflare Pages(無料枠・帯域無制限)
PDF レンダリングまわりはほぼ pdfjs-dist 1本でカバーできる。
実装のポイント
pdfjs-dist は動的 import で遅延ロードする
PDF.js はそこそこ重い(worker 込みでビルドサイズに数百KB乗る)ので、ツールページに来た瞬間にバンドルしたくない。ユーザーが実際に「変換」を押したタイミングで初めてロードする。
async function handleConvert() {
const pdfjsLib = await import('pdfjs-dist')
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
).toString()
// ...
}
new URL(..., import.meta.url) を使うと、Next.js / Webpack が worker ファイルを別バンドルとして吐き出してくれる。worker のパスを文字列ベタ書きにすると Cloudflare Pages のデプロイで 404 になるので、この書き方が一番事故が少ない。
解像度はスケール倍率で制御する
PDF.js では page.getViewport({ scale }) の scale 値で描画解像度を決める。scale: 1 で 72dpi 相当、scale: 2 で 144dpi 相当、scale: 3 で 216dpi 相当になる。
const SCALE_MAP = { low: 1, medium: 2, high: 3 } as const
const page = await pdfDoc.getPage(i)
const viewport = page.getViewport({ scale: SCALE_MAP[scaleLevel] })
const canvas = document.createElement('canvas')
canvas.width = Math.floor(viewport.width)
canvas.height = Math.floor(viewport.height)
const ctx = canvas.getContext('2d')
await page.render({ canvas, viewport }).promise
スケールを上げるほど Canvas のピクセル数が二乗で増える(縦横とも倍になる = 面積は4倍)。A4 1ページを scale 3 で描画すると 2000×2800px くらいになる。印刷用途には十分な解像度が出る。
Canvas → Blob 化と JPEG 品質
Canvas をファイルに落とすのは canvas.toBlob を Promise でラップする。
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('画像変換に失敗しました'))),
mimeType,
format === 'jpeg' ? 0.92 : undefined
)
})
JPEG の品質は 0.92 にしている。0.85 だと文字のエッジにジャギーが出やすく、1.0 にすると体感の画質変化のわりにサイズが跳ねる。0.92 は経験的に「文字が読めるレベルを保ちつつ、サイズも実用的」なバランス。
PNG は品質パラメータが効かないので第3引数は undefined を渡す(指定しても無視される)。
Object URL のリーク防止
ページごとに URL.createObjectURL(blob) でプレビュー用 URL を作るので、再変換時とアンマウント時に必ず URL.revokeObjectURL で解放する。
const resultsRef = useRef<PageResult[]>([])
useEffect(() => {
resultsRef.current = results
}, [results])
useEffect(() => {
return () => {
resultsRef.current.forEach((r) => URL.revokeObjectURL(r.url))
}
}, [])
useEffect のクリーンアップで直接 results を参照すると、依存配列に入れる必要があって不要な再実行が起きる。useRef で「最新の results を持つ箱」を作って、アンマウント時にそれを参照する形にしている。
進捗をパーセント表示する
複数ページの PDF だと変換に数秒〜十数秒かかるので、ループの中で setProgress を呼んでパーセント表示する。
for (let i = 1; i <= numPages; i++) {
// ... render & toBlob ...
setProgress(Math.round((i / numPages) * 100))
}
React 19 の自動バッチングが効くので、各ページ処理ごとに setState を呼んでも UI 更新は適切に間引かれる。手動で requestAnimationFrame をはさまなくて済む。
DPI と出力形式の選び方
PDF→画像変換でよく聞かれるのが「DPI と JPEG/PNG どっち選べばいいか」。用途別の目安はこんな感じ。
| 用途 | 解像度 | 形式 |
|---|---|---|
| チャット共有・サムネイル | 低(72dpi) | JPEG |
| 画面表示メイン・ブログ埋め込み | 中(144dpi) | JPEG または PNG |
| 印刷・拡大表示・OCR入力 | 高(216dpi 以上) | PNG |
文字主体の PDF は PNG のほうがエッジがくっきりするので読みやすい。写真主体の PDF は JPEG のほうがファイルサイズが半分以下になる。迷ったら「写真ならJPEG、文字ならPNG」で大きく外さない。
学び
-
pdfjs-distは動的 import が前提。普通にimportするとビルドサイズが膨らむし、SSR で worker 周りが壊れる。Next.js App Router で扱うなら use client + 動的 import の組み合わせがほぼ唯一の正解。 -
Canvas の
toBlobは Promise 化が必要。コールバック API のままだと async/await のフローに馴染まないので、ラッパー関数化しておくと取り回しが楽。 -
Object URL の解放は
useRef経由が安全。useEffectの依存配列に state を入れるとクリーンアップが頻発するので、ref に最新値を保持しておく定石が使える。
まとめ
PDF→画像変換は、PDF.js + Canvas でほぼ全部できる。サーバー処理を挟まないので、Cloudflare Pages の無料枠だけで動かせる。動的 import・スケール倍率・Object URL クリーンアップあたりが地味だが大事なポイント。
ブラウザだけで完結する系のツールは、サーバー代がかからないので個人開発で広告/Pro 課金モデルと相性がいい。同じ路線で 80 以上のツールを並べている。
Discussion