From 7bb1108779b0dc12e4f948e9e069088c7729e88f Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Fri, 30 Oct 2026 15:49:24 +0200 Subject: [PATCH] refactor: replace localeResourceUrl with localeMap for improved locale handling --- src/pdfjs-viewer-element.ts | 48 ++++++++++++++++++++++++--------- tests/basic.test.ts | 27 ++++++++++++++++++- tests/utils.ts | 24 +++++++++++++++++ types/pdfjs-viewer-element.d.ts | 3 ++- 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/pdfjs-viewer-element.ts b/src/pdfjs-viewer-element.ts index 76ad34d..c62b496 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -104,7 +104,7 @@ export class PdfjsViewerElement extends HTMLElement { public iframe!: PdfjsViewerElementIframe public initPromise: Promise = Promise.resolve({}) - private localeResourceUrl?: string + private localeMap?: Record private localeResourceLink?: HTMLLinkElement private viewerStyles = new Set() private customViewerOptions: Record = {} @@ -344,22 +344,18 @@ export class PdfjsViewerElement extends HTMLElement { this.cleanupLocaleResource() return } - // Made absolute because PDF.js resolves it against the `blob:` URL of the resource link below. + // Made absolute because PDF.js resolves it against the `data:` 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 = { - [lang]: localeUrl - } const localeLink = doc.createElement('link') localeLink.rel = 'resource' localeLink.type = 'application/l10n' this.cleanupLocaleResource() - this.localeResourceUrl = URL.createObjectURL( - new Blob([JSON.stringify(localeObject)], { type: 'application/json' }) - ) - localeLink.href = this.localeResourceUrl + this.localeMap = { [lang]: localeUrl } + // PDF.js gets the map from `serveLocaleFiles` and never requests this URL. + localeLink.href = `data:application/json,${encodeURIComponent(JSON.stringify(this.localeMap))}` this.iframe.contentDocument?.head.appendChild(localeLink) this.localeResourceLink = localeLink } @@ -369,9 +365,35 @@ export class PdfjsViewerElement extends HTMLElement { this.localeResourceLink.remove() this.localeResourceLink = undefined } - if (this.localeResourceUrl) { - URL.revokeObjectURL(this.localeResourceUrl) - this.localeResourceUrl = undefined + this.localeMap = undefined + } + + // The viewer takes `fetchData` from `pdfjsLib` when it loads and uses it only for its locale: + // the map named by the `application/l10n` link, then the `.ftl` file the map lists. The map is + // answered from memory, so a page CSP doesn't have to allow `blob:` or `data:` in `connect-src`. + // When the `.ftl` file fails to load, PDF.js leaves every control without a label. An empty file + // is returned instead, and PDF.js falls back to its built-in English strings. + private serveLocaleFiles() { + const pdfjsLib = (this.iframe.contentWindow as unknown as { + pdfjsLib?: { fetchData?: (url: string | URL, type?: string) => Promise } + })?.pdfjsLib + const fetchData = pdfjsLib?.fetchData + if (!pdfjsLib || typeof fetchData !== 'function') return + + pdfjsLib.fetchData = async (url, type) => { + const href = String(url) + if (this.localeResourceLink && href === this.localeResourceLink.href) { + return { ...this.localeMap } + } + if (!this.localeMap || !Object.values(this.localeMap).includes(href)) { + return fetchData(url, type) + } + try { + return await fetchData(url, type) + } catch (error) { + console.warn(`Unable to load locale file: ${href}. The viewer falls back to English.`, error) + return '' + } } } @@ -539,6 +561,8 @@ export class PdfjsViewerElement extends HTMLElement { await this.injectLocaleData() await this.injectScript(runtimeFileUrls.pdfjsLibSrc) + // Between the two scripts: the viewer reads `fetchData` from `pdfjsLib` once, when it loads. + this.serveLocaleFiles() await this.injectScript(runtimeFileUrls.viewerSrc) return await setupPromise diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 06c2fe4..e2bd7d3 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { getFileData, getViewerElement, mountViewer, getIframe } from './utils' +import { getFileData, getViewerElement, mountViewer, mountViewerWithPageCsp, getIframe } from './utils' import { PdfjsViewerElement } from '../src/pdfjs-viewer-element' import packageJson from '../package.json' @@ -177,6 +177,31 @@ describe('Basic tests', async () => { await vi.waitFor(() => expect(doc.querySelector('#previous')?.getAttribute('title')).eq('Página anterior')) }) + it('should load the locale under a page CSP that allows neither blob: nor data:', async () => { + const viewer = await mountViewerWithPageCsp("connect-src 'self'", ` + + ` + ) + + const doc = viewer.iframe.contentDocument! + await vi.waitFor(() => expect(doc.querySelector('#previous')?.getAttribute('title')).eq('Eine Seite zurück')) + }) + + it('should fall back to English when the locale file fails to load', async () => { + // The page CSP blocks the locale file's host, so the request fails without reaching the network. + const viewer = await mountViewerWithPageCsp("connect-src 'self'", ` + + ` + ) + + const doc = viewer.iframe.contentDocument! + await vi.waitFor(() => expect(doc.querySelector('#previous')?.getAttribute('title')).eq('Previous Page')) + }) + it('should apply worker-src option at runtime', async () => { await mountViewer(` ` diff --git a/tests/utils.ts b/tests/utils.ts index debb8ad..81af9bc 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -22,6 +22,30 @@ export const mountViewer = async (template: string) => { return viewerApp } +// Mounts the viewer in a frame with its own page CSP, which the viewer's srcdoc iframe inherits as +// it would a host page's. The frame is written with `document.write`, which gives it this page's +// URL; under `about:srcdoc` the dev build's runtime file URLs fail to resolve. Vitest routes the +// element's dynamic imports through `__vitest_browser_runner__`, which only the test page defines, +// so the frame gets the pass-through stub Vitest gives workers. +export const mountViewerWithPageCsp = async (csp: string, template: string) => { + document.body.innerHTML = '' + const frame = document.createElement('iframe') + document.body.append(frame) + const doc = frame.contentDocument! + doc.open() + doc.write(` + + + + ${template}`) + doc.close() + + await frame.contentWindow!.customElements.whenDefined('pdfjs-viewer-element') + const viewer = doc.querySelector('pdfjs-viewer-element') as TestViewerElement + await viewer.initPromise + return viewer +} + export const getFileData = () => { const baseStr = 'JVBERi0xLjcKCjEgMCBvYmogICUgZW50cnkgcG9pbnQKPDwKICAvVHlwZSAvQ2F0YWxvZwog' + diff --git a/types/pdfjs-viewer-element.d.ts b/types/pdfjs-viewer-element.d.ts index f7fb598..f6b603a 100644 --- a/types/pdfjs-viewer-element.d.ts +++ b/types/pdfjs-viewer-element.d.ts @@ -23,7 +23,7 @@ export declare class PdfjsViewerElement extends HTMLElement { constructor(); iframe: PdfjsViewerElementIframe; initPromise: Promise; - private localeResourceUrl?; + private localeMap?; private localeResourceLink?; private viewerStyles; private customViewerOptions; @@ -42,6 +42,7 @@ export declare class PdfjsViewerElement extends HTMLElement { private applyLocaleAtRuntime; private injectLocaleData; private cleanupLocaleResource; + private serveLocaleFiles; private onViewerAppCreated; private applyViewerOptions; private getIframeLocationHash;