fix(reading): package PDF.js decoder assets (#1305)

Co-authored-by: XZH <cto@xzh.ai>
This commit is contained in:
evan188199-tech
2026-09-10 23:41:36 +08:00
committed by GitHub
co-authored by XZH
parent d88ccee585
commit 791c8d28db
10 changed files with 97 additions and 3 deletions
+3
View File
@@ -86,6 +86,9 @@ web/.next/
web/.next-deeptutor/
# Custom distDir builds (DEEPTUTOR_NEXT_DIST_DIR / parallel dev servers)
web/.next-*/
# PDF.js decoder files are copied from node_modules for local/dev and release
# builds; they are dependency-generated assets, not source files.
web/public/pdfjs/
# Per-process TypeScript configs used by isolated build/typecheck wrappers
web/tsconfig.deeptutor-*.json
# Temporary Next.js dist roots created by isolated build/test runs
+4 -1
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { loadPdfjs, type PdfDocument } from "@/lib/pdfjs-loader";
import { loadPdfjs, pdfjsWasmUrl, type PdfDocument } from "@/lib/pdfjs-loader";
import type {
AnnotationItem,
NormalisedRect,
@@ -112,6 +112,9 @@ export function PdfDocumentView({
const pdfjs = await loadPdfjs();
const loadingTask = pdfjs.getDocument({
url: rawMaterialUrl(materialId),
// PDF.js loads OpenJPEG/JBIG2/QCMS from these assets when a PDF uses
// JPEG2000 or other formats that are not decoded in pure JS.
wasmUrl: pdfjsWasmUrl(),
// The raw route sits behind the session cookie like every other API
// route; pdf.js does its own fetching, so it needs telling.
withCredentials: true,
+1
View File
@@ -43,6 +43,7 @@ const config = [
"playwright-report/**",
"test-results/**",
"contracts/generated/.tmp/**",
"public/pdfjs/**",
],
},
];
+8
View File
@@ -18,6 +18,8 @@ export type Pdfjs = typeof PdfjsModule;
export type PdfDocument = Awaited<ReturnType<Pdfjs["getDocument"]>["promise"]>;
export type PdfPageProxy = Awaited<ReturnType<PdfDocument["getPage"]>>;
const PDFJS_WASM_PATH = "/pdfjs/wasm/";
let pending: Promise<Pdfjs> | null = null;
export function loadPdfjs(): Promise<Pdfjs> {
@@ -40,6 +42,12 @@ export function loadPdfjs(): Promise<Pdfjs> {
return pending;
}
/** Absolute same-origin base URL used by PDF.js for decoder modules. */
export function pdfjsWasmUrl(): string {
if (typeof window === "undefined") return PDFJS_WASM_PATH;
return new URL(PDFJS_WASM_PATH, window.location.origin).toString();
}
/**
* Device-pixel scale for crisp canvas rendering, bounded.
*
+1 -1
View File
@@ -42,7 +42,7 @@ export function isBackendPath(pathname: string): boolean {
// (issue #599 — broken logo/banner after login). Public assets are
// non-sensitive by design, so allowing them through is safe.
const STATIC_ASSET =
/\.(?:png|jpe?g|gif|svg|ico|webp|avif|woff2?|ttf|otf|txt|json|map|css|js)$/i;
/\.(?:png|jpe?g|gif|svg|ico|webp|avif|woff2?|ttf|otf|txt|json|map|css|js|wasm)$/i;
// Paths the auth gate must never block: the auth pages themselves, Next.js
// internals, and public static assets (see STATIC_ASSET above).
+2
View File
@@ -4,7 +4,9 @@
"private": true,
"scripts": {
"dev": "node ./scripts/dev.mjs",
"predev": "node ./scripts/copy-pdfjs-assets.mjs",
"dev:turbo": "node ./scripts/dev.mjs --turbopack",
"predev:turbo": "node ./scripts/copy-pdfjs-assets.mjs",
"build": "node ./scripts/build.mjs",
"start": "next start",
"lint": "eslint .",
+2
View File
@@ -6,6 +6,7 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { configureTypeIncludes } from "./next-type-includes.mjs";
import { copyPdfjsAssets } from "./copy-pdfjs-assets.mjs";
const webRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
@@ -66,6 +67,7 @@ if (isEntry) {
const buildTsconfigPath = prepareBuildTsconfig(snapshots, distDir);
let result;
try {
copyPdfjsAssets();
result = spawnSync(
process.execPath,
// Next.js 16 defaults to Turbopack, which does not emit the standalone
+34
View File
@@ -0,0 +1,34 @@
import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const webRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const sourceDir = path.join(webRoot, "node_modules", "pdfjs-dist", "wasm");
const targetDir = path.join(webRoot, "public", "pdfjs", "wasm");
/**
* Copy PDF.js decoder resources into Next's public asset tree.
*
* PDF.js keeps the OpenJPEG/JBIG2/QCMS WebAssembly modules outside its JS
* bundles and resolves them at runtime from `wasmUrl`. Next's standalone
* tracing does not include those package files automatically, so released
* builds otherwise render PDFs that use JPEG2000 as blank pages.
*/
export function copyPdfjsAssets() {
if (!existsSync(sourceDir)) {
throw new Error(
`Missing PDF.js decoder assets at ${sourceDir}. Run npm install first.`,
);
}
rmSync(targetDir, { recursive: true, force: true });
mkdirSync(targetDir, { recursive: true });
cpSync(sourceDir, targetDir, { recursive: true });
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
copyPdfjsAssets();
}
+41 -1
View File
@@ -1,6 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import path from "node:path";
const webRoot = process.cwd();
@@ -15,6 +16,45 @@ test("npm build routes through the generated-file wrapper", () => {
assert.equal(scripts.build, "node ./scripts/build.mjs");
});
test("the build publishes PDF.js decoder assets for standalone deployments", () => {
const scripts = JSON.parse(read("package.json")).scripts as Record<
string,
string
>;
assert.equal(scripts.predev, "node ./scripts/copy-pdfjs-assets.mjs");
assert.equal(scripts["predev:turbo"], "node ./scripts/copy-pdfjs-assets.mjs");
const source = read("scripts", "copy-pdfjs-assets.mjs");
assert.match(source, /node_modules.*pdfjs-dist.*wasm/);
assert.match(source, /public.*pdfjs.*wasm/);
assert.match(source, /cpSync\(sourceDir, targetDir, \{ recursive: true \}\)/);
const result = spawnSync(
process.execPath,
["scripts/copy-pdfjs-assets.mjs"],
{ cwd: webRoot, encoding: "utf8" },
);
assert.equal(result.status, 0, result.stderr);
for (const name of [
"openjpeg.wasm",
"openjpeg_nowasm_fallback.js",
"jbig2.wasm",
"qcms_bg.wasm",
]) {
assert.ok(
existsSync(path.join(webRoot, "public", "pdfjs", "wasm", name)),
name,
);
}
});
test("the reader gives PDF.js an absolute same-origin decoder URL", () => {
const loader = read("lib", "pdfjs-loader.ts");
const reader = read("components", "reading", "PdfDocumentView.tsx");
assert.match(loader, /new URL\(PDFJS_WASM_PATH, window\.location\.origin\)/);
assert.match(reader, /wasmUrl:\s*pdfjsWasmUrl\(\)/);
});
test("the build wrapper restores every generated checked-in input", () => {
const source = read("scripts", "build.mjs");
for (const name of ["next-env.d.ts", "tsconfig.json"]) {
+1
View File
@@ -102,6 +102,7 @@ test("isAuthExempt allows public static assets through the auth gate (issue #599
assert.equal(isAuthExempt("/logo_black.png"), true);
assert.equal(isAuthExempt("/apple-touch-icon.png"), true);
assert.equal(isAuthExempt("/provider-icons/openai.svg"), true);
assert.equal(isAuthExempt("/pdfjs/wasm/openjpeg.wasm"), true);
});
test("isAuthExempt allows auth pages and Next internals", () => {