pdfjs-distのstandard_fontsがNext.js standaloneに同梱されない
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
pdfjs-distはstandard_fonts/を実行時にfile://で動的に読むため、Next.jsのfile tracing(NFT)はその依存を追跡できずstandaloneビルドから同梱漏れする。パス解決の成否だけでは検知できず、実ファイルの存在まで確認する必要がある。
結論
pdfjs-distの standard_fonts/ はNext.jsのfile tracingが追跡できない読み方をしているため、output: "standalone" ビルドから同梱漏れすることがあります。
同梱漏れの影響は静かです。フォント自体が無くても例外にならず、標準フォント(Helvetica等)を使ったPDFの文字抽出だけが壊れます。設定(outputFileTracingIncludes)で同梱を明示しつつ、それでも漏れた場合に本番起動時点で気づけるfail-fastガードを別途置くところまでやって、ようやく安心して運用できる状態になりました。
症状
このリポジトリのPDF抽出器(PdfExtractor)は、pdfjs-dist(Node legacy build)で各ページのgetTextContent()を呼びテキストを取り出します。標準14フォント(Helvetica等)を使うPDFを正しく抽出するには、pdfjs-distが配布物に同梱するstandard_fonts/ディレクトリをstandardFontDataUrlとして渡す必要があります。
このディレクトリは、node_modulesにpdfjs-distがそのまま存在する開発環境やCIでは何の問題もなく見つかります。ところがnext buildが生成するoutput: "standalone"のランタイム(Cloud Runで実際に起動する側)では、file tracing (NFT) が依存関係を静的解析して必要なファイルだけを.next/standaloneに絞り込みます。この絞り込みの対象からstandard_fonts/が漏れることがありました。
同梱が漏れても、抽出処理自体は例外を投げません。pdfjs-dist v6ではstandardFontDataUrlが解決できないとgetTextContent()がUnknownErrorExceptionで失敗しますが、これは呼び出し側のtry/catchで飲み込まれやすく、結果としてPDFのテキスト抽出結果だけが空に近くなります(v5では警告止まりで抽出自体は成功していたため、v6での挙動変化に気づきにくい面もありました)。
原因
standard_fonts/のパスは、実行時に動的なfile://アクセスで解決されています。
// extractors.ts
function standardFontAnchors(): string[] {
return [import.meta.url, pathToFileURL(join(process.cwd(), "noop.cjs")).href];
}
function standardFontsDirFrom(anchor: string): string | undefined {
const require = createRequire(anchor);
const pkgJsonPath = require.resolve("pdfjs-dist/package.json");
const pkgRoot = pkgJsonPath.slice(0, pkgJsonPath.length - "package.json".length);
return `${pkgRoot}standard_fonts`;
}
standardFontAnchors()が起点候補を2つ持っているのは、Next.jsのバンドラの都合です。開発環境やvitestではimport.meta.urlがソースの実位置を指すのでそのまま解決できますが、Turbopackがバンドルした本番serverではimport.meta.urlが.next/server/chunks/...という仮想的なchunk位置を指すため、そこからの相対探索ではpdfjs-distを見つけられません。そこでprocess.cwd()(standaloneランタイムのルート)を起点にしたcreateRequireも試すフォールバックを持たせています。
問題は、このcreateRequire(...).resolve()によるパス解決が、Next.jsのfile tracingにとっては見えないことです。NFTはimport文やrequire()の静的な呼び出しを解析して同梱対象を決めますが、実行時に組み立てた文字列パスへの動的アクセスまでは追跡しません。その結果、standard_fonts/ディレクトリ自体が.next/standaloneの配布物から欠落するか、あってもファイルが空になることがあります。
厄介なのは、パス解決(require.resolve("pdfjs-dist/package.json"))自体は同梱漏れの状態でも成功しうることです。pdfjs-distパッケージ自体は他の理由(コード本体のrequire)で同梱されるため、package.jsonは見つかります。しかし隣のstandard_fonts/ディレクトリだけが同梱対象から漏れていると、パスは組み立てられるのに実体が無いという状態になります。そのため、このコードは経路解決の成否だけで判断せず、実ファイルの存在まで確認しています。
// extractors.ts
function hasStandardFontData(dir: string): boolean {
try {
return existsSync(dir) && readdirSync(dir).some((name) => STANDARD_FONT_FILE_RE.test(name));
} catch {
return false;
}
}
直し方
対処は2段構えです。1つはNext.js側の設定で同梱を明示すること、もう1つは万一それでも漏れた場合に本番起動時点で気づけるようにすることです。
① next.config.ts で standard_fonts/ の同梱をfile tracingへ明示追加する
// next.config.ts
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["pdfjs-dist"],
outputFileTracingRoot: monorepoRoot,
outputFileTracingIncludes: {
"/api/**": ["./node_modules/**/pdfjs-dist/standard_fonts/**"],
},
};
outputFileTracingIncludesのキーはルートパターンで、対象のルートが使う追加ファイルをglobで指定します。ここではpnpmの入れ子ディレクトリ構造(.pnpm/pdfjs-dist@*/node_modules/pdfjs-dist/...)をワイルドカードで吸収するため./node_modules/**/pdfjs-dist/standard_fonts/**という書き方にしています。ルートキーを個別APIパスではなく/api/**という広いパターンにしているのは、Next.jsの動的ルートセグメント([id]のような角括弧)がfile tracingの世界ではglobのキャラクタクラスとして解釈されてしまい、意図通りにマッチしないためです。
serverExternalPackages: ["pdfjs-dist"]も同時に必要です。pdfjs-distをNext.jsのバンドル対象に含めてしまうと、前述のcreateRequire(import.meta.url).resolve(...)がバンドル後のchunk位置から解決を試みて失敗するのに加え、legacy buildが依存するworker/フォント関連の資産もバンドラによって壊れます。バンドルせず、実行時に実ファイルシステムのnode_modulesからrequireさせることで、next startとstandaloneランタイムの両方で同じ解決経路を保っています。
② 同梱設定が万一漏れても、本番起動時にfail-fastで検知する
設定だけに頼らないのは、Dockerfileの書き直しなどで同梱経路自体が別の理由でもう一度外れることがあり得るためです。そこで、production起動の最初の一度だけ呼ばれるinstrumentation.tsのregister()から、フォント実体の解決可否を検査するガードを呼んでいます。
// instrumentation.ts
export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
if (process.env.NODE_ENV !== "production") return;
const { assertStandardFontsAvailable } = await import("./lib/pdf-extract.js");
assertStandardFontsAvailable();
}
(実際は抽出処理を持つ内部パッケージからimportしています。edge runtimeにNode専用の依存グラフを引き込まないよう、nodejsガードの内側で動的importにしているのがポイントです。)
// extractors.ts
export function assertStandardFontsAvailable(): void {
if (resolveStandardFontDataUrl() !== undefined) return;
const dir = locateStandardFontsDir() ?? "pdfjs-dist 未解決";
throw new Error(
`pdfjs-dist standard_fonts のフォント実体を解決できません (${dir})。` +
"標準フォント PDF の text 抽出がサイレントに空になります。" +
"Cloud Run standalone バンドルに standard_fonts/ が同梱されているか確認してください " +
"(apps/web/next.config.ts の outputFileTracingIncludes)。Issue #311。",
);
}
process.env.NODE_ENV !== "production"で早期returnしているのは、開発やCIのユニットテストではnode_modulesにフォント実体がそのまま存在し通常はthrowしないため、このガードが検知したいのは本番固有の同梱漏れに絞りたいからです。この検査を通すことで、同梱漏れは「PDFのテキスト抽出結果が静かに空になる」という発見しづらい劣化から、「デプロイ直後に起動そのものが失敗する」という気づきやすい形に変わります。
見落としやすい前提
- パスが解決できることと、ファイルが実在することは別の話です。
require.resolve()が成功しても、隣接するディレクトリが同梱されているとは限りません。動的なfile://アクセスに依存している資産は、file tracingの追跡漏れを疑って実ファイルの存在まで確認する価値があります。 - 開発環境とCIのユニットテストは、この種の同梱漏れを検知できません。
node_modulesが丸ごと存在する実行環境では再現しないためです。standaloneビルドを実際に動かして初めて症状が出ます。 - 設定で同梱を直しても、それだけでは再発を防げません。 Dockerfileの書き直しなど、別の変更が同じ同梱経路をもう一度外すことがあります。設定と、起動時に実体を検査するfail-fastガードは、どちらか一方では足りません。
よくある質問
Q1同梱漏れが起きても、開発環境やCIでは気づけないのですか?
気づけません。開発/テストのNode実行はnode_modulesにpdfjs-distの実体がそのまま存在するため、standard_fonts/も普通に解決できます。同梱漏れが露見するのは、Next.jsがfile tracingでファイルを取捨選択するstandaloneビルドを実際にCloud Run等で起動した時だけです。
Q2outputFileTracingIncludesを1回設定すれば、もう心配しなくていいですか?
設定ミスやDockerfileの書き直しで同梱経路自体が外れる再発があり得ます。そのため設定だけに頼らず、起動時にフォント実体の存在を検査してthrowするfail-fastガードを別途instrumentation.tsに置き、設定が壊れても本番でサイレントに劣化させず気づける形にしています。
Q3pdfjs-distをNext.jsのバンドルに含めてはいけないのですか?
含めると別の問題が起きます。PdfExtractorはcreateRequire(import.meta.url).resolve('pdfjs-dist/package.json')でファイル位置からstandard_fontsを逆算していますが、バンドルされるとその位置がchunk内の仮想パスになり解決できません。加えてlegacy buildのworker/font資産も壊れるため、serverExternalPackagesでバンドル対象から外し、実行時にnode_modulesから解決させています。
確認した環境
- Next.js ^16.0.0 / pdfjs-dist ^6.0.227(Node legacy build)
- output: "standalone"(Cloud Run 用ランタイム)構成
- 2026-06-04 に本番の同梱漏れを修正(Issue #311)
この記事の根拠
- TypeScriptファイル 1〜25行目コミット c76e823
- TypeScriptファイル 57〜73行目コミット c76e823
- TypeScriptファイル 93〜168行目コミット c76e823
- TypeScriptファイル 18〜22行目コミット c76e823
- TypeScriptファイル 34〜39行目コミット c76e823
- JSONファイル 25〜26行目コミット c76e823
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。