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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ Bundlers that do not analyse `new URL(..., import.meta.url)`, esbuild among them
- The value is a directory URL, relative to the page or absolute. A trailing slash is added when missing.
- Individual runtime URL attributes take precedence over `assets-base` for their respective files, including `worker-src`.
- The attribute is read when the viewer is (re)initialized, so set it before the element is attached to the document.
- All runtime files must come from the same package release as the element. The viewer HTML is built into the element, and mixing PDF.js versions breaks the viewer.

PDF.js annotation icons are a separate case: the viewer loads them at runtime as `imageResourcesPath + 'annotation-' + name + '.svg'`, a dynamic name no bundler can emit. If `assets-base` is set, `imageResourcesPath` defaults to `assets-base + 'images/'`. Otherwise serve the package `dist/images` folder and set `image-resources-path` explicitly.

Expand Down
69 changes: 33 additions & 36 deletions src/pdfjs-viewer-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,17 @@ 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

// File names inside the package `dist` folder, used when `assets-base` is set.
const DIST_FILE_NAMES = {
workerSrc: 'pdf.worker.min.mjs',
pdfjsLibSrc: 'pdf.mjs',
viewerSrc: 'viewer.mjs',
viewerCssSrc: 'viewer.css',
themeCssSrc: 'paper-and-ink.css'
// 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.
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 }
} as const

type RuntimeFileUrls = Record<keyof typeof DIST_FILE_NAMES, string>

const DEFAULT_RUNTIME_FILE_URLS: RuntimeFileUrls = {
workerSrc: DEFAULT_BUILT_IN_WORKER_SRC,
pdfjsLibSrc: DEFAULT_PDF_SRC,
viewerSrc: DEFAULT_VIEWER_SRC,
viewerCssSrc: DEFAULT_VIEWER_CSS_SRC,
themeCssSrc: DEFAULT_PAPER_AND_INK_THEME_CSS_SRC
}
type RuntimeFileUrls = Record<keyof typeof RUNTIME_FILES, string>

const DEFAULTS = {
src: '',
Expand Down Expand Up @@ -141,12 +134,12 @@ export class PdfjsViewerElement extends HTMLElement {
private getRuntimeFileUrls(): RuntimeFileUrls {
const assetsBase = this.getAssetsBase()

const resolveFile = (field: keyof typeof DIST_FILE_NAMES) => {
const attr = this.getAttribute(field.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`))?.trim()
const resolveFile = (field: keyof typeof RUNTIME_FILES) => {
const { attribute, fileName, defaultUrl } = RUNTIME_FILES[field]
const attr = this.getAttribute(attribute)?.trim()
if (attr) return this.getFullPath(attr)
if (!assetsBase) return DEFAULT_RUNTIME_FILE_URLS[field]
if (!assetsBase) return defaultUrl

const fileName = DIST_FILE_NAMES[field]
try {
return new URL(fileName, assetsBase).href
} catch {
Expand Down Expand Up @@ -414,7 +407,7 @@ export class PdfjsViewerElement extends HTMLElement {
return '#' + Object.entries(params).map(([k, v]) => `${k}=${v}`).join('&')
}

private buildViewerEntry = async () => {
private buildViewerEntry = async (runtimeFileUrls: RuntimeFileUrls) => {
return new Promise<void>(async (resolve) => {
const viewerEntry = await import('virtual:pdfjs-viewer-html')
const resolveHttpOrigin = (url: string) => {
Expand All @@ -432,7 +425,11 @@ export class PdfjsViewerElement extends HTMLElement {
const toSourceList = (sources: string[]) =>
Array.from(new Set(sources.filter(Boolean))).join(' ')

const { pdfjsLibSrc, viewerSrc, viewerCssSrc, themeCssSrc, workerSrc } = this.getRuntimeFileUrls()
// Attribute values that are not valid URLs reach the markup as written, so escape them.
const escapeAttribute = (value: string) =>
value.replace(/&/g, '&amp;').replace(/"/g, '&quot;')

const { pdfjsLibSrc, viewerSrc, viewerCssSrc, themeCssSrc, workerSrc } = runtimeFileUrls
const workerSrcOrigin = resolveHttpOrigin(workerSrc)

const scriptSources = toSourceList([
Expand Down Expand Up @@ -478,8 +475,8 @@ export class PdfjsViewerElement extends HTMLElement {

const completeHtml = viewerHtmlWithSrcdocCsp
.replace('</head>', `
<link rel="stylesheet" href="${viewerCssSrc}">
<link rel="stylesheet" href="${themeCssSrc}">
<link rel="stylesheet" href="${escapeAttribute(viewerCssSrc)}">
<link rel="stylesheet" href="${escapeAttribute(themeCssSrc)}">
${Array.from(this.viewerStyles).map(style => `<style>${style}</style>`).join('\n')}
</head>`)
this.iframe.addEventListener('load', () => resolve(), { once: true })
Expand All @@ -500,28 +497,31 @@ export class PdfjsViewerElement extends HTMLElement {
}
}

private buildViewerApp = async () => {
private buildViewerApp = async (runtimeFileUrls: RuntimeFileUrls) => {
await this.applyIframeHash()

// Set viewer options as soon as PDFViewerApplication is created.
const setupPromise = this.setupViewerApp()

await this.injectLocaleData()

const { pdfjsLibSrc, viewerSrc } = this.getRuntimeFileUrls()
await this.injectScript(pdfjsLibSrc)
await this.injectScript(viewerSrc)
await this.injectScript(runtimeFileUrls.pdfjsLibSrc)
await this.injectScript(runtimeFileUrls.viewerSrc)

return await setupPromise
}

// Runtime file URLs are read once per build, so the srcdoc CSP always allows the files it loads.
private buildViewer = async () => {
const runtimeFileUrls = this.getRuntimeFileUrls()
await this.buildViewerEntry(runtimeFileUrls)
return await this.buildViewerApp(runtimeFileUrls)
}

async connectedCallback() {
this.iframe = this.shadowRoot?.querySelector('iframe') as PdfjsViewerElementIframe
this.iframe.setAttribute('title', this.getAttribute('iframe-title') || DEFAULTS.iframeTitle)
this.initPromise = (async () => {
await this.buildViewerEntry()
return await this.buildViewerApp()
})()
this.initPromise = this.buildViewer()
}

disconnectedCallback() {
Expand Down Expand Up @@ -584,10 +584,7 @@ export class PdfjsViewerElement extends HTMLElement {
await this.injectLocaleData()
const didApplyRuntimeLocale = await this.applyLocaleAtRuntime()
if (!didApplyRuntimeLocale) {
this.initPromise = (async () => {
await this.buildViewerEntry()
return await this.buildViewerApp()
})()
this.initPromise = this.buildViewer()
await this.initPromise
}
await this.applyIframeHash()
Expand Down
45 changes: 45 additions & 0 deletions tests/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,51 @@ describe('Basic tests', async () => {
expect(getViewerElement()).exist
})

it('should include individual runtime URL origins in CSP', async () => {
// Hosts under `.invalid` never resolve: the CSP is written before the scripts fail to load.
document.body.innerHTML = `
<pdfjs-viewer-element
pdfjs-lib-src="https://pdfjs-lib.invalid/pdf.mjs"
viewer-src="https://viewer.invalid/viewer.mjs"
viewer-css-src="https://viewer-css.invalid/viewer.css"
theme-css-src="https://theme-css.invalid/paper-and-ink.css">
</pdfjs-viewer-element>`
const viewer = document.body.querySelector('pdfjs-viewer-element') as PdfjsViewerElement
await viewer.initPromise.catch(() => {})

const cspMeta = getIframe().contentDocument?.querySelector('meta[http-equiv="Content-Security-Policy"]')
const csp = cspMeta?.getAttribute('content') || ''
expect(csp).toMatch(/script-src-elem[^;]*https:\/\/pdfjs-lib\.invalid/)
expect(csp).toMatch(/script-src-elem[^;]*https:\/\/viewer\.invalid/)
expect(csp).toMatch(/style-src[^;]*https:\/\/viewer-css\.invalid/)
expect(csp).toMatch(/style-src[^;]*https:\/\/theme-css\.invalid/)
})

it('should keep the runtime URLs read at connection for the whole build', async () => {
document.body.innerHTML = `<pdfjs-viewer-element></pdfjs-viewer-element>`
const viewer = document.body.querySelector('pdfjs-viewer-element') as PdfjsViewerElement
viewer.setAttribute('pdfjs-lib-src', 'https://pdfjs-lib.invalid/pdf.mjs')
await viewer.initPromise

expect(getIframe().contentDocument!.querySelector('script[src^="https://pdfjs-lib.invalid"]')).not.exist
expect(getViewerElement()).exist
})

it('should not inject markup from an invalid stylesheet URL', async () => {
// The theme stylesheet is optional, so the viewer still initializes without it.
const invalidUrl = 'http://[invalid"><link rel="stylesheet" href="/injected.css">'
await mountViewer(`
<pdfjs-viewer-element theme-css-src='${invalidUrl}'></pdfjs-viewer-element>`
)

const doc = getIframe().contentDocument!
const stylesheets = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'))
.map((link) => link.getAttribute('href'))
expect(stylesheets).toContain(invalidUrl)
expect(stylesheets).not.toContain('/injected.css')
expect(getViewerElement()).exist
})

it('should open a new document when src changes at runtime', async () => {
const viewerApp = await mountViewer(`
<pdfjs-viewer-element src="/sample-pdf-10MB.pdf"></pdfjs-viewer-element>`
Expand Down
1 change: 1 addition & 0 deletions types/pdfjs-viewer-element.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export declare class PdfjsViewerElement extends HTMLElement {
private buildViewerEntry;
private setupViewerApp;
private buildViewerApp;
private buildViewer;
connectedCallback(): Promise<void>;
disconnectedCallback(): void;
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): Promise<void>;
Expand Down
Loading