🖼️

PDF.jsでPDFを画像に変換するブラウザ完結ツールを作った(解像度3段階・JPEG/PNG対応)

に公開

何を作ったか

ぱんだツールズの一機能として「PDF→画像変換ツール」を作った。PDFをアップロードすると、各ページを JPEG または PNG に変換してダウンロードできるツール。解像度は低・中・高の3段階から選べる。

ファイルはサーバーに送らずブラウザ内ですべて処理する。Cloudflare Pages にデプロイしているが、Pages Functions も使わず純粋なクライアント処理だけで完結している。

https://sakutto-panda.com/tools/pdf-to-image

ツールの概要

PDF→画像変換ツールの入力画面。ドロップゾーン・出力形式(JPEG/PNG)・解像度(低/中/高)の選択UI

  • 入力: PDF(最大20MB)
  • 出力: JPEG または PNG(ページ単位、複数ページは1ページずつダウンロード)
  • 解像度: 低(1倍 = 72dpi相当)・中(2倍 = 144dpi相当)・高(3倍 = 216dpi相当)
  • 全ページを一括で変換し、ページごとにサムネイル + ダウンロードボタンを並べる

ページ単位でサムネイルが出るので、「3ページ目だけ欲しい」みたいなときも全部変換してからクリックするだけで済む。

2ページのPDFを変換した結果。各ページのサムネイルとダウンロードボタンが並ぶ

技術スタック

  • Next.js 16 (App Router) + TypeScript
  • pdfjs-dist v5 系(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 以上のツールを並べている。

https://sakutto-panda.com/tools/pdf-to-image

Discussion