Skip to content

Bundled apps (Next.js, Vite) lose the PDF.js data folders: findings and a BinaryDataFactory prototype #66

Closed

Description

@alekswebnet

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:srcdoc iframe, 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 worker in the console).
  • The resource defaults degrade. new URL('wasm/', '/_next/static/media/viewer.<hash>.mjs') throws, the catch at src/pdfjs-viewer-element.ts#L177-L182 returns a bare wasm/, and PDF.js resolves that against the page's path. Copying wasm/ into public/ 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:

  1. 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.
  2. A factory that answers requests from that name → URL map.
  3. The factory is passed in by wrapping PDFViewerApplication.open(), which forwards its arguments to getDocument(). The viewer's AppOptions does not accept BinaryDataFactory or useWorkerFetch (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.wasm is loaded with a synchronous fetch inside the worker, and useWorkerFetch: false turns 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 opens defaultUrl (the prototype withheld src until after the wrap). The package pins PDF.js, so a test would catch a change.
  • The factory's fetch runs 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 (the src/web/ and dist/ 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" to exports (finding 4)
  • Docs updates above, and reply on [Question]: How to make this work with Nextjs? #65
  • Decide on the BinaryDataFactory approach: detection, per-kind lazy maps, build-time map generation, tests with the PDF.js test files

Activity

  1. alekswebnet commented on Nov 1, 2026

    @alekswebnet
    CollaboratorAuthor

    Here's how the experiments turned out:

    • fix: Next.js worker and stylesheets, strict page CSP, package.json export #70 fixes findings 2, 3 and 4. It also fixes a problem found while testing: under a page CSP without 'unsafe-inline' for styles, the element collapsed to a 150px iframe and injectViewerStyles did nothing. The stylesheets now ship as JS modules and are applied as constructed stylesheets, which a CSP doesn't block.
    • Finding 1 stays a documented requirement. The BinaryDataFactory approach 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.
    • The README in fix: Next.js worker and stylesheets, strict page CSP, package.json export #70 now says what breaks without the folders. Explain the data folders bundlers don't copy missing-elements.github.io#5 adds a copy script to the hosting docs, going live with the release.

    Closing, since #70 is merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions