UPSIDER Tech Blog

「とりあえず動いた」react-pdf の設定を、ちゃんと理解できるまで調べました

はじめに

支払い.com フロントエンドエンジニアの Torii です。

react-pdf を Next.js に入れてみたところ、思ったより設定が多くて手こずりました。worker ファイルをどこに置くか、webpack をどう設定するか、あちこちに手を入れる必要があります。正直なところ、私は最初、それぞれの設定が何をしているのか分からないまま、動いているコードをコピペして "とりあえず動く" 状態で使っていました。

ただ、それだと少し気持ち悪さが残ります。エラーが出たときに自分で直せません。そこで一つずつ「なぜこの設定が必要なのか」を調べてみたところ、それぞれにちゃんと理由がありました。整理して残しておきます。

環境

パッケージ / 設定
react-pdf 10.4.1
pdfjs-dist 5.4.296
Next.js 15.5.18
ルーター / 出力 Pages Router / Static Export
バンドラ webpack
パッケージマネージャ pnpm 11.8.0

なんの設定が必要なのか

まず、PDFのページサムネイルを表示するために私が用意した設定を並べてみます。ここだけ読めば全体像がつかめるはずです。

  1. worker ファイル(pdf.worker.min.mjs)を public/ に置き、react-pdf に workerSrc として教える
  2. 日本語表示のための cmap ファイルを public/ に置き、options で指定する
  3. <Document> に渡す options はコンポーネントの外で定数化する
  4. webpack で pdfjs-dist を min 版(pdf.min.mjs)に固定する

一つの機能なのに、なぜこんなに設定が散らばるのか。理由はシンプルで、pdf.js が PDFの解析(パース)という重い処理を、ブラウザのメインスレッドとは別に Web Worker を立ててそちらで動かす設計になっているからです(Canvas への実際の描画はメインスレッド側の担当です)。だから "本体" とは別に worker ファイルを用意して、それを読み込ませる必要があります。設定が本体側と worker 側に分かれるのは、この仕組みからくる自然な結果です。

もう一つ、前提として押さえておきたい点があります。react-pdf はブラウザのAPIに依存するので、サーバ側のプリレンダリングで import すると落ちてしまいます。そこで私は ssr:false の dynamic import を使い、クライアント限定・別チャンクとして読み込むようにしました。

import dynamic from 'next/dynamic'
import { Loading } from './Loading'

// react-pdf(pdf.js)はブラウザAPIに依存するので、ssr:false でクライアント限定で読み込む
export const PdfPageThumbnailViewer = dynamic(
  () => import('./PdfPageThumbnailViewer').then((mod) => mod.PdfPageThumbnailViewer),
  { ssr: false, loading: () => <Loading /> }
)

ここから、この 4 つの設定を上から順に、なぜ必要なのかを見ていきます。

worker ファイルは URL で読み込むので public に置く

pdf.js は PDF の解析(パース)という重い処理を、メインスレッドとは別のスレッドで動かします。このとき使うのが Web Worker というブラウザの仕組みで、メインスレッドとは別のスレッドで JavaScript を実行できます(Canvas への描画自体はメインスレッドの担当です)。

ここで少し引っかかったのが、その Web Worker の起動のしかたでした。Web Worker で動かすコードは、1 つの JavaScript ファイルにまとまっています。pdf.js の場合は pdf.worker.min.mjs がそれで、以下では「worker ファイル」と呼びます。ブラウザは new Worker(url) のように、この worker ファイルの URL を渡してスレッドを起動します。

つまり worker ファイルは、URL でアクセスできる実ファイルとして置かれている必要があります。webpack 5 自体は new URL('...', import.meta.url) の形で worker ファイルを別ファイルとして出力できます。ただ、Next.js と pnpm を組み合わせた環境では、この worker ファイルの URL 解決がうまくいかないことが多いようでした。それなら、あらかじめ worker ファイルを置いておくほうが手堅いです。

どこに置けばいいかというと、答えはシンプルで、public/ に置くだけでした。私たちは Static Export を使っているので、public/ に置いたファイルはそのまま静的配信されます。worker ファイルもここに置くのが一番素直です。

配置はビルドの前にスクリプトでコピーしています。

WORKER_SOURCE_FILE="./node_modules/pdfjs-dist/legacy/build/pdf.worker.min.mjs"
WORKER_OUTPUT_FILE="./public/pdf.worker.min.mjs"

# 実行のたびに作り直す(> で truncate してから中身を書き込む)
echo "// generated from ${WORKER_SOURCE_FILE}" > "${WORKER_OUTPUT_FILE}"
cat "${WORKER_SOURCE_FILE}" >> "${WORKER_OUTPUT_FILE}"

あとは react-pdf に、worker ファイルの場所を URL で教えてあげるだけです。

import { pdfjs } from 'react-pdf'

pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'

ちなみに、この public/ にコピーして参照するやり方は、Next.js のメンテナ自身も issue で提案しているものです。私だけの自己流ではないので、安心して使っています。

日本語を表示するには cmap を public に置く

日本語の PDF を表示しようとしたら、文字が出てきませんでした。原因を追いかけていくと、CMap というファイルにたどり着きます。

日本語のような文字を含むPDFは、CID フォントという形式を使うことが多いです。この形式では、文字コードをそのまま字形に結びつけるのではなく、いったん「文字コード → CID」という対応表を経由して文字を特定します。この対応表が CMap です。

ここは正確に押さえておきたいのですが、CMap はあくまで「文字コード → CID」の変換までを担当します。CID から実際のグリフ(字形)を取り出すのは、フォント側、つまり CIDFont の役割です。CMap 自体が字形を持っているわけではありません。

pdf.js は、必要になった CMap を実行時に fetch して読み込む設計になっています。つまり worker と同じで、あらかじめ静的な場所に置いておく必要があります。私はビルド前のスクリプトでコピーしました。

cp -R ./node_modules/pdfjs-dist/cmaps ./public/pdfjs/cmaps

そのうえで、<Document> に渡す options で場所を教えてあげます。

const C_MAP_OPTIONS = {
  cMapUrl: '/pdfjs/cmaps/',
  cMapPacked: true,
} as const

なお、日本語なら必ず CMap が要る、とまでは言い切れません。フォントがPDFに埋め込まれている場合は、CMap がなくても表示できることがあります。あくまで今回私が扱ったPDFでは、この cmaps の配置で日本語が表示できました。

options はコンポーネントの外で定数化する

これは地味なのですが、放っておくと実害が出るハマりどころです。

きっかけは、日本語 PDF の文字化け対策で <Document>options を渡したことでした。cMap の設定を入れるために、こう書きたくなります。

<Document file={fileUrl} options={{ cMapUrl: '/pdfjs/cmaps/', cMapPacked: true }}>

見た目はまったく問題ありません。しかし、これだと毎レンダリングで PDF の再読み込みが走ってしまいます。

理由は参照比較で、JavaScript では中身がまったく同じでも {} はレンダリングのたびに別のオブジェクトとして作り直されます。react-pdf の Document は「options が変わった」と判断し、中身は同じなのに PDF を読み直してしまいます。

対策はシンプルで、options をコンポーネントの外で定数として定義し、同じ参照を渡し続けます。

// コンポーネントの外で定数化(毎回同じ参照を渡す)
const C_MAP_OPTIONS = {
  cMapUrl: '/pdfjs/cmaps/',
  cMapPacked: true,
} as const

// ...

<Document file={fileUrl} options={C_MAP_OPTIONS}>

これだけで無駄な再読み込みが消えます。

ちなみに、表示するファイルを切り替えたときの状態リセットは、useEffect で手動リセットするのではなく key={fileUrl} を付けてコンポーネントを再マウントさせる方法をとっています。

なぜ pdfjs-dist を min 版に固定するのか

まず設定を示します。next.config.mjs で pdfjs-dist の解決先を圧縮版に差し替えています。

// next.config.mjs
webpack(config) {
  config.resolve.alias = {
    ...config.resolve.alias,
    'pdfjs-dist$': 'pdfjs-dist/build/pdf.min.mjs',
  }
  return config
}

なぜ min 版に固定するのかというと、非圧縮版の pdf.mjs のままだと、特に開発サーバ(next dev)を動かしているときに Object.defineProperty called on non-object というエラーが出て動かないことがある、と報告されているからです(本番ビルドでは出ないという報告が多いです)。

原因の位置づけも伝聞として書いておきます。これは pdfjs-dist を webpack がさらに再バンドルする過程で、開発時の source map 設定(eval 系)と噛み合ったときに起きる、webpack 側の既知の不具合とされています。一次情報としては次の issue があります。

私たちのプロジェクトでは、min 版に固定することでこの問題を避けています。ただし、min 版に固定するという対処は上の issue に書かれているわけではなく、私の環境でたまたま効いている回避策です(issue で挙がっているのは Turbopack への移行などで、min 版固定の話は出てきません)。

ただ、正直に書いておきます。記事のために手元で再現を試してみたのですが、今のバージョン(Next.js 15.5.18 / pdfjs-dist 5.4.296)では再現しませんでした。ですのでこの min 版固定は、今のところ効いている暫定的な回避策という位置づけです。原因とされる webpack の不具合自体はすでに修正版(webpack 5.103.0)が出ているようで、webpack や Next.js の更新、あるいは Turbopack への移行で、いずれ不要になるかもしれません。

「とりあえず効いている回避策」と「原因そのものの修正」を分けて捉えておくと、あとで設定を見直すときに迷いにくいと思います。

おわりに

今回の設定は、最初は「とりあえず動いたから」とコピペで済ませていたものばかりでした。でも一つずつ「なぜ必要なのか」を調べていくと、pdf.js が重い処理をメインスレッドとは別の Web Worker で動かす設計だ、という一本の理由にたどり着きました。

そこが腑に落ちると、worker を public に置くのも、日本語用の cmap を public に置くのも、同じ理由でつながっていることが見えてきます。バラバラだった設定が、急に一つの筋道として整理できた感覚がありました。

コピペのままだと次に別の環境で詰まったとき、また調べ直しになります。理由まで自分の言葉で説明できるようになると、それは再現性のある知識に変わります。地味な作業ですが、私はこの積み重ねがいちばん効くと感じています。

We Are Hiring

herp.careers

herp.careers

UPSIDER Engineering Deckはこちら📣

speakerdeck.com