From 39d3a0a9f4aa658c712327ba8e164a3496a49c04 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 1 Nov 2026 02:21:07 +0200 Subject: [PATCH 1/5] fix: resolve the bundler's default runtime URLs against the page Next.js, with webpack and Turbopack, returns `new URL('./file', import.meta.url).href` as a root-relative path such as `/_next/static/media/pdf.worker.min..mjs`. Inside the srcdoc iframe, PDF.js can't resolve a relative worker URL and silently falls back to running on the main thread, and the resource defaults derived from the viewer's URL degraded to bare `wasm/` and `cmaps/`. The defaults now go through `getFullPath` like attribute values do. Refs #66 Co-Authored-By: Claude Opus 5.5 --- src/pdfjs-viewer-element.ts | 4 +++- tests/basic.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/pdfjs-viewer-element.ts b/src/pdfjs-viewer-element.ts index c62b496..939161c 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -149,7 +149,9 @@ export class PdfjsViewerElement extends HTMLElement { const { attribute, fileName, defaultUrl } = RUNTIME_FILES[field] const attr = this.getAttribute(attribute)?.trim() if (attr) return this.getFullPath(attr) - if (!assetsBase) return defaultUrl + // Next.js (webpack and Turbopack) rewrites the defaults to root-relative URLs. The worker and + // the resource folders derived from these need absolute ones. + if (!assetsBase) return this.getFullPath(defaultUrl) try { return new URL(fileName, assetsBase).href diff --git a/tests/basic.test.ts b/tests/basic.test.ts index e2bd7d3..8cc0dd5 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -530,6 +530,36 @@ describe('Resource URL attributes', () => { } }) + it('makes the defaults absolute when a bundler rewrites them to relative URLs', async () => { + // Next.js returns `new URL('./file', import.meta.url).href` as a root-relative path. A fresh copy + // of the module is evaluated with a URL class that does the same for the module's runtime files. + const NativeURL = URL + class RelativeURL extends NativeURL { + override get href() { + return /^\/src\/(build|web|themes)\//.test(this.pathname) ? this.pathname : super.href + } + } + globalThis.URL = RelativeURL + let RelativeElement: typeof PdfjsViewerElement + try { + ({ PdfjsViewerElement: RelativeElement } = await import(/* @vite-ignore */ '/src/pdfjs-viewer-element.ts?relative-urls')) + } finally { + globalThis.URL = NativeURL + } + if (!customElements.get('pdfjs-viewer-relative-urls')) { + customElements.define('pdfjs-viewer-relative-urls', RelativeElement) + } + + document.body.innerHTML = '' + const viewer = document.body.querySelector('pdfjs-viewer-relative-urls') as PdfjsViewerElement + await viewer.initPromise + + const options = viewer.iframe.contentWindow.PDFViewerApplicationOptions.getAll() + expect(options.workerSrc).eq(new URL('/src/build/pdf.worker.mjs', location.href).href) + expect(options.cMapUrl).eq(new URL('/src/web/cmaps/', location.href).href) + expect(options.sandboxBundleSrc).eq(new URL('/src/build/pdf.sandbox.mjs', location.href).href) + }) + it('follows assets-base', () => { const viewer = new PdfjsViewerElement() viewer.setAttribute('assets-base', '/static/pdfjs') From 4f57b102a41ba93e8a0119e2eb94238afada9911 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 1 Nov 2026 02:21:25 +0200 Subject: [PATCH 2/5] fix: export package.json Scripts that locate the package with `require.resolve('@missing-elements/pdfjs-viewer/package.json')`, for example to copy `dist` next to the app, failed with ERR_PACKAGE_PATH_NOT_EXPORTED. Refs #66 Co-Authored-By: Claude Opus 5.5 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 704c5ff..c1671da 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "types": "./types/pdfjs-viewer-element.d.ts", "default": "./dist/pdfjs-viewer-element.js" }, - "./dist/*": "./dist/*" + "./dist/*": "./dist/*", + "./package.json": "./package.json" }, "repository": { "type": "git", From 3d526faf6d5cc894be460c171a02a02d50be0206 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 1 Nov 2026 02:26:39 +0200 Subject: [PATCH 3/5] fix: apply the default stylesheets from JS modules The element referenced viewer.css and paper-and-ink.css with `new URL('./file', import.meta.url)`, so consumer bundlers treated them as the app's own CSS. `next build --webpack` emitted both as empty files, so the viewer never initialized ("The `container` must be absolutely positioned"), and moved their contents into the host page's stylesheets, restyling the app with the viewer's global rules. That happened with `assets-base` too, since it is a build-time effect. The same bundled CSS text now ships as two JS modules, and the element applies it as constructed stylesheets on the viewer document. `assets-base`, `viewer-css-src` and `theme-css-src` still load `` elements, and dist still has both `.css` files for them. - `injectViewerStyles` styles are constructed stylesheets too, so they still come after the theme. - A theme from `theme-css-src` is a ``, which precedes every constructed stylesheet. The bundled viewer stylesheet then goes into a cascade layer, so the theme still overrides it. - The viewer document stays `hidden` until its stylesheets are applied, so it never paints unstyled. - Constructed stylesheets aren't blocked by a page CSP without `'unsafe-inline'` for styles, which the srcdoc iframe inherits. Refs #66 Co-Authored-By: Claude Opus 5.5 --- scripts/bundle-css.mjs | 21 +++++++ scripts/copy-worker.mjs | 21 ++----- src/pdfjs-viewer-element.ts | 104 ++++++++++++++++++++------------ src/vite-env.d.ts | 10 +++ tests/basic.test.ts | 37 +++++++++++- tests/fixtures/custom-theme.css | 4 ++ types/pdfjs-viewer-element.d.ts | 6 +- vite.config.ts | 26 +++++++- 8 files changed, 169 insertions(+), 60 deletions(-) create mode 100644 scripts/bundle-css.mjs create mode 100644 tests/fixtures/custom-theme.css diff --git a/scripts/bundle-css.mjs b/scripts/bundle-css.mjs new file mode 100644 index 0000000..85d3a49 --- /dev/null +++ b/scripts/bundle-css.mjs @@ -0,0 +1,21 @@ +import { build } from 'esbuild' + +// Bundles a stylesheet so that its url(...) references are inlined as data URLs. Consumer +// bundlers emit dist files under hashed, relocated names, and viewer.css alone would otherwise +// break: it references images/ relative to its own URL. esbuild picks base64 or percent +// encoding per file, whichever is shorter. +export const bundleCss = async (sourcePath) => { + const result = await build({ + entryPoints: [sourcePath], + bundle: true, + minify: true, + legalComments: 'none', + write: false, + loader: { + '.svg': 'dataurl', + '.gif': 'dataurl' + } + }) + + return result.outputFiles[0].text +} diff --git a/scripts/copy-worker.mjs b/scripts/copy-worker.mjs index 7f3e717..e200985 100644 --- a/scripts/copy-worker.mjs +++ b/scripts/copy-worker.mjs @@ -1,6 +1,7 @@ import { cp, mkdir, readFile, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' -import { build, transform } from 'esbuild' +import { transform } from 'esbuild' +import { bundleCss } from './bundle-css.mjs' const rootDir = resolve(import.meta.dirname, '..') const sourceFile = resolve(rootDir, 'src', 'build', 'pdf.worker.min.mjs') @@ -42,22 +43,10 @@ const minifyModuleFile = async (sourcePath, targetPath) => { await writeFile(targetPath, result.code, 'utf8') } -// Bundles a stylesheet so that its url(...) references are inlined as data URLs. Consumer -// bundlers emit dist files under hashed, relocated names, and viewer.css alone would otherwise -// break: it references images/ relative to its own URL. esbuild picks base64 or percent -// encoding per file, whichever is shorter. +// The element applies the same text from JS modules (see vite.config.ts); these files are for +// `assets-base`, `viewer-css-src` and `theme-css-src`. const bundleCssFile = async (sourcePath, targetPath) => { - await build({ - entryPoints: [sourcePath], - outfile: targetPath, - bundle: true, - minify: true, - legalComments: 'none', - loader: { - '.svg': 'dataurl', - '.gif': 'dataurl' - } - }) + await writeFile(targetPath, await bundleCss(sourcePath), 'utf8') } await Promise.all([ diff --git a/src/pdfjs-viewer-element.ts b/src/pdfjs-viewer-element.ts index 939161c..b7d9484 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -21,22 +21,17 @@ const DEFAULT_VIEWER_SRC = import.meta.env.DEV ? new URL('./web/viewer.mjs', import.meta.url).href : new URL(/* @vite-ignore */ './viewer.mjs', import.meta.url).href -const DEFAULT_VIEWER_CSS_SRC = import.meta.env.DEV - ? new URL('./web/viewer.css', import.meta.url).href - : new URL(/* @vite-ignore */ './viewer.css', import.meta.url).href - -const DEFAULT_PAPER_AND_INK_THEME_CSS_SRC = import.meta.env.DEV - ? new URL('./themes/paper-and-ink.css', import.meta.url).href - : new URL(/* @vite-ignore */ './paper-and-ink.css', import.meta.url).href - // Runtime files of the viewer: the attribute that overrides each URL, the file name inside the -// package `dist` folder (used when `assets-base` is set) and the bundled default. +// package `dist` folder (used when `assets-base` is set) and the bundled default. The stylesheets +// have no default URL: bundlers treat a referenced `.css` file as the app's own CSS (Next.js with +// webpack moves it into the host page and emits an empty file), so without the attribute or +// `assets-base` the element applies the same CSS from JS modules instead (`buildViewerEntry`). const RUNTIME_FILES = { workerSrc: { attribute: 'worker-src', fileName: 'pdf.worker.min.mjs', defaultUrl: DEFAULT_BUILT_IN_WORKER_SRC }, pdfjsLibSrc: { attribute: 'pdfjs-lib-src', fileName: 'pdf.mjs', defaultUrl: DEFAULT_PDF_SRC }, viewerSrc: { attribute: 'viewer-src', fileName: 'viewer.mjs', defaultUrl: DEFAULT_VIEWER_SRC }, - viewerCssSrc: { attribute: 'viewer-css-src', fileName: 'viewer.css', defaultUrl: DEFAULT_VIEWER_CSS_SRC }, - themeCssSrc: { attribute: 'theme-css-src', fileName: 'paper-and-ink.css', defaultUrl: DEFAULT_PAPER_AND_INK_THEME_CSS_SRC } + viewerCssSrc: { attribute: 'viewer-css-src', fileName: 'viewer.css', defaultUrl: '' }, + themeCssSrc: { attribute: 'theme-css-src', fileName: 'paper-and-ink.css', defaultUrl: '' } } as const type RuntimeFileUrls = Record @@ -107,6 +102,9 @@ export class PdfjsViewerElement extends HTMLElement { private localeMap?: Record private localeResourceLink?: HTMLLinkElement private viewerStyles = new Set() + // The viewer document whose stylesheets are in place, and the `viewerStyles` applied to it. + private styledDocument?: Document + private appliedViewerStyles = new Set() private customViewerOptions: Record = {} static get observedAttributes() { @@ -237,24 +235,30 @@ export class PdfjsViewerElement extends HTMLElement { } } - private appendRuntimeStyle(styles: string) { - const doc = this.iframe?.contentDocument - if (!doc?.head || !styles) return - - const exists = Array.from(doc.querySelectorAll('style')) - .some((styleNode) => styleNode.textContent === styles) - if (exists) return - - const styleElement = doc.createElement('style') - styleElement.setAttribute('data-pdfjs-viewer-runtime-style', 'true') - styleElement.textContent = styles - doc.head.appendChild(styleElement) + // Constructed stylesheets, unlike ``).join('\n')} - `) - this.iframe.addEventListener('load', () => resolve(), { once: true }) + .replace('', `${stylesheetLinks}`) + // Constructed stylesheets are applied once the document has loaded, so it starts hidden + // rather than painting the viewer unstyled. + .replace(/])/, ' { + const doc = this.iframe.contentDocument + if (doc) { + // A theme from `theme-css-src` is a ``, which precedes every constructed + // stylesheet. The bundled viewer stylesheet then goes into a cascade layer, so the + // theme still overrides it. + const bundledViewerCss = viewerCss && themeCssSrc + ? `@layer pdfjs-viewer{${viewerCss.default}}` + : viewerCss?.default + this.styledDocument = doc + this.appliedViewerStyles = new Set() + this.adoptStyleSheets(doc, [bundledViewerCss, themeCss?.default].filter((styles) => styles !== undefined)) + this.applyViewerStyles() + doc.body.hidden = false + } + resolve() + }, { once: true }) this.iframe.srcdoc = completeHtml }) } @@ -547,7 +576,6 @@ export class PdfjsViewerElement extends HTMLElement { await viewerApp?.initializedPromise this.applyViewerTheme() - this.applyQueuedRuntimeStyles() return { viewerApp @@ -657,7 +685,7 @@ export class PdfjsViewerElement extends HTMLElement { public async injectViewerStyles(styles: string) { if (!styles) return this.viewerStyles.add(styles) - this.appendRuntimeStyle(styles) + this.applyViewerStyles() } public async setViewerOptions(options: Record = {}) { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 8ee0598..5743bce 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -4,3 +4,13 @@ declare module 'virtual:pdfjs-viewer-html' { const content: string export default content } + +declare module 'virtual:pdfjs-viewer-css' { + const content: string + export default content +} + +declare module 'virtual:pdfjs-viewer-theme-css' { + const content: string + export default content +} diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 8cc0dd5..3dcaa8a 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -61,8 +61,10 @@ describe('Basic tests', async () => { await mountViewer('') const viewer = document.body.querySelector('pdfjs-viewer-element')! const doc = getIframe().contentDocument! - const themeSheet = Array.from(doc.styleSheets).find(sheet => sheet.href?.endsWith('/paper-and-ink.css'))! // The whole theme sits in one block that steps aside in forced colors mode. + const themeSheet = doc.adoptedStyleSheets.find(sheet => + (sheet.cssRules[0] as CSSMediaRule | undefined)?.conditionText === 'not all and (forced-colors: active)' + )! expect(themeSheet.cssRules).toHaveLength(1) const themeBlock = themeSheet.cssRules[0] as CSSMediaRule expect(themeBlock.conditionText).eq('not all and (forced-colors: active)') @@ -71,13 +73,14 @@ describe('Basic tests', async () => { ) // One system dark block per palette; the neutral palette hides the warm one in the colors below. expect(darkRules).toHaveLength(2) - // `--hover-filter` comes from viewer.css, which switches it on the system scheme alone. + // `--hover-filter` comes from viewer.css, which switches it on the system scheme alone. The + // bundled stylesheets are minified, and custom properties keep the text as written. const colors = () => [ getComputedStyle(doc.documentElement).getPropertyValue('--body-bg-color').trim(), getComputedStyle(doc.querySelector('#viewsManager')!).getPropertyValue('--text-color').trim(), getComputedStyle(doc.documentElement).getPropertyValue('--hover-filter').trim() ] - const light = ['#f1f3f5', '#1f2328', 'brightness(0.9)'] + const light = ['#f1f3f5', '#1f2328', 'brightness(.9)'] const dark = ['#0d1117', '#e6edf3', 'brightness(1.4)'] for (const systemDark of [false, true, false]) { // Toggle matching of the system dark rules without depending on the host's theme. @@ -114,6 +117,34 @@ describe('Basic tests', async () => { expect(getComputedStyle(getViewerElement('#downloadButton')!).display).eq('none') }) + it('should apply the bundled stylesheets without referencing CSS files', async () => { + await mountViewer('') + + const doc = getIframe().contentDocument! + expect(doc.querySelector('link[rel="stylesheet"]')).not.exist + expect(doc.adoptedStyleSheets.length).toBeGreaterThanOrEqual(2) + expect(doc.body.hidden).toBe(false) + expect(getComputedStyle(getViewerElement('#viewerContainer')!).position).eq('absolute') + }) + + it('should apply injected styles after the theme', async () => { + document.body.innerHTML = '' + const viewer = document.body.querySelector('pdfjs-viewer-element') as PdfjsViewerElement + await viewer.injectViewerStyles(':root, :root[data-color-scheme] { --body-bg-color: rgb(4, 5, 6) }') + await viewer.initPromise + + const doc = getIframe().contentDocument! + expect(getComputedStyle(doc.documentElement).getPropertyValue('--body-bg-color').trim()).eq('rgb(4, 5, 6)') + }) + + it('should let a theme from theme-css-src override the bundled viewer stylesheet', async () => { + await mountViewer('') + + const doc = getIframe().contentDocument! + expect(getComputedStyle(doc.documentElement).getPropertyValue('--body-bg-color').trim()).eq('rgb(1, 2, 3)') + expect(getComputedStyle(getViewerElement('#viewerContainer')!).position).eq('absolute') + }) + it('should add a title attribute to the iframe', async () => { const viewerApp = await mountViewer(` = { + 'virtual:pdfjs-viewer-css': 'src/web/viewer.css', + 'virtual:pdfjs-viewer-theme-css': 'src/themes/paper-and-ink.css' +} + +const stylesheetModules = () => ({ + name: 'stylesheet-modules', + resolveId(id: string) { + return id in STYLESHEET_VIRTUAL_IDS ? `\0${id}` : null + }, + async load(id: string) { + const sourcePath = id.startsWith('\0') ? STYLESHEET_VIRTUAL_IDS[id.slice(1)] : undefined + if (!sourcePath) { + return null + } + + const css = await bundleCss(resolve(process.cwd(), sourcePath)) + return `export default ${JSON.stringify(css)};` + } +}) const RESOLVED_VIEWER_HTML_VIRTUAL_ID = '\0virtual:pdfjs-viewer-html' const rawViewerHtmlMinifier = () => ({ @@ -42,7 +66,7 @@ const rawViewerHtmlMinifier = () => ({ // https://vitejs.dev/config/ export default defineConfig({ - plugins: [rawViewerHtmlMinifier()], + plugins: [rawViewerHtmlMinifier(), stylesheetModules()], build: { minify: 'terser', rollupOptions: { From 4f0bb7455c65d33ea10aeb3eb11c1065c4ffdd0d Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 1 Nov 2026 02:27:29 +0200 Subject: [PATCH 4/5] fix: style the element under a page CSP that blocks inline styles The shadow root's ` - - ` + const iframe = '' + if (HOST_STYLES) { + shadowRoot.adoptedStyleSheets = [HOST_STYLES] + shadowRoot.innerHTML = iframe + } else { + shadowRoot.innerHTML = `${iframe}` + } } public iframe!: PdfjsViewerElementIframe diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 3dcaa8a..11754ab 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -233,6 +233,20 @@ describe('Basic tests', async () => { await vi.waitFor(() => expect(doc.querySelector('#previous')?.getAttribute('title')).eq('Previous Page')) }) + it('should keep the element and the viewer styled under a page CSP that blocks inline styles', async () => { + const viewer = await mountViewerWithPageCsp("style-src 'self'", ` + ` + ) as unknown as PdfjsViewerElement + await viewer.injectViewerStyles('#outerContainer { letter-spacing: 3px }') + + const hostWindow = viewer.ownerDocument.defaultView! + const viewerWindow = viewer.iframe.contentWindow + const doc = viewer.iframe.contentDocument! + expect(hostWindow.getComputedStyle(viewer).display).eq('block') + expect(viewerWindow.getComputedStyle(doc.querySelector('#viewerContainer')!).position).eq('absolute') + expect(viewerWindow.getComputedStyle(doc.querySelector('#outerContainer')!).letterSpacing).eq('3px') + }) + it('should apply worker-src option at runtime', async () => { await mountViewer(` ` From 203b6d941164d32733b3267c9b5b183667f429df Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 1 Nov 2026 14:04:35 +0200 Subject: [PATCH 5/5] docs: say what breaks without the data folders in bundled apps Vite, webpack 5 and Next.js bundle the viewer but not the folders PDF.js loads its data from by name. Without them, scans compressed with JBIG2, CCITT or JPEG 2000 render blank or black, CJK text in fonts the PDF doesn't embed goes missing, and JavaScript in PDFs doesn't run. Checked in Next.js (Turbopack and webpack) and Vite builds, which render all of it once a postinstall copy of dist is served and assets-base points at it. The note moves from "Why use it" to Install, where bundler users set the package up. Refs #66 Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5adb34a..47f3c3a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The [PDF.js default viewer](https://mozilla.github.io/pdf.js/web/viewer.html) as ## Why use it - **Instead of the browser's built-in viewer:** you get the same viewer in every browser, and your code controls it. Set the page, zoom, search, theme and language with attributes, or use PDF.js's own API. -- **Instead of wiring up `pdfjs-dist` yourself:** PDF.js, its viewer interface, its worker and the data some PDFs need (CMaps, fonts, image decoders, the scripting sandbox) ship in the package, and nothing loads from a CDN by default. Vite and webpack 5 bundle the viewer with no configuration, but they don't copy the data folders: if your documents need them, serve the package's `dist` folder and point [`assets-base`](https://missing-elements.github.io/pdfjs-viewer/#hosting) at it. +- **Instead of wiring up `pdfjs-dist` yourself:** PDF.js, its viewer interface, its worker and the data some PDFs need (CMaps, fonts, image decoders, the scripting sandbox) ship in the package, and nothing loads from a CDN by default. - **What it costs:** the element is 4 KB gzipped. The viewer files, about 720 KB gzipped, load only when a viewer is on the page, and the extra PDF.js data only for documents that need it. - **When to use something else:** if you want your own page-rendering interface rather than the PDF.js viewer, use [`pdfjs-dist`](https://www.npmjs.com/package/pdfjs-dist) directly. @@ -26,6 +26,12 @@ npm install @missing-elements/pdfjs-viewer import '@missing-elements/pdfjs-viewer' ``` +Vite, webpack 5 and Next.js bundle the viewer with no configuration. They don't copy the PDF.js data folders, though, because PDF.js loads those files by name. Without them, scanned pages compressed with JBIG2, CCITT or JPEG 2000 render blank or black, CJK text in fonts the PDF doesn't embed goes missing, and JavaScript in PDFs doesn't run. To support every PDF, copy the package's `dist` folder into your public files, for example in a `postinstall` script, and point [`assets-base`](https://missing-elements.github.io/pdfjs-viewer/#hosting) at it: + +```html + +``` + Or load it from a CDN without a build step: ```html