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/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 16399bb..76ad34d 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -51,16 +51,27 @@ const DEFAULTS = { pagemode: 'none', locale: '', viewerCssTheme: 'AUTOMATIC', - debuggerSrc: './debugger.mjs', - 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 +// 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', 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 + export const ViewerCssTheme = { AUTOMATIC: 0, LIGHT: 1, DARK: 2 } as const export type PdfViewerApplicationOpenParameters = @@ -96,12 +107,12 @@ 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[ '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' ] @@ -156,17 +167,18 @@ export class PdfjsViewerElement extends HTMLElement { } } - private getImageResourcesPath() { - const imageResourcesPath = this.getAttribute('image-resources-path') - if (imageResourcesPath) return imageResourcesPath - - const assetsBase = this.getAssetsBase() - if (!assetsBase) return DEFAULTS.imageResourcesPath + private getResourceOption(attribute: ResourceAttribute) { + const { fileName, base } = RESOURCE_OPTIONS[attribute] + const value = this.getAttribute(attribute)?.trim() + 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 } } @@ -177,11 +189,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) => { @@ -324,17 +336,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' @@ -381,34 +397,34 @@ 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) - 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) + for (const [attribute, { key }] of Object.entries(RESOURCE_OPTIONS)) { + viewerOptions?.set(key, this.getResourceOption(attribute as ResourceAttribute)) + } viewerOptions?.set('defaultUrl', this.getFullPath(this.getAttribute('src') || DEFAULTS.src)) viewerOptions?.set('disablePreferences', true) 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 = {} } - 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) => { @@ -436,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([ @@ -464,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:', @@ -504,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() @@ -542,31 +569,25 @@ 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) { - const viewerOptions = this.iframe.contentWindow?.PDFViewerApplicationOptions - const { key, fallback } = optionByAttribute[name as keyof typeof optionByAttribute] - viewerOptions?.set(key, newValue || fallback) - return - } - - if (name === 'image-resources-path') { - const viewerOptions = this.iframe.contentWindow?.PDFViewerApplicationOptions - viewerOptions?.set('imageResourcesPath', newValue || this.getImageResourcesPath()) + if (name in RESOURCE_OPTIONS) { + const contentWindow = this.iframe.contentWindow + const attribute = name as ResourceAttribute + 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 } @@ -614,7 +635,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/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 6eaa048..06c2fe4 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 () => { @@ -58,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. @@ -137,6 +145,38 @@ 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(` + ` + ) + + 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(` ` @@ -238,48 +278,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: 'debugger-src', - key: 'debuggerSrc', - value: 'https://example.com/debugger.mjs', - fallback: './debugger.mjs' - }, { 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 @@ -294,6 +329,30 @@ 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 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(` { 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 @@ -388,6 +467,52 @@ 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], + ['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 + + 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) + } + }) + + 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', () => { 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..f7fb598 100644 --- a/types/pdfjs-viewer-element.d.ts +++ b/types/pdfjs-viewer-element.d.ts @@ -26,13 +26,13 @@ export declare class PdfjsViewerElement extends HTMLElement { private localeResourceUrl?; private localeResourceLink?; private viewerStyles; - private optionsToSet; + private customViewerOptions; static get observedAttributes(): string[]; private formatTemplate; private getFullPath; private getAssetsBase; private getRuntimeFileUrls; - private getImageResourcesPath; + private getResourceOption; private getCssThemeOption; private applyIframeHash; private applyViewerTheme;