Repository navigation
Description
Follow-up to #65, which asks for a Next.js setup because jbig2.wasm doesn't load. Reproduced with the published 4.4.0 package in Next.js 16.4.3 (Turbopack and --webpack) and Vite 8.3.5, with PDF.js's own test files: jbig2_symbol_offset.pdf, ccitt_EndOfBlock_false.pdf, bug_jpx.pdf (images) and noembed-identity.pdf, noembed-identity-2.pdf, vertical.pdf (CJK text that needs a CMap). The reported problem turned out to be one of three separate problems on Next.js, and there is a partial fix the package could ship. This issue collects the findings so we can experiment with them.
Findings
1. Bundlers emit the runtime files but not the PDF.js data (the reported problem)
Vite, webpack and Turbopack copy the five runtime files because the element references each one with a literal new URL('./file', import.meta.url) (src/pdfjs-viewer-element.ts#L12-L30). Nothing references wasm/, cmaps/, standard_fonts/, iccs/, images/ or pdf.sandbox.mjs, so they are left behind. The element only derives their folder from the viewer's location (new URL('wasm/', viewerSrc)), which after bundling points at /assets/wasm/ or /_next/static/media/wasm/, folders nobody created.
In PDF.js 6, JBIG2, CCITT fax and JPEG 2000 images are decoded only by WASM, and even the no-WASM fallback lives in wasm/. So scanned PDFs render wrong (blank page, or a solid fill for CCITT), and CJK text that needs a CMap loses its glyphs and text extraction (ABC 1231 instead of ABC あいうえお 1231). Much more common than an edge case.
Bundlers can't pick this up on their own: PDF.js asks for these files by name from a folder URL (${wasmUrl}jbig2.wasm, ${cMapUrl}${name}.bcmap), the names depend on the document, and a bundler only emits files that are referenced one by one, under hashed names.
Vite is a special case: the dev server serves the real node_modules/…/dist folder, so everything works in vite dev and breaks in vite build.
2. Next.js returns relative URLs from new URL('./x', import.meta.url).href (bug)
Both Next.js bundlers do it (Turbopack through its URL shim, webpack through its relative URL mode): the element gets /_next/static/media/pdf.worker.min.<hash>.mjs, and it assumes these are absolute. Two consequences:
- The PDF.js worker never starts. Inside the
about:srcdociframe,new URL(workerSrc, window.location)throws for a relative path, PDF.js catches it and silently falls back to the main thread (Setting up fake workerin the console). - The resource defaults degrade.
new URL('wasm/', '/_next/static/media/viewer.<hash>.mjs')throws, thecatchat src/pdfjs-viewer-element.ts#L177-L182 returns a barewasm/, and PDF.js resolves that against the page's path. Copyingwasm/intopublic/would only work on top-level routes.
Fix: at src/pdfjs-viewer-element.ts#L152 return this.getFullPath(defaultUrl) instead of defaultUrl. Verified on a patched copy of the dist file: a real Worker starts.
3. next build --webpack emits the two stylesheets as 0-byte files (bug)
Next's own CSS rules catch viewer.css and paper-and-ink.css and emit empty files. PDF.js then throws The container must be absolutely positioned., the viewer stays unstyled, never initializes, and initPromise never settles. The three .mjs files are re-minified but intact. Turbopack emits all five files unchanged. (This was the default build before Next 16, which I did not test.)
Fix idea: ship both stylesheets the way the viewer HTML already ships, as a JS module injected as <style>, and keep the <link> path when assets-base, viewer-css-src or theme-css-src is set. The iframe CSP already allows 'unsafe-inline' for styles.
4. exports has no ./package.json entry (minor)
Copy scripts that locate the package through require.resolve('@missing-elements/pdfjs-viewer/package.json') fail with ERR_PACKAGE_PATH_NOT_EXPORTED.
Results with 4.4.0 unchanged
| Next.js setup | Turbopack | webpack |
|---|---|---|
| No configuration | Viewer loads, fake worker, scanned images and CMap text broken | Unstyled, never initializes |
Pinned CDN wasm-url only |
Images render, still fake worker | not tested |
dist copied to public/ + assets-base |
Everything works | Everything works |
Pinned CDN assets-base |
Everything works | not tested |
assets-base also sidesteps bugs 2 and 3, because the element stops using the bundler-emitted files altogether.
What works today
Copy dist and set assets-base (any bundler):
"scripts": {
"postinstall": "node -e \"require('fs').cpSync('node_modules/@missing-elements/pdfjs-viewer/dist', 'public/pdfjs-viewer', { recursive: true })\""
}<pdfjs-viewer-element src="/file.pdf" assets-base="/pdfjs-viewer/"></pdfjs-viewer-element>Pinned CDN: assets-base="https://cdn.jsdelivr.net/npm/@missing-elements/pdfjs-viewer@4.4.0/dist/". The version must match the package, so the WASM matches the bundled PDF.js.
Vite only, no attributes: let Vite emit the data files, but keep their names and folders. An import alone is not enough (files get hashed names, flat in assets/, and 135 of the 169 CMaps are inlined as data: URLs into the entry chunk, +256 KB). With the naming rule, all three image formats decoded and nothing failed:
// main.js
import.meta.glob(
'/node_modules/@missing-elements/pdfjs-viewer/dist/{wasm,cmaps,standard_fonts,iccs,images}/*',
{ query: '?url&no-inline', eager: true }
)
import.meta.glob('/node_modules/@missing-elements/pdfjs-viewer/dist/pdf.sandbox.mjs', { query: '?url&no-inline', eager: true })// vite.config.js (Vite 8: rolldownOptions; Vite ≤7: rollupOptions)
const PDFJS_DATA = /@missing-elements\/pdfjs-viewer\/dist\/((?:wasm|cmaps|standard_fonts|iccs|images)\/[^/]+|pdf\.sandbox\.mjs)$/
export default {
build: {
rolldownOptions: {
output: {
assetFileNames(asset) {
const match = asset.originalFileNames?.map((f) => PDFJS_DATA.exec(f)).find(Boolean)
return match ? `assets/${match[1]}` : 'assets/[name]-[hash][extname]'
}
}
}
}
}The glob assumes the package sits in the app's own node_modules (not hoisted to a workspace root).
Prototype: serve the data through PDF.js's BinaryDataFactory
PDF.js's URL options only take folders, but getDocument() also accepts a BinaryDataFactory (with useWorkerFetch: false): the worker then asks the page for { kind, filename } (cMapUrl, standardFontDataUrl, wasmUrl) and the page returns the bytes. That lets the package supply these files by name from wherever the bundler put them. The prototype:
- A generated module with one literal
new URL('./cmaps/X.bcmap', import.meta.url)per data file, 184 entries (2 decoders, 168 CMaps, 14 fonts), so bundlers emit each file and rewrite each URL, exactly like the runtime files. - A factory that answers requests from that name → URL map.
- The factory is passed in by wrapping
PDFViewerApplication.open(), which forwards its arguments togetDocument(). The viewer'sAppOptionsdoes not acceptBinaryDataFactoryoruseWorkerFetch(unknown names are ignored), so the wrap is the only route.
const FOLDER_BY_KIND = { cMapUrl: 'cmaps', standardFontDataUrl: 'standard_fonts', wasmUrl: 'wasm' }
class BundledDataFactory {
async fetch({ kind, filename }) {
const url = files[`${FOLDER_BY_KIND[kind]}/${filename}`]
if (!url) throw new Error(`No bundled ${kind} file: ${filename}`)
const response = await fetch(url)
if (!response.ok) throw new Error(`${response.status} ${url}`)
return new Uint8Array(await response.arrayBuffer())
}
}
const open = viewerApp.open.bind(viewerApp)
viewerApp.open = (args) => open({ BinaryDataFactory: BundledDataFactory, useWorkerFetch: false, ...args })Results, identical in Next.js/Turbopack and Vite (no copy step, no attributes):
| Today, no setup | Factory prototype | Copied folder | |
|---|---|---|---|
| JBIG2 / CCITT / JPEG 2000 images | wrong | rendered | rendered |
| Japanese text (CMap) | ABC 1231 |
ABC あいうえお 1231 |
ABC あいうえお 1231 |
Same ink ratios and extracted text as the copied folder, real worker, no failed requests. The browser fetched the renamed files (jbig2.31m8gst1beb1k.wasm, Adobe-Japan1-UCS2.<hash>.bcmap), which only exist in the map, so the factory path was really exercised. All 184 emitted files were byte-identical to the package's.
What it can't cover (PDF.js hard-codes the folder URL for these):
- ICC color management:
qcms_bg.wasmis loaded with a synchronous fetch inside the worker, anduseWorkerFetch: falseturns it off. Note it is already off in bundled builds today (the folder 404s, PDF.js warns and continues), so the factory loses nothing versus today; it only falls short of the copied folder. - JavaScript in PDFs: the sandbox imports
${wasmUrl}quickjs-eval.js. - Annotation icons:
images/next to the viewer. Already broken in bundled builds today. - No-WASM fallbacks:
import(${wasmUrl}jbig2_nowasm_fallback.js). Only matter when WASM is blocked, for example by a strict page CSP.
Costs and caveats:
- 184 extra files in every app build. In Next.js they are only fetched on demand. In Vite, 134 of them were inlined as
data:URLs into a 136 KB chunk that the prototype loads at every viewer start. Per-kind maps loaded on first request (CMaps only when a CMap is first asked for) would fix that. - The fonts part is nearly moot in browsers: with the viewer's default
useSystemFonts: true, PDF.js only fetches standard font data for Symbol and ZapfDingbats. Handled identically by the factory, but untested. - The wrap of
open()is viewer internals. In the element it has to be installed before the viewer opensdefaultUrl(the prototype withheldsrcuntil after the wrap). The package pins PDF.js, so a test would catch a change. - The factory's
fetchruns in the parent page's realm, which is why Next.js's root-relative URLs resolved. The URLs should be made absolute anyway (same bug class as finding 2). - Shipping the map means generating it at package build time directly into
dist(thesrc/web/anddist/layouts differ), and consumer bundlers must follow the element's dynamic import of it. They already do that for the viewer-HTML and locale chunks. - Untested: the
data:-URL branch, the unpatched 4.4.0 element (all runs used the finding-2 patch; the fake worker should still route through the factory), Next.js with webpack (needs the CSS fix first), the Vite dev server, pages with a CSP.
Open design question: when to use the factory. Using it unconditionally would turn ICC off for CDN and assets-base users who have it working today. Options: use the folder path whenever assets-base or a resource attribute is set, and otherwise detect a relocated build, for example by the hashed file name of the rewritten pdf.worker.min.mjs URL. Not designed yet.
Docs
- Add a Next.js section (copy +
assets-base, the pinned CDN alternative). - Qualify "Vite and webpack 5 need no configuration": true for the viewer files, not for the data, and not for Next.js's webpack build.
- Warn that Vite works in dev and breaks in production without the data folders.
- Optionally document the Vite naming rule above.
Proposed next steps
- Make the bundler's default URLs absolute (finding 2), with a test
- Ship the two stylesheets as JS modules, keep
<link>for custom URLs (finding 3) - Add
"./package.json"toexports(finding 4) - Docs updates above, and reply on [Question]: How to make this work with Nextjs? #65
- Decide on the
BinaryDataFactoryapproach: detection, per-kind lazy maps, build-time map generation, tests with the PDF.js test files
Activity
docs: say what breaks without the data folders in bundled apps
alekswebnet commentedon Nov 1, 2026
Here's how the experiments turned out:
'unsafe-inline'for styles, the element collapsed to a 150px iframe andinjectViewerStylesdid nothing. The stylesheets now ship as JS modules and are applied as constructed stylesheets, which a CSP doesn't block.BinaryDataFactoryapproach worked: Next.js (Turbopack and webpack) and Vite builds rendered the scans and CJK text with no setup. But a factory only serves CMaps, standard fonts and the WASM decoders. PDF.js still loads the scripting sandbox, ICC profiles and annotation icons from folders, and it turns ICC off whenever a custom factory is set. So full support would still need the copy. That isn't worth a second way of loading data that depends on PDF.js internals, 0.6 KB more on every page, and a 48 KB CMap chunk in Vite builds.Closing, since #70 is merged.