ReactとPDF.jsで、PDFをページ送りしながら表示する方法
PDF.js の npm パッケージ pdfjs-dist を使うと、PDF のページをブラウザの <canvas> に描けます。React では、PDF の読み込みとページの描画をそれぞれ useEffect に分け、片付けの関数で読み込みを止めたり、描いている途中の描画を取り消したりすると、ページを速く送ってもエラーになりません。
作るのは、「前のページ」「次のページ」のボタンと「3 / 5」のようなページ番号を持つ PdfPager コンポーネントです。検証では、ワーカーの指定を忘れると PDF を読み込めず、描画を取り消さずにページを速く送るとエラーになりました。フォントを埋め込んでいない日本語の PDF は、cMap のデータを指定しないと白紙になりました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、pdfjs-dist 6.4.299、Vite 8.3.5、TypeScript 7.0.2、Playwright 1.63.0 です。検証用の PDF は2つ作りました(Chromium で HTML から作った5ページの PDF と、日本語のフォントを埋め込まずに組み立てた1ページの PDF)。アプリは Vite でビルドして vite preview で配信し、Playwright で操作しました。StrictMode の確認だけは開発サーバーで行いました。検証環境には日本語フォント(Noto Sans CJK)を入れています。Firefox と Safari、スマートフォンのブラウザ、ページ数の多い PDF や大きな PDF は試していません。Vite 以外のバンドラーでのワーカーの指定も試していません。

pdfjs-dist を入れて、ワーカーの場所を指定する
PDF.js の Getting Started の説明では、PDF を解析するのは core layer と呼ばれる部分で、そのファイルが pdf.worker.mjs です。このファイルは Web Worker として動かすので、ワーカーのファイル(ここでは縮小版の pdf.worker.min.mjs)の URL を GlobalWorkerOptions.workerSrc に指定します。Vite では、?url を付けて読み込む書き方で、ファイルをビルド結果に含め、その URL を受け取れます。
import { GlobalWorkerOptions } from 'pdfjs-dist';
// Vite の ?url で、ワーカーのファイルをビルド結果に含め、その URL を受け取る
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
GlobalWorkerOptions.workerSrc = workerUrl;
検証環境では、ビルドすると dist/assets/ に pdf.worker.min-(ハッシュ).mjs という名前で、ワーカーのファイル(約1.26MB)が別のファイルとして出力されました。
指定を忘れて getDocument() を呼ぶと、PDF は読み込めず、次のエラーになりました。
Error: No "GlobalWorkerOptions.workerSrc" specified.
PDF.js の公式サンプルにある、ページ送りの例でも、ワーカーのファイルが別の場所にあるときは workerSrc を指定するよう、コメントで説明しています。
ページを canvas に描くコンポーネント
PdfPager は、PDF の URL と表示する横幅を受け取ります。読み込みと描画を、2つの useEffect に分けています。
import { useEffect, useRef, useState } from 'react';
import { getDocument, RenderingCancelledException } from 'pdfjs-dist';
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
import './pdfjs';
type Props = { url: string; width?: number };
export function PdfPager({ url, width = 600 }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
const [pageNum, setPageNum] = useState(1);
const [error, setError] = useState('');
// PDF を読み込む。画面から消えるときは、読み込みと文書をまとめて片付ける
useEffect(() => {
let ignore = false;
const task = getDocument({ url, cMapUrl: '/cmaps/' });
task.promise.then(
(pdf) => { if (!ignore) setDoc(pdf); },
(err: Error) => { if (!ignore) setError(`PDF を開けませんでした(${err.message})`); },
);
return () => {
ignore = true;
task.destroy();
};
}, [url]);
// 表示するページが変わるたびに描く。描いている途中で変わったら、前の描画を取り消す
useEffect(() => {
if (!doc) return;
let renderTask: RenderTask | null = null;
let cancelled = false;
(async () => {
const page = await doc.getPage(pageNum);
if (cancelled) return;
// 横幅を width に合わせる倍率を、ページごとに求める(横長のページもあるため)
const scale = width / page.getViewport({ scale: 1 }).width;
const viewport = page.getViewport({ scale });
// 高解像度の画面では、canvas の画素を devicePixelRatio 倍にして描く
const ratio = window.devicePixelRatio || 1;
const canvas = canvasRef.current!;
canvas.width = Math.floor(viewport.width * ratio);
canvas.height = Math.floor(viewport.height * ratio);
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
renderTask = page.render({
canvas,
viewport,
transform: ratio !== 1 ? [ratio, 0, 0, ratio, 0, 0] : undefined,
});
try {
await renderTask.promise;
} catch (err) {
if (err instanceof RenderingCancelledException) return; // 取り消したときは何もしない
setError(`ページを表示できませんでした(${(err as Error).message})`);
}
})();
return () => {
cancelled = true;
renderTask?.cancel();
};
}, [doc, pageNum, width]);
if (error) return <p role="alert">{error}</p>;
if (!doc) return <p>読み込んでいます…</p>;
const last = doc.numPages;
return (
<div className="pdf-pager">
<div className="pdf-nav">
<button type="button" onClick={() => setPageNum((n) => n - 1)} disabled={pageNum <= 1}>
前のページ
</button>
<span aria-live="polite">{pageNum} / {last}</span>
<button type="button" onClick={() => setPageNum((n) => n + 1)} disabled={pageNum >= last}>
次のページ
</button>
</div>
<canvas ref={canvasRef} />
</div>
);
}
1つ目の useEffect は、getDocument() で PDF を読み込みます。戻り値は、読み込みを表すオブジェクト(PDFDocumentLoadingTask)で、その promise が解決すると文書(PDFDocumentProxy)が手に入ります。片付けでは task.destroy() を呼びます。PDF.js のソースコードの説明では、destroy() はネットワークの読み込みをすべて止め、ワーカーを終了します。読み込みの途中で destroy() を呼ぶと、promise は Loading aborted というエラーで拒否されました。ignore は、このときのエラーや、あとから届いた結果を画面に出さないためのものです。
2つ目の useEffect は、pageNum が変わるたびに getPage() でページを取り出し、render() で canvas に描きます。render() に渡す viewport は、ページの大きさと倍率を表すもので、getViewport({ scale: 1 }) の横幅は PDF 本来の大きさ(1/72インチを1とする単位)です。公式の Examples のページにあるとおり、横幅を決めたいときは、決めたい横幅をこの値で割って倍率を求めます。
検証用の PDF は4ページ目だけが横長(A4 の横向き)で、倍率をページごとに求めているので、canvas の大きさもページごとに変わりました。横幅 360px で表示したときの結果は次のとおりです。
| 表示したページ | canvas の大きさ |
|---|---|
| 1〜3ページ目、5ページ目(A4 縦) | 360×509 |
| 4ページ目(A4 横) | 360×254 |
ボタンは、1ページ目で「前のページ」、最後のページで「次のページ」を disabled にしています。ページ番号の <span> には aria-live="polite" を付けています。MDN の aria-live の説明では、polite にした部分が変わると、支援技術は、利用者の今の作業をふつうは遮らずに、変化を知らせます(読み上げは試していません)。
ページを速く送ると、前の描画と重なってエラーになる
render() は、呼んだらすぐ描き終わるわけではありません。描いている途中に同じ canvas へ次の render() を呼ぶと、PDF.js はエラーにします。公式の Examples のページでも、同じ canvas で2つのページを同時に描けないことが説明されています。
そこで、2つ目の useEffect の片付けで renderTask.cancel() を呼んでいます。React の公式ドキュメントにある Effect の説明のとおり、片付けの関数は次に Effect が動く前に呼ばれます。ページが変わると、前のページの描画が取り消されてから次のページを描き始めます。取り消された描画の promise は RenderingCancelledException で拒否されるので、それはエラーとして表示しません。
片付けの renderTask?.cancel() だけを消した版と比べました。スクリプトから「次のページ」のボタンの click() を 10ms おきに4回呼んだところ、結果は次のようになりました(それぞれ3回ずつ試しました)。
| コンポーネント | 結果 |
|---|---|
PdfPager |
3回とも、ページ番号が 2、3、4、5 と進み、canvas には5ページ目が描かれた |
| 取り消さない版 | 3回とも途中でエラーになり、ページの代わりにエラーの文が表示された。エラーが出たのは、2回目を押した直後が2回、4回目を押した直後が1回 |
取り消さない版で表示されたエラーは次のとおりです。
ページを表示できませんでした(Cannot use the same canvas during multiple render() operations. Use different canvas or ensure previous operations were cancelled or completed.)
React を使わずに、同じ canvas へ1〜5ページ目の render() を続けて呼んだときも、同じエラーが出ました。取り消さないと、途中のページの描画がこのエラーで失敗し、3回試して3回とも、最後に canvas に残ったのは3ページ目でした。最後に描こうとした5ページ目ではありません。前の描画を取り消してから次を呼ぶと、1〜4ページ目は取り消しになり、5ページ目だけが描かれました。
一方、CPU を6倍遅くして Playwright の click() で4回押したときは、取り消さない版でもエラーになりませんでした。取り消さない版のエラーは、操作の間隔によって起きたり起きなかったりします。
公式のページ送りの例では、描いている途中なら次のページ番号を覚えておき、描き終わってから描く、という別の方法を取っています。React では、取り消しを片付けの関数に書けば、描いている途中かどうかや次のページ番号を、別の変数で覚えておく必要がありません。
高解像度の画面でぼやけないように描く
devicePixelRatio が 2 の画面で、canvas の画素の数を表示の大きさと同じにすると、ぼやけます。PdfPager では、canvas の画素の数を devicePixelRatio 倍にし、表示の大きさは CSS で元のままにしています。PDF.js では、render() に transform: [ratio, 0, 0, ratio, 0, 0] を渡すと、その倍率で描けます。公式の Examples のページにある Hello World の説明と同じ書き方です。
Playwright で devicePixelRatio を 2 にして開くと、canvas の画素の数は 720×1018、表示の大きさは 360×509 になりました。倍率を考えずに描いた canvas と、同じ部分を並べたのが次の画像です。

MDN の説明では、devicePixelRatio はブラウザのズームで拡大・縮小したときや、ウィンドウを解像度の違う画面へ移したときにも変わります。PdfPager は、その変化に合わせて描き直す処理は入れていません。必要なら、MDN の同じページにある matchMedia() で変化を知る例を参考に、描き直しのきっかけを足します。
フォントを埋め込んでいない日本語の PDF には cMap が要る
PDF.js で、フォントを埋め込まず、Adobe の定義済みの CMap(ここでは UniJIS-UCS2-H)で文字を指定した PDF を描くには、CMap のデータの場所を getDocument() の cMapUrl で指定します。データは pdfjs-dist の cmaps フォルダーに入っているので、Vite の public フォルダーへコピーして、そのまま配信しました。
$ cp -r node_modules/pdfjs-dist/cmaps public/
検証環境では、cmaps には .bcmap のファイルが168個入っていました。PdfPager の getDocument({ url, cMapUrl: '/cmaps/' }) は、このフォルダーを指しています。PDF.js のソースコードにある getDocument() の引数の説明には、cMapUrl の最後に / を付けるよう書かれています。
フォントを埋め込まず UniJIS-UCS2-H で日本語を書いた PDF を用意し、cMapUrl の有無で描き比べました。
cMapUrl |
結果 |
|---|---|
| 指定しない | 文字が1つも描かれなかった(canvas は真っ白)。コンソールに警告が出た |
| 指定する | 文字が描かれた(暗い画素が1,750個になった) |
指定しないときにコンソールに出た警告は次のとおりです。エラーにはならず、ページは白いまま描き終わります。
Warning: Error during font loading: Ensure that the `cMapUrl` API parameter is provided.
画面には何も出ないので、日本語の PDF を扱うなら、最初から cMapUrl を指定しておくのが安全です。フォントを埋め込んだ検証用の5ページの PDF は、cMapUrl を指定しなくても表示されました。
getDocument() の引数の説明によると、PDF.js はブラウザでは、埋め込まれていないフォントをシステムのフォントで代わりに描きます(useSystemFonts の既定値)。検証環境には日本語フォントを入れていたので、cMapUrl を指定したときに文字が描かれたのは、そのためでもあると考えられます。日本語フォントのない環境は試していません。
PDF.js には、PDF の標準フォントを埋め込んでいない PDF のための standardFontDataUrl という指定もあります。この記事の検証用の PDF には当てはまらないため、試していません。
別の PDF に切り替えるときは、key で作り直す
PdfPager は、url が変わっても pageNum を1に戻しません。親のコンポーネントで key に URL を渡し、PDF が変わったらコンポーネントごと作り直します。
<PdfPager key={url} url={url} width={360} />
React の公式ドキュメントでも、props が変わったときに state をすべて初期値に戻したいときは、key を使う方法を勧めています。
作り直すと、前の PdfPager の片付けで task.destroy() が呼ばれます。Web Worker の作成と終了の回数を数えたところ、本番用のビルドでは、5ページの PDF を開いた直後は「作成 1、終了 0」、別の PDF に切り替えたあとは「作成 2、終了 1」でした。開発サーバーで StrictMode を有効にしたときは、Effect が一度片付けられてからもう一度動くので「作成 2、終了 1」と「作成 4、終了 3」でした。どちらも、動いているワーカーは1つだけで、エラーも出ませんでした。
存在しない URL を渡すと、getDocument() の promise が拒否され、次の文が表示されました。
PDF を開けませんでした(Unexpected server response (404) while retrieving PDF "http://localhost:4173/missing.pdf".)
PdfPager は err.message をそのまま出していますが、これは原因を調べるための英語の文です。利用者に見せる画面では、「PDF を開けませんでした。時間をおいてもう一度お試しください」のような文に置き換えると親切です。





