From 0cb38d429132bae1d6f7d8e1e65860097d6aec1b Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 25 Oct 2026 01:58:32 +0300 Subject: [PATCH 1/9] fix: resolve PDF.js resource URLs and add the folder trailing slash PDF.js fetches CMaps, standard fonts, WASM decoders and ICC profiles from its worker, which runs from a blob: URL, so a relative c-map-url, standard-font-data-url, wasm-url or icc-url never loaded. The element now resolves these attributes, plus sandbox-bundle-src and debugger-src, against the page the same way it resolves src and the runtime files, and adds the trailing slash PDF.js requires on folder URLs ("Invalid factory url ... must include trailing slash"). Unset attributes keep their defaults. Co-Authored-By: Claude Opus 5.5 --- src/pdfjs-viewer-element.ts | 45 ++++++++++++++++++++------------- tests/basic.test.ts | 22 ++++++++++++++++ types/pdfjs-viewer-element.d.ts | 1 + 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/pdfjs-viewer-element.ts b/src/pdfjs-viewer-element.ts index 16399bb..3eb8725 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -61,6 +61,20 @@ const DEFAULTS = { localeSrcTemplate: 'https://cdn.jsdelivr.net/gh/mozilla-l10n/firefox-l10n@main/{locale}/toolkit/toolkit/pdfviewer/viewer.ftl' } as const +// PDF.js resource options set from attributes. PDF.js fetches the folders from its worker, which +// runs from a `blob:` URL where relative paths don't resolve, so attribute values are made absolute, +// and folder URLs get the trailing slash PDF.js requires. +const RESOURCE_OPTIONS = { + 'debugger-src': { key: 'debuggerSrc', fallback: DEFAULTS.debuggerSrc, folder: false }, + 'c-map-url': { key: 'cMapUrl', fallback: DEFAULTS.cMapUrl, folder: true }, + 'icc-url': { key: 'iccUrl', fallback: DEFAULTS.iccUrl, folder: true }, + 'sandbox-bundle-src': { key: 'sandboxBundleSrc', fallback: DEFAULTS.sandboxBundleSrc, folder: false }, + 'standard-font-data-url': { key: 'standardFontDataUrl', fallback: DEFAULTS.standardFontDataUrl, folder: true }, + 'wasm-url': { key: 'wasmUrl', fallback: DEFAULTS.wasmUrl, folder: true } +} as const + +type ResourceAttribute = keyof typeof RESOURCE_OPTIONS + export const ViewerCssTheme = { AUTOMATIC: 0, LIGHT: 1, DARK: 2 } as const export type PdfViewerApplicationOpenParameters = @@ -156,6 +170,13 @@ export class PdfjsViewerElement extends HTMLElement { } } + private getResourceOption(attribute: ResourceAttribute) { + const { fallback, folder } = RESOURCE_OPTIONS[attribute] + const value = this.getAttribute(attribute)?.trim() + if (!value) return fallback + return this.getFullPath(folder && !value.endsWith('/') ? `${value}/` : value) + } + private getImageResourcesPath() { const imageResourcesPath = this.getAttribute('image-resources-path') if (imageResourcesPath) return imageResourcesPath @@ -381,13 +402,10 @@ export class PdfjsViewerElement extends HTMLElement { private applyViewerOptions = () => { const viewerOptions = this.iframe.contentWindow?.PDFViewerApplicationOptions viewerOptions?.set('workerSrc', this.getRuntimeFileUrls().workerSrc) - viewerOptions?.set('debuggerSrc', this.getAttribute('debugger-src') || DEFAULTS.debuggerSrc) - viewerOptions?.set('cMapUrl', this.getAttribute('c-map-url') || DEFAULTS.cMapUrl) - viewerOptions?.set('iccUrl', this.getAttribute('icc-url') || DEFAULTS.iccUrl) + for (const [attribute, { key }] of Object.entries(RESOURCE_OPTIONS)) { + viewerOptions?.set(key, this.getResourceOption(attribute as ResourceAttribute)) + } viewerOptions?.set('imageResourcesPath', this.getImageResourcesPath()) - viewerOptions?.set('sandboxBundleSrc', this.getAttribute('sandbox-bundle-src') || DEFAULTS.sandboxBundleSrc) - viewerOptions?.set('standardFontDataUrl', this.getAttribute('standard-font-data-url') || DEFAULTS.standardFontDataUrl) - viewerOptions?.set('wasmUrl', this.getAttribute('wasm-url') || DEFAULTS.wasmUrl) viewerOptions?.set('defaultUrl', this.getFullPath(this.getAttribute('src') || DEFAULTS.src)) viewerOptions?.set('disablePreferences', true) viewerOptions?.set('eventBusDispatchToDOM', true) @@ -542,25 +560,16 @@ export class PdfjsViewerElement extends HTMLElement { if (oldValue === newValue) return if (!this.iframe) return - const optionByAttribute = { - 'debugger-src': { key: 'debuggerSrc', fallback: DEFAULTS.debuggerSrc }, - 'c-map-url': { key: 'cMapUrl', fallback: DEFAULTS.cMapUrl }, - 'icc-url': { key: 'iccUrl', fallback: DEFAULTS.iccUrl }, - 'sandbox-bundle-src': { key: 'sandboxBundleSrc', fallback: DEFAULTS.sandboxBundleSrc }, - 'standard-font-data-url': { key: 'standardFontDataUrl', fallback: DEFAULTS.standardFontDataUrl }, - 'wasm-url': { key: 'wasmUrl', fallback: DEFAULTS.wasmUrl } - } as const - if (name === 'worker-src') { const viewerOptions = this.iframe.contentWindow?.PDFViewerApplicationOptions viewerOptions?.set('workerSrc', this.getRuntimeFileUrls().workerSrc) return } - if (name in optionByAttribute) { + if (name in RESOURCE_OPTIONS) { const viewerOptions = this.iframe.contentWindow?.PDFViewerApplicationOptions - const { key, fallback } = optionByAttribute[name as keyof typeof optionByAttribute] - viewerOptions?.set(key, newValue || fallback) + const attribute = name as ResourceAttribute + viewerOptions?.set(RESOURCE_OPTIONS[attribute].key, this.getResourceOption(attribute)) return } diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 6eaa048..148a1b7 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -388,6 +388,28 @@ describe('Basic tests', async () => { }) }) +describe('Resource URL attributes', () => { + it('makes values absolute and ends folder URLs with a slash', () => { + const viewer = new PdfjsViewerElement() + const cases = [ + ['c-map-url', '/static/cmaps', new URL('/static/cmaps/', location.href).href], + ['standard-font-data-url', './fonts/', new URL('./fonts/', location.href).href], + ['wasm-url', 'https://cdn.example.com/wasm', 'https://cdn.example.com/wasm/'], + ['icc-url', ' /static/iccs/ ', new URL('/static/iccs/', location.href).href], + ['sandbox-bundle-src', '/static/pdf.sandbox.mjs', new URL('/static/pdf.sandbox.mjs', location.href).href], + ['debugger-src', 'https://cdn.example.com/debugger.mjs', 'https://cdn.example.com/debugger.mjs'] + ] as const + + for (const [attribute, value, expected] of cases) { + const fallback = viewer['getResourceOption'](attribute) + viewer.setAttribute(attribute, value) + expect(viewer['getResourceOption'](attribute)).eq(expected) + viewer.setAttribute(attribute, ' ') + expect(viewer['getResourceOption'](attribute)).eq(fallback) + } + }) +}) + describe('Runtime file URL overrides', () => { const files = { workerSrc: ['worker-src', 'pdf.worker.min.mjs'], diff --git a/types/pdfjs-viewer-element.d.ts b/types/pdfjs-viewer-element.d.ts index b24b067..355f41f 100644 --- a/types/pdfjs-viewer-element.d.ts +++ b/types/pdfjs-viewer-element.d.ts @@ -32,6 +32,7 @@ export declare class PdfjsViewerElement extends HTMLElement { private getFullPath; private getAssetsBase; private getRuntimeFileUrls; + private getResourceOption; private getImageResourcesPath; private getCssThemeOption; private applyIframeHash; From 345af195edb526ebc119f73b10abfb39fbc9d980 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 25 Oct 2026 01:58:32 +0300 Subject: [PATCH 2/9] fix: load region locales and resolve the locale template The locale check compared the attribute with the lowercase keys of locale.json, so locale="pt-BR" was dropped without a message. A lowercase "pt-br" passed, but went into the template as-is, and the locale folders keep their case (pt-BR/viewer.ftl), so the file 404ed. The element now matches the locale ignoring case, fills {locale} with the folder name from locale.json, and keys the resource by the lowercase code PDF.js looks up. The filled template is also resolved against the page. PDF.js resolves it against the blob: URL of the l10n resource link, so a relative template failed and the viewer showed no interface text at all. Co-Authored-By: Claude Opus 5.5 --- src/pdfjs-viewer-element.ts | 18 +++++++++++------- tests/basic.test.ts | 20 +++++++++++++++++++- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/pdfjs-viewer-element.ts b/src/pdfjs-viewer-element.ts index 3eb8725..db40003 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -345,17 +345,21 @@ export class PdfjsViewerElement extends HTMLElement { return } const localesData = await import('./web/locale/locale.json?raw') - const supportedLocales = Object.keys(JSON.parse(localesData.default)) - if (!supportedLocales.includes(locale as string)) { + // PDF.js looks bundles up by the lowercase code (`pt-br`), the key format of `locale.json`, + // while the locale folders keep their case (`pt-BR/viewer.ftl`), so the template gets the folder. + const lang = locale.trim().toLowerCase() + const localePath: string | undefined = JSON.parse(localesData.default)[lang] + if (!localePath) { this.cleanupLocaleResource() return } - const localeUrl = this.formatTemplate( - this.getAttribute('locale-src-template') || DEFAULTS.localeSrcTemplate, - { locale } - ) + // Made absolute because PDF.js resolves it against the `blob:` URL of the resource link below. + const localeUrl = this.getFullPath(this.formatTemplate( + this.getAttribute('locale-src-template') || DEFAULTS.localeSrcTemplate, + { locale: localePath.split('/')[0] } + )) const localeObject = { - [String(locale)]: localeUrl + [lang]: localeUrl } const localeLink = doc.createElement('link') localeLink.rel = 'resource' diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 148a1b7..8905c8d 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getFileData, getViewerElement, mountViewer, getIframe } from './utils' import { PdfjsViewerElement } from '../src/pdfjs-viewer-element' +import packageJson from '../package.json' + +// The PDF.js release that `pnpm sync:pdfjs` unpacks into `public/`. +const pdfjsDist = `/pdfjs-${packageJson.dependencies['pdfjs-dist'].match(/\d+\.\d+\.\d+/)![0]}-dist` describe('Basic tests', async () => { it('should render the PDF file', async () => { @@ -137,6 +141,20 @@ describe('Basic tests', async () => { expect(hash).toContain('pagemode=thumbs') }) + it('should load a region locale from a relative template', async () => { + const template = `${pdfjsDist}/web/locale/{locale}/viewer.ftl` + await mountViewer(` + ` + ) + + const doc = getIframe().contentDocument! + const resource = doc.querySelector('link[type="application/l10n"]')! + expect(await (await fetch(resource.href)).json()).toEqual({ + 'pt-br': new URL(template.replace('{locale}', 'pt-BR'), location.href).href + }) + await vi.waitFor(() => expect(doc.querySelector('#previous')?.getAttribute('title')).eq('Página anterior')) + }) + it('should apply worker-src option at runtime', async () => { await mountViewer(` ` From cf3b67ab430219a13fde65c5bf55f08a1386dec5 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 25 Oct 2026 01:58:32 +0300 Subject: [PATCH 3/9] fix: keep the reader's page and sidebar when a hash attribute changes The iframe hash listed every navigation parameter, set or not. PDF.js reads an empty page= as page 1 and the default pagemode=none closes the sidebar, so changing zoom, search, phrase or locale on a viewer without a page attribute sent the reader back to page 1 and closed their sidebar. Unset parameters are now left out, and pagemode falls back to none only when the viewer is built, so the sidebar still starts closed. Co-Authored-By: Claude Opus 5.5 --- src/pdfjs-viewer-element.ts | 17 +++++++++++------ tests/basic.test.ts | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/pdfjs-viewer-element.ts b/src/pdfjs-viewer-element.ts index db40003..82d6f7d 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -198,11 +198,11 @@ export class PdfjsViewerElement extends HTMLElement { : ViewerCssTheme[DEFAULTS.viewerCssTheme] } - private applyIframeHash = async () =>{ + private applyIframeHash = async (initial = false) =>{ const contentWindow = this.iframe?.contentWindow if (!contentWindow) return - const hash = this.getIframeLocationHash() + const hash = this.getIframeLocationHash(initial) if (contentWindow.location.hash === hash) return await new Promise((resolve) => { @@ -421,16 +421,21 @@ export class PdfjsViewerElement extends HTMLElement { this.optionsToSet = {} } - private getIframeLocationHash = () => { + // Unset parameters are left out: PDF.js reads an empty `page=` as page 1, so writing them would + // move the reader whenever another attribute changes. For the same reason `pagemode` falls back + // to `none` only when the viewer is built, and later changes leave the reader's sidebar alone. + private getIframeLocationHash = (initial = false) => { const params: Record = { page: this.getAttribute('page') || DEFAULTS.page, zoom: this.getAttribute('zoom') || DEFAULTS.zoom, - pagemode: this.getAttribute('pagemode') || DEFAULTS.pagemode, + pagemode: this.getAttribute('pagemode') || (initial ? DEFAULTS.pagemode : ''), search: this.getAttribute('search') || DEFAULTS.search, phrase: this.getAttribute('phrase') || DEFAULTS.phrase, locale: this.getAttribute('locale') || DEFAULTS.locale } - return '#' + Object.entries(params).map(([k, v]) => `${k}=${v}`).join('&') + const query = Object.entries(params).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join('&') + // An empty hash reads back as '' from `location.hash`, so return '' rather than '#'. + return query ? `#${query}` : '' } private buildViewerEntry = async (runtimeFileUrls: RuntimeFileUrls) => { @@ -526,7 +531,7 @@ export class PdfjsViewerElement extends HTMLElement { } private buildViewerApp = async (runtimeFileUrls: RuntimeFileUrls) => { - await this.applyIframeHash() + await this.applyIframeHash(true) // Set viewer options as soon as PDFViewerApplication is created. const setupPromise = this.setupViewerApp() diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 8905c8d..1b4b7c3 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -141,6 +141,24 @@ describe('Basic tests', async () => { expect(hash).toContain('pagemode=thumbs') }) + it('should keep the current page when an unrelated hash attribute changes', async () => { + const viewerApp = await mountViewer(` + ` + ) as unknown as { page: number, isInitialViewSet: boolean } + const contentWindow = getIframe().contentWindow + expect(contentWindow.location.hash).eq('#pagemode=none') + await vi.waitFor(() => expect(viewerApp.isInitialViewSet).toBe(true), { timeout: 10000 }) + + viewerApp.page = 3 + const viewer = document.body.querySelector('pdfjs-viewer-element') as HTMLElement + viewer.setAttribute('zoom', 'page-width') + + // No empty `page=`, which PDF.js reads as page 1, and no `pagemode`, which would close the sidebar. + expect(contentWindow.location.hash).eq('#zoom=page-width') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(viewerApp.page).eq(3) + }) + it('should load a region locale from a relative template', async () => { const template = `${pdfjsDist}/web/locale/{locale}/viewer.ftl` await mountViewer(` From 33615e9cebdc20923376087e8ac9f7423deb4b44 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 25 Oct 2026 02:28:02 +0300 Subject: [PATCH 4/9] fix: remove the debugger-src attribute debugger-src set PDF.js's debuggerSrc option, but PDF.js only loads its debugger when pdfBugEnabled is set and the viewer hash has a pdfbug parameter, which the element never writes, and debugger.mjs isn't in dist. The attribute never had an effect, so it's removed. Co-Authored-By: Claude Opus 5.5 --- src/pdfjs-viewer-element.ts | 4 +--- tests/basic.test.ts | 9 +-------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/pdfjs-viewer-element.ts b/src/pdfjs-viewer-element.ts index 82d6f7d..d46fb58 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -51,7 +51,6 @@ const DEFAULTS = { pagemode: 'none', locale: '', viewerCssTheme: 'AUTOMATIC', - debuggerSrc: './debugger.mjs', cMapUrl: '../web/cmaps/', iccUrl: '../web/iccs/', imageResourcesPath: './images/', @@ -65,7 +64,6 @@ const DEFAULTS = { // runs from a `blob:` URL where relative paths don't resolve, so attribute values are made absolute, // and folder URLs get the trailing slash PDF.js requires. const RESOURCE_OPTIONS = { - 'debugger-src': { key: 'debuggerSrc', fallback: DEFAULTS.debuggerSrc, folder: false }, 'c-map-url': { key: 'cMapUrl', fallback: DEFAULTS.cMapUrl, folder: true }, 'icc-url': { key: 'iccUrl', fallback: DEFAULTS.iccUrl, folder: true }, 'sandbox-bundle-src': { key: 'sandboxBundleSrc', fallback: DEFAULTS.sandboxBundleSrc, folder: false }, @@ -115,7 +113,7 @@ export class PdfjsViewerElement extends HTMLElement { static get observedAttributes() { return[ 'src', 'locale', 'viewer-css-theme', 'worker-src', - 'debugger-src', 'c-map-url', 'icc-url', 'image-resources-path', + 'c-map-url', 'icc-url', 'image-resources-path', 'sandbox-bundle-src', 'standard-font-data-url', 'wasm-url', 'page', 'search', 'phrase', 'zoom', 'pagemode', 'iframe-title' ] diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 1b4b7c3..80c6f7c 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -275,12 +275,6 @@ describe('Basic tests', async () => { const viewer = document.body.querySelector('pdfjs-viewer-element') as HTMLElement const cases = [ - { - attr: 'debugger-src', - key: 'debuggerSrc', - value: 'https://example.com/debugger.mjs', - fallback: './debugger.mjs' - }, { attr: 'c-map-url', key: 'cMapUrl', @@ -432,8 +426,7 @@ describe('Resource URL attributes', () => { ['standard-font-data-url', './fonts/', new URL('./fonts/', location.href).href], ['wasm-url', 'https://cdn.example.com/wasm', 'https://cdn.example.com/wasm/'], ['icc-url', ' /static/iccs/ ', new URL('/static/iccs/', location.href).href], - ['sandbox-bundle-src', '/static/pdf.sandbox.mjs', new URL('/static/pdf.sandbox.mjs', location.href).href], - ['debugger-src', 'https://cdn.example.com/debugger.mjs', 'https://cdn.example.com/debugger.mjs'] + ['sandbox-bundle-src', '/static/pdf.sandbox.mjs', new URL('/static/pdf.sandbox.mjs', location.href).href] ] as const for (const [attribute, value, expected] of cases) { From ee4d579403245ea83d9784d9368c1e9bfb3058b6 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 25 Oct 2026 02:28:24 +0300 Subject: [PATCH 5/9] fix: ship PDF.js resources and default their URLs next to the viewer files The defaults for c-map-url, icc-url, standard-font-data-url, wasm-url and sandbox-bundle-src were paths in PDF.js's source tree, for folders the package didn't ship, and image-resources-path defaulted to ./images/ next to the host page. CJK text without embedded fonts, non-embedded standard fonts, JPEG 2000 and JBIG2 images, ICC colors, PDF JavaScript and annotation icons failed unless every attribute was set. dist now includes cmaps/, iccs/, standard_fonts/, wasm/ and pdf.sandbox.mjs. The packed tarball grows from about 0.7 to 2.7 MB; the files load only for documents that need them. Each resource defaults to its folder next to viewer.mjs, and the sandbox to pdf.sandbox.mjs next to pdf.mjs, so CDN script tags, assets-base and the individual runtime URLs carry them along. Bundlers emit the runtime files but not folders, so bundled apps still set assets-base or the attributes. image-resources-path is now resolved and gets a trailing slash like the other folder attributes. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +-- demo/asset-attributes.html | 9 +++--- scripts/copy-worker.mjs | 11 ++++++- src/pdfjs-viewer-element.ts | 54 ++++++++++++--------------------- tests/basic.test.ts | 38 +++++++++++++++++++---- types/pdfjs-viewer-element.d.ts | 1 - 6 files changed, 68 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index f695d82..745fce1 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ 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 and its worker ship in the package. Vite and webpack 5 need no configuration, 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. +- **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. Vite and webpack 5 need no configuration for the viewer, 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. ## Install diff --git a/demo/asset-attributes.html b/demo/asset-attributes.html index 7e86dae..815b01b 100644 --- a/demo/asset-attributes.html +++ b/demo/asset-attributes.html @@ -32,13 +32,13 @@ worker-src="{% static 'pdfjs/pdf.worker.min.mjs' %}" viewer-css-src="{% static 'pdfjs/viewer.css' %}" theme-css-src="{% static 'pdfjs/paper-and-ink.css' %}" - image-resources-path="{% get_static_prefix %}pdfjs/images/" style="height: 100dvh"> - Adjust the static prefix to your deployment. Keep original filenames available - for resources loaded by directory and filename, such as annotation icons, - CMaps, fonts and WASM; configure their resource path attributes as needed. + Resources loaded by directory and filename, such as annotation icons, CMaps, + fonts, WASM and the scripting sandbox, default to the folders next to + viewer-src and pdfjs-lib-src. Keep their original filenames available there, + or set their resource path attributes. For a separate static host, configure module script CORS and the page's CSP. --> diff --git a/scripts/copy-worker.mjs b/scripts/copy-worker.mjs index ec3a9dc..7f3e717 100644 --- a/scripts/copy-worker.mjs +++ b/scripts/copy-worker.mjs @@ -9,8 +9,13 @@ const sourceViewerFile = resolve(rootDir, 'src', 'web', 'viewer.mjs') const sourceViewerCssFile = resolve(rootDir, 'src', 'web', 'viewer.css') const sourceViewerImagesDir = resolve(rootDir, 'src', 'web', 'images') const sourcePaperAndInkCssFile = resolve(rootDir, 'src', 'themes', 'paper-and-ink.css') +const sourceSandboxFile = resolve(rootDir, 'src', 'build', 'pdf.sandbox.min.mjs') const distDir = resolve(rootDir, 'dist') const targetFile = resolve(distDir, 'pdf.worker.min.mjs') +const targetSandboxFile = resolve(distDir, 'pdf.sandbox.mjs') +// Resources PDF.js loads only for documents that need them. The element defaults their URLs to +// these folders next to viewer.mjs. +const resourceDirs = ['cmaps', 'iccs', 'standard_fonts', 'wasm'] const targetPdfFile = resolve(distDir, 'pdf.mjs') const targetViewerFile = resolve(distDir, 'viewer.mjs') const targetViewerCssFile = resolve(distDir, 'viewer.css') @@ -61,5 +66,9 @@ await Promise.all([ minifyModuleFile(sourceViewerFile, targetViewerFile), bundleCssFile(sourceViewerCssFile, targetViewerCssFile), bundleCssFile(sourcePaperAndInkCssFile, targetPaperAndInkCssFile), - cp(sourceViewerImagesDir, targetViewerImagesDir, { recursive: true, force: true }) + cp(sourceViewerImagesDir, targetViewerImagesDir, { recursive: true, force: true }), + copyFile(sourceSandboxFile, targetSandboxFile), + ...resourceDirs.map((dirName) => + cp(resolve(rootDir, 'src', 'web', dirName), resolve(distDir, dirName), { recursive: true, force: true }) + ) ]) diff --git a/src/pdfjs-viewer-element.ts b/src/pdfjs-viewer-element.ts index d46fb58..e8c5f16 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -51,24 +51,23 @@ const DEFAULTS = { pagemode: 'none', locale: '', viewerCssTheme: 'AUTOMATIC', - cMapUrl: '../web/cmaps/', - iccUrl: '../web/iccs/', - imageResourcesPath: './images/', - sandboxBundleSrc: '../build/pdf.sandbox.mjs', - standardFontDataUrl: '../web/standard_fonts/', - wasmUrl: '../web/wasm/', localeSrcTemplate: 'https://cdn.jsdelivr.net/gh/mozilla-l10n/firefox-l10n@main/{locale}/toolkit/toolkit/pdfviewer/viewer.ftl' } as const -// PDF.js resource options set from attributes. PDF.js fetches the folders from its worker, which -// runs from a `blob:` URL where relative paths don't resolve, so attribute values are made absolute, -// and folder URLs get the trailing slash PDF.js requires. +// Resources PDF.js loads only for documents that need them. They ship in the package `dist` +// folder, and each defaults to `fileName` resolved against the runtime file named by `base`, so +// `assets-base` and the individual runtime URLs carry them along. Bundlers emit the runtime files +// but not these folders, so bundled apps set `assets-base` or the attributes. +// PDF.js fetches most of them from its worker, which runs from a `blob:` URL where relative paths +// don't resolve, so attribute values are made absolute, and folder URLs get the trailing slash +// PDF.js requires. const RESOURCE_OPTIONS = { - 'c-map-url': { key: 'cMapUrl', fallback: DEFAULTS.cMapUrl, folder: true }, - 'icc-url': { key: 'iccUrl', fallback: DEFAULTS.iccUrl, folder: true }, - 'sandbox-bundle-src': { key: 'sandboxBundleSrc', fallback: DEFAULTS.sandboxBundleSrc, folder: false }, - 'standard-font-data-url': { key: 'standardFontDataUrl', fallback: DEFAULTS.standardFontDataUrl, folder: true }, - 'wasm-url': { key: 'wasmUrl', fallback: DEFAULTS.wasmUrl, folder: true } + 'c-map-url': { key: 'cMapUrl', fileName: 'cmaps/', base: 'viewerSrc' }, + 'icc-url': { key: 'iccUrl', fileName: 'iccs/', base: 'viewerSrc' }, + 'image-resources-path': { key: 'imageResourcesPath', fileName: 'images/', base: 'viewerSrc' }, + 'sandbox-bundle-src': { key: 'sandboxBundleSrc', fileName: 'pdf.sandbox.mjs', base: 'pdfjsLibSrc' }, + 'standard-font-data-url': { key: 'standardFontDataUrl', fileName: 'standard_fonts/', base: 'viewerSrc' }, + 'wasm-url': { key: 'wasmUrl', fileName: 'wasm/', base: 'viewerSrc' } } as const type ResourceAttribute = keyof typeof RESOURCE_OPTIONS @@ -169,23 +168,17 @@ export class PdfjsViewerElement extends HTMLElement { } private getResourceOption(attribute: ResourceAttribute) { - const { fallback, folder } = RESOURCE_OPTIONS[attribute] + const { fileName, base } = RESOURCE_OPTIONS[attribute] const value = this.getAttribute(attribute)?.trim() - if (!value) return fallback - return this.getFullPath(folder && !value.endsWith('/') ? `${value}/` : value) - } - - private getImageResourcesPath() { - const imageResourcesPath = this.getAttribute('image-resources-path') - if (imageResourcesPath) return imageResourcesPath - - const assetsBase = this.getAssetsBase() - if (!assetsBase) return DEFAULTS.imageResourcesPath + if (value) { + return this.getFullPath(fileName.endsWith('/') && !value.endsWith('/') ? `${value}/` : value) + } try { - return new URL('images/', assetsBase).href + return new URL(fileName, this.getRuntimeFileUrls()[base]).href } catch { - return `${assetsBase}images/` + // A runtime URL that doesn't parse can't load the viewer either. + return fileName } } @@ -407,7 +400,6 @@ export class PdfjsViewerElement extends HTMLElement { for (const [attribute, { key }] of Object.entries(RESOURCE_OPTIONS)) { viewerOptions?.set(key, this.getResourceOption(attribute as ResourceAttribute)) } - viewerOptions?.set('imageResourcesPath', this.getImageResourcesPath()) viewerOptions?.set('defaultUrl', this.getFullPath(this.getAttribute('src') || DEFAULTS.src)) viewerOptions?.set('disablePreferences', true) viewerOptions?.set('eventBusDispatchToDOM', true) @@ -580,12 +572,6 @@ export class PdfjsViewerElement extends HTMLElement { return } - if (name === 'image-resources-path') { - const viewerOptions = this.iframe.contentWindow?.PDFViewerApplicationOptions - viewerOptions?.set('imageResourcesPath', newValue || this.getImageResourcesPath()) - return - } - switch (name) { case 'src': { const viewerApp = this.iframe.contentWindow?.PDFViewerApplication diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 80c6f7c..14e5c86 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -274,42 +274,43 @@ describe('Basic tests', async () => { ) const viewer = document.body.querySelector('pdfjs-viewer-element') as HTMLElement + const { viewerSrc, pdfjsLibSrc } = (viewer as PdfjsViewerElement)['getRuntimeFileUrls']() const cases = [ { attr: 'c-map-url', key: 'cMapUrl', value: 'https://example.com/cmaps/', - fallback: '../web/cmaps/' + fallback: new URL('cmaps/', viewerSrc).href }, { attr: 'icc-url', key: 'iccUrl', value: 'https://example.com/iccs/', - fallback: '../web/iccs/' + fallback: new URL('iccs/', viewerSrc).href }, { attr: 'image-resources-path', key: 'imageResourcesPath', value: 'https://example.com/images/', - fallback: './images/' + fallback: new URL('images/', viewerSrc).href }, { attr: 'sandbox-bundle-src', key: 'sandboxBundleSrc', value: 'https://example.com/pdf.sandbox.mjs', - fallback: '../build/pdf.sandbox.mjs' + fallback: new URL('pdf.sandbox.mjs', pdfjsLibSrc).href }, { attr: 'standard-font-data-url', key: 'standardFontDataUrl', value: 'https://example.com/standard_fonts/', - fallback: '../web/standard_fonts/' + fallback: new URL('standard_fonts/', viewerSrc).href }, { attr: 'wasm-url', key: 'wasmUrl', value: 'https://example.com/wasm/', - fallback: '../web/wasm/' + fallback: new URL('wasm/', viewerSrc).href } ] as const @@ -426,6 +427,7 @@ describe('Resource URL attributes', () => { ['standard-font-data-url', './fonts/', new URL('./fonts/', location.href).href], ['wasm-url', 'https://cdn.example.com/wasm', 'https://cdn.example.com/wasm/'], ['icc-url', ' /static/iccs/ ', new URL('/static/iccs/', location.href).href], + ['image-resources-path', '../images', new URL('../images/', location.href).href], ['sandbox-bundle-src', '/static/pdf.sandbox.mjs', new URL('/static/pdf.sandbox.mjs', location.href).href] ] as const @@ -437,6 +439,30 @@ describe('Resource URL attributes', () => { expect(viewer['getResourceOption'](attribute)).eq(fallback) } }) + + it('defaults to resources that ship next to the runtime files', async () => { + const viewer = new PdfjsViewerElement() + const files = { + 'c-map-url': 'Adobe-Japan1-UCS2.bcmap', + 'icc-url': 'CGATS001Compat-v2-micro.icc', + 'image-resources-path': 'annotation-comment.svg', + 'sandbox-bundle-src': '', + 'standard-font-data-url': 'FoxitFixed.pfb', + 'wasm-url': 'openjpeg.wasm' + } as const + + for (const [attribute, file] of Object.entries(files)) { + const url = viewer['getResourceOption'](attribute as keyof typeof files) + file + expect((await fetch(url, { method: 'HEAD' })).status, url).eq(200) + } + }) + + it('follows assets-base', () => { + const viewer = new PdfjsViewerElement() + viewer.setAttribute('assets-base', '/static/pdfjs') + expect(viewer['getResourceOption']('c-map-url')).eq(new URL('/static/pdfjs/cmaps/', location.href).href) + expect(viewer['getResourceOption']('sandbox-bundle-src')).eq(new URL('/static/pdfjs/pdf.sandbox.mjs', location.href).href) + }) }) describe('Runtime file URL overrides', () => { diff --git a/types/pdfjs-viewer-element.d.ts b/types/pdfjs-viewer-element.d.ts index 355f41f..5c3cc35 100644 --- a/types/pdfjs-viewer-element.d.ts +++ b/types/pdfjs-viewer-element.d.ts @@ -33,7 +33,6 @@ export declare class PdfjsViewerElement extends HTMLElement { private getAssetsBase; private getRuntimeFileUrls; private getResourceOption; - private getImageResourcesPath; private getCssThemeOption; private applyIframeHash; private applyViewerTheme; From 645023b90b15607494346dc4c7eb8c42767af6d7 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 25 Oct 2026 02:28:35 +0300 Subject: [PATCH 6/9] fix: apply a changed image-resources-path to the next document PDF.js copies imageResourcesPath into its page viewer when the viewer is created, so changing the attribute later only updated an option that nothing read again. The element now updates that copy too, so the next document uses the new path, like the other resource attributes. Co-Authored-By: Claude Opus 5.5 --- src/pdfjs-viewer-element.ts | 13 +++++++++++-- tests/basic.test.ts | 10 ++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/pdfjs-viewer-element.ts b/src/pdfjs-viewer-element.ts index e8c5f16..f6bf866 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -566,9 +566,18 @@ export class PdfjsViewerElement extends HTMLElement { } if (name in RESOURCE_OPTIONS) { - const viewerOptions = this.iframe.contentWindow?.PDFViewerApplicationOptions + const contentWindow = this.iframe.contentWindow const attribute = name as ResourceAttribute - viewerOptions?.set(RESOURCE_OPTIONS[attribute].key, this.getResourceOption(attribute)) + const value = this.getResourceOption(attribute) + contentWindow?.PDFViewerApplicationOptions?.set(RESOURCE_OPTIONS[attribute].key, value) + // PDF.js copies this option into its page viewer once, when the viewer is created. Update the + // copy too, so the next document uses the new path like the other resources do. + if (attribute === 'image-resources-path') { + const pdfViewer = (contentWindow?.PDFViewerApplication as unknown as { + pdfViewer?: { imageResourcesPath: string } + } | undefined)?.pdfViewer + if (pdfViewer) pdfViewer.imageResourcesPath = value + } return } diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 14e5c86..4caaa22 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -325,6 +325,16 @@ describe('Basic tests', async () => { } }) + it('should use a changed image-resources-path for the next document', async () => { + const viewerApp = await mountViewer(` + ` + ) as unknown as { pdfViewer: { imageResourcesPath: string } } + + const viewer = document.body.querySelector('pdfjs-viewer-element') as HTMLElement + viewer.setAttribute('image-resources-path', '/static/images') + expect(viewerApp.pdfViewer.imageResourcesPath).eq(new URL('/static/images/', location.href).href) + }) + it('should load individual runtime URLs without assets-base', async () => { await mountViewer(` Date: Sun, 25 Oct 2026 02:28:47 +0300 Subject: [PATCH 7/9] fix: keep setViewerOptions options across viewer rebuilds Options were applied once and then cleared, so a locale change that rebuilt the viewer dropped them. They are now kept and applied on every build, and later calls merge with earlier ones instead of replacing them. Co-Authored-By: Claude Opus 5.5 --- src/pdfjs-viewer-element.ts | 8 ++++---- tests/basic.test.ts | 14 ++++++++++++++ types/pdfjs-viewer-element.d.ts | 2 +- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/pdfjs-viewer-element.ts b/src/pdfjs-viewer-element.ts index f6bf866..172fcb9 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -107,7 +107,7 @@ export class PdfjsViewerElement extends HTMLElement { private localeResourceUrl?: string private localeResourceLink?: HTMLLinkElement private viewerStyles = new Set() - private optionsToSet: Record = {} + private customViewerOptions: Record = {} static get observedAttributes() { return[ @@ -405,10 +405,9 @@ export class PdfjsViewerElement extends HTMLElement { viewerOptions?.set('eventBusDispatchToDOM', true) viewerOptions?.set('localeProperties', { lang: this.getAttribute('locale') || DEFAULTS.locale }) viewerOptions?.set('viewerCssTheme', this.getCssThemeOption()) - for (const [key, value] of Object.entries(this.optionsToSet)) { + for (const [key, value] of Object.entries(this.customViewerOptions)) { viewerOptions?.set(key, value) } - this.optionsToSet = {} } // Unset parameters are left out: PDF.js reads an empty `page=` as page 1, so writing them would @@ -625,7 +624,8 @@ export class PdfjsViewerElement extends HTMLElement { } public async setViewerOptions(options: Record = {}) { - this.optionsToSet = options + // Kept and applied on every build: a locale change can rebuild the viewer. + this.customViewerOptions = { ...this.customViewerOptions, ...options } await this.initPromise return { viewerOptions: this.iframe.contentWindow?.PDFViewerApplicationOptions diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 4caaa22..030da11 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -335,6 +335,20 @@ describe('Basic tests', async () => { expect(viewerApp.pdfViewer.imageResourcesPath).eq(new URL('/static/images/', location.href).href) }) + it('should keep viewer options across a rebuild', async () => { + document.body.innerHTML = `` + const viewer = document.body.querySelector('pdfjs-viewer-element') as PdfjsViewerElement + viewer.setViewerOptions({ enableComment: true }) + await viewer.setViewerOptions({ defaultZoomValue: 'page-fit' }) + const options = () => getIframe().contentWindow.PDFViewerApplicationOptions.getAll() + expect(options()).toMatchObject({ enableComment: true, defaultZoomValue: 'page-fit' }) + + // A locale change that PDF.js can't apply in place rebuilds the viewer the same way. + viewer.initPromise = viewer['buildViewer']() + await viewer.initPromise + expect(options()).toMatchObject({ enableComment: true, defaultZoomValue: 'page-fit' }) + }) + it('should load individual runtime URLs without assets-base', async () => { await mountViewer(` Date: Sun, 25 Oct 2026 02:28:59 +0300 Subject: [PATCH 8/9] fix: show toolbar icons in forced colors mode viewer.css switches its colors to system colors in forced colors mode, and the browser keeps author system colors while replacing fixed ones. Paper & Ink loads after viewer.css with fixed colors, so its values won, the browser replaced them, and every toolbar icon (a mask filled with a background color) disappeared. The theme now sits in a not (forced-colors: active) block and steps aside in that mode, so PDF.js's own high-contrast styles apply. Co-Authored-By: Claude Opus 5.5 --- ACCESSIBILITY.md | 3 +++ src/themes/paper-and-ink.css | 11 ++++++++++- tests/basic.test.ts | 6 +++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md index 1c2a62c..53863da 100644 --- a/ACCESSIBILITY.md +++ b/ACCESSIBILITY.md @@ -33,6 +33,9 @@ The `iframe-title` attribute can be updated at runtime. application's language. - If custom CSS is added with `injectViewerStyles`, preserve visible focus indicators, sufficient color contrast, and readable text. +- In forced colors mode, such as Windows High Contrast, the Paper & Ink theme + steps aside and PDF.js's own high-contrast styles apply. A custom theme loaded + with `theme-css-src` should do the same or support forced colors itself. ## PDF Document Accessibility diff --git a/src/themes/paper-and-ink.css b/src/themes/paper-and-ink.css index d3fbfbf..6e4fb8d 100644 --- a/src/themes/paper-and-ink.css +++ b/src/themes/paper-and-ink.css @@ -1,3 +1,10 @@ +/* Forced colors (Windows High Contrast and similar): the theme steps aside, and PDF.js's own + high-contrast styles apply. viewer.css switches its colors to system colors in this mode, which + the browser keeps. The fixed colors below would win over them, and the browser replaces fixed + colors, so toolbar icons (masks filled with a background color) would disappear. + The rules inside this block are left unindented. */ +@media not all and (forced-colors: active) { + :root { /* Paper & Ink: warm neutrals with clear, low-glare controls. */ --sidebar-width:228px; @@ -984,4 +991,6 @@ input[type="number"] { --undo-button-bg-color-active: #333b47; --undo-button-border: 1px solid #3d444d; --undo-button-fg-color: #e6edf3; -} \ No newline at end of file +} + +} diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 030da11..fc99a2c 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -62,7 +62,11 @@ describe('Basic tests', async () => { 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'))! - const darkRules = Array.from(themeSheet.cssRules).filter( + // The whole theme sits in one block that steps aside in forced colors mode. + expect(themeSheet.cssRules).toHaveLength(1) + const themeBlock = themeSheet.cssRules[0] as CSSMediaRule + expect(themeBlock.conditionText).eq('not all and (forced-colors: active)') + const darkRules = Array.from(themeBlock.cssRules).filter( (rule): rule is CSSMediaRule => (rule as CSSMediaRule).conditionText === '(prefers-color-scheme: dark)' ) // One system dark block per palette; the neutral palette hides the warm one in the colors below. From 6b0397ef5d4f3d4bdc358f8d4d0672994d822940 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Sun, 25 Oct 2026 02:47:10 +0300 Subject: [PATCH 9/9] fix: allow cross-origin sandbox, WASM and annotation icon URLs in the viewer CSP The viewer document's CSP listed only the runtime file origins, so a sandbox-bundle-src or wasm-url on another origin was blocked by script-src (the sandbox is a dynamic import, and so is the QuickJS module it loads from the WASM folder), and annotation icons from an image-resources-path on another origin were blocked by img-src. Those origins are now added, read once per build like the runtime files. Co-Authored-By: Claude Fable 5.1 --- src/pdfjs-viewer-element.ts | 17 ++++++++++++++--- tests/basic.test.ts | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/pdfjs-viewer-element.ts b/src/pdfjs-viewer-element.ts index 172fcb9..76ad34d 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -452,19 +452,30 @@ export class PdfjsViewerElement extends HTMLElement { const { pdfjsLibSrc, viewerSrc, viewerCssSrc, themeCssSrc, workerSrc } = runtimeFileUrls const workerSrcOrigin = resolveHttpOrigin(workerSrc) + // Resource attributes are read here once per build like the runtime files: a later change + // to another origin is not added to the CSP. const scriptSources = toSourceList([ "'self'", window.location.origin, resolveHttpOrigin(pdfjsLibSrc), resolveHttpOrigin(viewerSrc), - workerSrcOrigin + workerSrcOrigin, + // The scripting sandbox, and the QuickJS module it imports from the WASM folder. + resolveHttpOrigin(this.getResourceOption('sandbox-bundle-src')), + resolveHttpOrigin(this.getResourceOption('wasm-url')) ]) - const styleSources = toSourceList([ + const styleSourceList = [ "'self'", window.location.origin, resolveHttpOrigin(viewerCssSrc), resolveHttpOrigin(themeCssSrc) + ] + const styleSources = toSourceList(styleSourceList) + // Annotation icons are elements in the annotation layer. + const imageSources = toSourceList([ + ...styleSourceList, + resolveHttpOrigin(this.getResourceOption('image-resources-path')) ]) const workerSources = toSourceList([ @@ -480,7 +491,7 @@ export class PdfjsViewerElement extends HTMLElement { `script-src-elem ${scriptSources}`, `worker-src ${workerSources}`, `style-src ${styleSources} 'unsafe-inline'`, - `img-src ${styleSources} blob: data:`, + `img-src ${imageSources} blob: data:`, 'media-src blob:', `font-src ${styleSources} data:`, 'connect-src * blob: data:', diff --git a/tests/basic.test.ts b/tests/basic.test.ts index fc99a2c..06c2fe4 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -396,6 +396,26 @@ describe('Basic tests', async () => { expect(csp).toMatch(/style-src[^;]*https:\/\/theme-css\.invalid/) }) + it('should include cross-origin sandbox, WASM and annotation icon URLs in CSP', async () => { + document.body.innerHTML = ` + + ` + const viewer = document.body.querySelector('pdfjs-viewer-element') as PdfjsViewerElement + await viewer.initPromise + + const cspMeta = getIframe().contentDocument?.querySelector('meta[http-equiv="Content-Security-Policy"]') + const csp = cspMeta?.getAttribute('content') || '' + for (const directive of ['script-src', 'script-src-elem']) { + expect(csp).toMatch(new RegExp(`${directive} [^;]*https://sandbox\\.invalid`)) + expect(csp).toMatch(new RegExp(`${directive} [^;]*https://wasm\\.invalid`)) + } + expect(csp).toMatch(/img-src[^;]*https:\/\/images\.invalid/) + expect(csp).not.toMatch(/style-src[^;]*https:\/\/images\.invalid/) + }) + it('should keep the runtime URLs read at connection for the whole build', async () => { document.body.innerHTML = `` const viewer = document.body.querySelector('pdfjs-viewer-element') as PdfjsViewerElement