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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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, its worker and the data some PDFs need (CMaps, fonts, image decoders, the scripting sandbox) ship in the package, and nothing loads from a CDN by default. Vite and webpack 5 bundle the viewer with no configuration, but they don't copy the data folders: if your documents need them, serve the package's `dist` folder and point [`assets-base`](https://missing-elements.github.io/pdfjs-viewer/#hosting) at it.
- **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, 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.

Expand All @@ -26,6 +26,12 @@ npm install @missing-elements/pdfjs-viewer
import '@missing-elements/pdfjs-viewer'
```

Vite, webpack 5 and Next.js bundle the viewer with no configuration. They don't copy the PDF.js data folders, though, because PDF.js loads those files by name. Without them, scanned pages compressed with JBIG2, CCITT or JPEG 2000 render blank or black, CJK text in fonts the PDF doesn't embed goes missing, and JavaScript in PDFs doesn't run. To support every PDF, copy the package's `dist` folder into your public files, for example in a `postinstall` script, and point [`assets-base`](https://missing-elements.github.io/pdfjs-viewer/#hosting) at it:

```html
<pdfjs-viewer-element src="/file.pdf" assets-base="/pdfjs-viewer/"></pdfjs-viewer-element>
```

Or load it from a CDN without a build step:

```html
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"types": "./types/pdfjs-viewer-element.d.ts",
"default": "./dist/pdfjs-viewer-element.js"
},
"./dist/*": "./dist/*"
"./dist/*": "./dist/*",
"./package.json": "./package.json"
},
"repository": {
"type": "git",
Expand Down
21 changes: 21 additions & 0 deletions scripts/bundle-css.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { build } from 'esbuild'

// Bundles a stylesheet so that its url(...) references are inlined as data URLs. Consumer
// bundlers emit dist files under hashed, relocated names, and viewer.css alone would otherwise
// break: it references images/ relative to its own URL. esbuild picks base64 or percent
// encoding per file, whichever is shorter.
export const bundleCss = async (sourcePath) => {
const result = await build({
entryPoints: [sourcePath],
bundle: true,
minify: true,
legalComments: 'none',
write: false,
loader: {
'.svg': 'dataurl',
'.gif': 'dataurl'
}
})

return result.outputFiles[0].text
}
21 changes: 5 additions & 16 deletions scripts/copy-worker.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { build, transform } from 'esbuild'
import { transform } from 'esbuild'
import { bundleCss } from './bundle-css.mjs'

const rootDir = resolve(import.meta.dirname, '..')
const sourceFile = resolve(rootDir, 'src', 'build', 'pdf.worker.min.mjs')
Expand Down Expand Up @@ -42,22 +43,10 @@ const minifyModuleFile = async (sourcePath, targetPath) => {
await writeFile(targetPath, result.code, 'utf8')
}

// Bundles a stylesheet so that its url(...) references are inlined as data URLs. Consumer
// bundlers emit dist files under hashed, relocated names, and viewer.css alone would otherwise
// break: it references images/ relative to its own URL. esbuild picks base64 or percent
// encoding per file, whichever is shorter.
// The element applies the same text from JS modules (see vite.config.ts); these files are for
// `assets-base`, `viewer-css-src` and `theme-css-src`.
const bundleCssFile = async (sourcePath, targetPath) => {
await build({
entryPoints: [sourcePath],
outfile: targetPath,
bundle: true,
minify: true,
legalComments: 'none',
loader: {
'.svg': 'dataurl',
'.gif': 'dataurl'
}
})
await writeFile(targetPath, await bundleCss(sourcePath), 'utf8')
}

await Promise.all([
Expand Down
127 changes: 84 additions & 43 deletions src/pdfjs-viewer-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,17 @@ const DEFAULT_VIEWER_SRC = import.meta.env.DEV
? new URL('./web/viewer.mjs', import.meta.url).href
: new URL(/* @vite-ignore */ './viewer.mjs', import.meta.url).href

const DEFAULT_VIEWER_CSS_SRC = import.meta.env.DEV
? new URL('./web/viewer.css', import.meta.url).href
: new URL(/* @vite-ignore */ './viewer.css', import.meta.url).href

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

// 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.
// package `dist` folder (used when `assets-base` is set) and the bundled default. The stylesheets
// have no default URL: bundlers treat a referenced `.css` file as the app's own CSS (Next.js with
// webpack moves it into the host page and emits an empty file), so without the attribute or
// `assets-base` the element applies the same CSS from JS modules instead (`buildViewerEntry`).
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 }
viewerCssSrc: { attribute: 'viewer-css-src', fileName: 'viewer.css', defaultUrl: '' },
themeCssSrc: { attribute: 'theme-css-src', fileName: 'paper-and-ink.css', defaultUrl: '' }
} as const

type RuntimeFileUrls = Record<keyof typeof RUNTIME_FILES, string>
Expand Down Expand Up @@ -92,21 +87,35 @@ export interface PdfViewerApplicationOptions {
getAll: () => Record<string, unknown>
}

const HOST_CSS = ':host{width:100%;display:block;overflow:hidden}:host iframe{height:100%}'

// A constructed stylesheet, which a page CSP without `'unsafe-inline'` for styles doesn't block
// the way it blocks a `<style>` element. Older jsdom versions, such as the one Jest 29 uses, can't
// construct stylesheets, so importing the element there keeps the `<style>` element.
const HOST_STYLES = 'replaceSync' in CSSStyleSheet.prototype ? new CSSStyleSheet() : undefined
HOST_STYLES?.replaceSync(HOST_CSS)

export class PdfjsViewerElement extends HTMLElement {
constructor() {
super()
const shadowRoot = this.attachShadow({ mode: 'open' })
shadowRoot.innerHTML = `
<style>:host{width:100%;display:block;overflow:hidden}:host iframe{height:100%}</style>
<iframe frameborder="0" width="100%" loading="lazy"></iframe>
`
const iframe = '<iframe frameborder="0" width="100%" loading="lazy"></iframe>'
if (HOST_STYLES) {
shadowRoot.adoptedStyleSheets = [HOST_STYLES]
shadowRoot.innerHTML = iframe
} else {
shadowRoot.innerHTML = `<style>${HOST_CSS}</style>${iframe}`
}
}

public iframe!: PdfjsViewerElementIframe
public initPromise: Promise<InitializationData> = Promise.resolve({})
private localeMap?: Record<string, string>
private localeResourceLink?: HTMLLinkElement
private viewerStyles = new Set<string>()
// The viewer document whose stylesheets are in place, and the `viewerStyles` applied to it.
private styledDocument?: Document
private appliedViewerStyles = new Set<string>()
private customViewerOptions: Record<string, PdfViewerApplicationOptionValue> = {}

static get observedAttributes() {
Expand Down Expand Up @@ -149,7 +158,9 @@ export class PdfjsViewerElement extends HTMLElement {
const { attribute, fileName, defaultUrl } = RUNTIME_FILES[field]
const attr = this.getAttribute(attribute)?.trim()
if (attr) return this.getFullPath(attr)
if (!assetsBase) return defaultUrl
// Next.js (webpack and Turbopack) rewrites the defaults to root-relative URLs. The worker and
// the resource folders derived from these need absolute ones.
if (!assetsBase) return this.getFullPath(defaultUrl)

try {
return new URL(fileName, assetsBase).href
Expand Down Expand Up @@ -235,24 +246,30 @@ export class PdfjsViewerElement extends HTMLElement {
}
}

private appendRuntimeStyle(styles: string) {
const doc = this.iframe?.contentDocument
if (!doc?.head || !styles) return

const exists = Array.from(doc.querySelectorAll('style'))
.some((styleNode) => styleNode.textContent === styles)
if (exists) return

const styleElement = doc.createElement('style')
styleElement.setAttribute('data-pdfjs-viewer-runtime-style', 'true')
styleElement.textContent = styles
doc.head.appendChild(styleElement)
// Constructed stylesheets, unlike `<style>` elements, are not blocked by a page CSP without
// `'unsafe-inline'` for styles, which the srcdoc iframe inherits. They follow every `<link>`.
private adoptStyleSheets(doc: Document, styles: string[]) {
const CSSStyleSheetConstructor = doc.defaultView?.CSSStyleSheet
// Older jsdom versions, such as the one Jest 29 uses, can't construct stylesheets. The viewer
// doesn't run there anyway.
if (!CSSStyleSheetConstructor || !('replaceSync' in CSSStyleSheetConstructor.prototype) || !styles.length) return

const sheets = styles.map((text) => {
const sheet = new CSSStyleSheetConstructor()
sheet.replaceSync(text)
return sheet
})
doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, ...sheets]
}

private applyQueuedRuntimeStyles() {
this.viewerStyles.forEach((styles) => {
this.appendRuntimeStyle(styles)
})
// Styles from `injectViewerStyles` come last, after the viewer and theme stylesheets.
private applyViewerStyles() {
const doc = this.iframe?.contentDocument
if (!doc || doc !== this.styledDocument) return

const pending = Array.from(this.viewerStyles).filter((styles) => !this.appliedViewerStyles.has(styles))
pending.forEach((styles) => this.appliedViewerStyles.add(styles))
this.adoptStyleSheets(doc, pending)
}

private injectScript(src: string, type = 'module') {
Expand Down Expand Up @@ -450,8 +467,14 @@ export class PdfjsViewerElement extends HTMLElement {
}

private buildViewerEntry = async (runtimeFileUrls: RuntimeFileUrls) => {
return new Promise<void>(async (resolve) => {
const viewerEntry = await import('virtual:pdfjs-viewer-html')
const { pdfjsLibSrc, viewerSrc, viewerCssSrc, themeCssSrc, workerSrc } = runtimeFileUrls
const [viewerEntry, viewerCss, themeCss] = await Promise.all([
import('virtual:pdfjs-viewer-html'),
viewerCssSrc ? undefined : import('virtual:pdfjs-viewer-css'),
themeCssSrc ? undefined : import('virtual:pdfjs-viewer-theme-css')
])

return new Promise<void>((resolve) => {
const resolveHttpOrigin = (url: string) => {
try {
const parsed = new URL(url, window.location.href)
Expand All @@ -471,7 +494,6 @@ export class PdfjsViewerElement extends HTMLElement {
const escapeAttribute = (value: string) =>
value.replace(/&/g, '&amp;').replace(/"/g, '&quot;')

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
Expand Down Expand Up @@ -526,13 +548,33 @@ export class PdfjsViewerElement extends HTMLElement {
`<meta http-equiv="Content-Security-Policy" content="${srcdocCsp};" />`
)

const stylesheetLinks = [viewerCssSrc, themeCssSrc]
.filter(Boolean)
.map((href) => `<link rel="stylesheet" href="${escapeAttribute(href)}">`)
.join('')
const completeHtml = viewerHtmlWithSrcdocCsp
.replace('</head>', `
<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 })
.replace('</head>', `${stylesheetLinks}</head>`)
// Constructed stylesheets are applied once the document has loaded, so it starts hidden
// rather than painting the viewer unstyled.
.replace(/<body(?=[\s>])/, '<body hidden')

this.iframe.addEventListener('load', () => {
const doc = this.iframe.contentDocument
if (doc) {
// A theme from `theme-css-src` is a `<link>`, which precedes every constructed
// stylesheet. The bundled viewer stylesheet then goes into a cascade layer, so the
// theme still overrides it.
const bundledViewerCss = viewerCss && themeCssSrc
? `@layer pdfjs-viewer{${viewerCss.default}}`
: viewerCss?.default
this.styledDocument = doc
this.appliedViewerStyles = new Set()
this.adoptStyleSheets(doc, [bundledViewerCss, themeCss?.default].filter((styles) => styles !== undefined))
this.applyViewerStyles()
doc.body.hidden = false
}
resolve()
}, { once: true })
this.iframe.srcdoc = completeHtml
})
}
Expand All @@ -545,7 +587,6 @@ export class PdfjsViewerElement extends HTMLElement {
await viewerApp?.initializedPromise

this.applyViewerTheme()
this.applyQueuedRuntimeStyles()

return {
viewerApp
Expand Down Expand Up @@ -655,7 +696,7 @@ export class PdfjsViewerElement extends HTMLElement {
public async injectViewerStyles(styles: string) {
if (!styles) return
this.viewerStyles.add(styles)
this.appendRuntimeStyle(styles)
this.applyViewerStyles()
}

public async setViewerOptions(options: Record<string, PdfViewerApplicationOptionValue> = {}) {
Expand Down
10 changes: 10 additions & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,13 @@ declare module 'virtual:pdfjs-viewer-html' {
const content: string
export default content
}

declare module 'virtual:pdfjs-viewer-css' {
const content: string
export default content
}

declare module 'virtual:pdfjs-viewer-theme-css' {
const content: string
export default content
}
Loading
Loading