Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 36 additions & 12 deletions src/pdfjs-viewer-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export class PdfjsViewerElement extends HTMLElement {

public iframe!: PdfjsViewerElementIframe
public initPromise: Promise<InitializationData> = Promise.resolve({})
private localeResourceUrl?: string
private localeMap?: Record<string, string>
private localeResourceLink?: HTMLLinkElement
private viewerStyles = new Set<string>()
private customViewerOptions: Record<string, PdfViewerApplicationOptionValue> = {}
Expand Down Expand Up @@ -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
}
Expand All @@ -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<unknown> }
})?.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 ''
}
}
}

Expand Down Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion tests/basic.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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'", `
<pdfjs-viewer-element
locale="de"
locale-src-template="${pdfjsDist}/web/locale/{locale}/viewer.ftl">
</pdfjs-viewer-element>`
)

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'", `
<pdfjs-viewer-element
locale="de"
locale-src-template="https://locales.invalid/{locale}/viewer.ftl">
</pdfjs-viewer-element>`
)

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(`
<pdfjs-viewer-element src="/sample-pdf-10MB.pdf"></pdfjs-viewer-element>`
Expand Down
24 changes: 24 additions & 0 deletions tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
<meta http-equiv="Content-Security-Policy" content="${csp}">
<script>globalThis.__vitest_browser_runner__ = { wrapDynamicImport: (f) => f() }</script>
<script type="module" src="/src/pdfjs-viewer-element.ts"></script>
${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' +
Expand Down
3 changes: 2 additions & 1 deletion types/pdfjs-viewer-element.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export declare class PdfjsViewerElement extends HTMLElement {
constructor();
iframe: PdfjsViewerElementIframe;
initPromise: Promise<InitializationData>;
private localeResourceUrl?;
private localeMap?;
private localeResourceLink?;
private viewerStyles;
private customViewerOptions;
Expand All @@ -42,6 +42,7 @@ export declare class PdfjsViewerElement extends HTMLElement {
private applyLocaleAtRuntime;
private injectLocaleData;
private cleanupLocaleResource;
private serveLocaleFiles;
private onViewerAppCreated;
private applyViewerOptions;
private getIframeLocationHash;
Expand Down
Loading