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
3 changes: 3 additions & 0 deletions ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 4 additions & 5 deletions demo/asset-attributes.html
Original file line number Diff line number Diff line change
Expand Up @@ -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">
</pdfjs-viewer-element>

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.
-->
<pdfjs-viewer-element
Expand All @@ -48,7 +48,6 @@
worker-src="../dist/pdf.worker.min.mjs"
viewer-css-src="../dist/viewer.css"
theme-css-src="../dist/paper-and-ink.css"
image-resources-path="../dist/images/"
style="height: 100dvh">
</pdfjs-viewer-element>
</body>
Expand Down
11 changes: 10 additions & 1 deletion scripts/copy-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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 })
)
])
146 changes: 84 additions & 62 deletions src/pdfjs-viewer-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -96,12 +107,12 @@ export class PdfjsViewerElement extends HTMLElement {
private localeResourceUrl?: string
private localeResourceLink?: HTMLLinkElement
private viewerStyles = new Set<string>()
private optionsToSet: Record<string, PdfViewerApplicationOptionValue> = {}
private customViewerOptions: Record<string, PdfViewerApplicationOptionValue> = {}

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'
]
Expand Down Expand Up @@ -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
}
}

Expand All @@ -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<void>((resolve) => {
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, string> = {
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) => {
Expand Down Expand Up @@ -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 <img> elements in the annotation layer.
const imageSources = toSourceList([
...styleSourceList,
resolveHttpOrigin(this.getResourceOption('image-resources-path'))
])

const workerSources = toSourceList([
Expand All @@ -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:',
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -614,7 +635,8 @@ export class PdfjsViewerElement extends HTMLElement {
}

public async setViewerOptions(options: Record<string, PdfViewerApplicationOptionValue> = {}) {
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
Expand Down
11 changes: 10 additions & 1 deletion src/themes/paper-and-ink.css
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -984,4 +991,6 @@ input[type="number"] {
--undo-button-bg-color-active: #333b47;
--undo-button-border: 1px solid #3d444d;
--undo-button-fg-color: #e6edf3;
}
}

}
Loading
Loading