fix(reading): package PDF.js decoder assets (#1305)
Co-authored-by: XZH <cto@xzh.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,6 +43,7 @@ const config = [
|
||||
"playwright-report/**",
|
||||
"test-results/**",
|
||||
"contracts/generated/.tmp/**",
|
||||
"public/pdfjs/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 .",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user