diff --git a/README.md b/README.md index 5adb34a..47f3c3a 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 + +``` + Or load it from a CDN without a build step: ```html diff --git a/package.json b/package.json index 704c5ff..c1671da 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/bundle-css.mjs b/scripts/bundle-css.mjs new file mode 100644 index 0000000..85d3a49 --- /dev/null +++ b/scripts/bundle-css.mjs @@ -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 +} diff --git a/scripts/copy-worker.mjs b/scripts/copy-worker.mjs index 7f3e717..e200985 100644 --- a/scripts/copy-worker.mjs +++ b/scripts/copy-worker.mjs @@ -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') @@ -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([ diff --git a/src/pdfjs-viewer-element.ts b/src/pdfjs-viewer-element.ts index c62b496..5793c58 100644 --- a/src/pdfjs-viewer-element.ts +++ b/src/pdfjs-viewer-element.ts @@ -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 @@ -92,14 +87,25 @@ export interface PdfViewerApplicationOptions { getAll: () => Record } +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 ` - - ` + const iframe = '' + if (HOST_STYLES) { + shadowRoot.adoptedStyleSheets = [HOST_STYLES] + shadowRoot.innerHTML = iframe + } else { + shadowRoot.innerHTML = `${iframe}` + } } public iframe!: PdfjsViewerElementIframe @@ -107,6 +113,9 @@ export class PdfjsViewerElement extends HTMLElement { private localeMap?: Record private localeResourceLink?: HTMLLinkElement private viewerStyles = new Set() + // The viewer document whose stylesheets are in place, and the `viewerStyles` applied to it. + private styledDocument?: Document + private appliedViewerStyles = new Set() private customViewerOptions: Record = {} static get observedAttributes() { @@ -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 @@ -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 ``).join('\n')} - `) - this.iframe.addEventListener('load', () => resolve(), { once: true }) + .replace('', `${stylesheetLinks}`) + // Constructed stylesheets are applied once the document has loaded, so it starts hidden + // rather than painting the viewer unstyled. + .replace(/])/, ' { + const doc = this.iframe.contentDocument + if (doc) { + // A theme from `theme-css-src` is a ``, 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 }) } @@ -545,7 +587,6 @@ export class PdfjsViewerElement extends HTMLElement { await viewerApp?.initializedPromise this.applyViewerTheme() - this.applyQueuedRuntimeStyles() return { viewerApp @@ -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 = {}) { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 8ee0598..5743bce 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -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 +} diff --git a/tests/basic.test.ts b/tests/basic.test.ts index e2bd7d3..11754ab 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -61,8 +61,10 @@ describe('Basic tests', async () => { await mountViewer('') const viewer = document.body.querySelector('pdfjs-viewer-element')! const doc = getIframe().contentDocument! - const themeSheet = Array.from(doc.styleSheets).find(sheet => sheet.href?.endsWith('/paper-and-ink.css'))! // The whole theme sits in one block that steps aside in forced colors mode. + const themeSheet = doc.adoptedStyleSheets.find(sheet => + (sheet.cssRules[0] as CSSMediaRule | undefined)?.conditionText === 'not all and (forced-colors: active)' + )! expect(themeSheet.cssRules).toHaveLength(1) const themeBlock = themeSheet.cssRules[0] as CSSMediaRule expect(themeBlock.conditionText).eq('not all and (forced-colors: active)') @@ -71,13 +73,14 @@ describe('Basic tests', async () => { ) // One system dark block per palette; the neutral palette hides the warm one in the colors below. expect(darkRules).toHaveLength(2) - // `--hover-filter` comes from viewer.css, which switches it on the system scheme alone. + // `--hover-filter` comes from viewer.css, which switches it on the system scheme alone. The + // bundled stylesheets are minified, and custom properties keep the text as written. const colors = () => [ getComputedStyle(doc.documentElement).getPropertyValue('--body-bg-color').trim(), getComputedStyle(doc.querySelector('#viewsManager')!).getPropertyValue('--text-color').trim(), getComputedStyle(doc.documentElement).getPropertyValue('--hover-filter').trim() ] - const light = ['#f1f3f5', '#1f2328', 'brightness(0.9)'] + const light = ['#f1f3f5', '#1f2328', 'brightness(.9)'] const dark = ['#0d1117', '#e6edf3', 'brightness(1.4)'] for (const systemDark of [false, true, false]) { // Toggle matching of the system dark rules without depending on the host's theme. @@ -114,6 +117,34 @@ describe('Basic tests', async () => { expect(getComputedStyle(getViewerElement('#downloadButton')!).display).eq('none') }) + it('should apply the bundled stylesheets without referencing CSS files', async () => { + await mountViewer('') + + const doc = getIframe().contentDocument! + expect(doc.querySelector('link[rel="stylesheet"]')).not.exist + expect(doc.adoptedStyleSheets.length).toBeGreaterThanOrEqual(2) + expect(doc.body.hidden).toBe(false) + expect(getComputedStyle(getViewerElement('#viewerContainer')!).position).eq('absolute') + }) + + it('should apply injected styles after the theme', async () => { + document.body.innerHTML = '' + const viewer = document.body.querySelector('pdfjs-viewer-element') as PdfjsViewerElement + await viewer.injectViewerStyles(':root, :root[data-color-scheme] { --body-bg-color: rgb(4, 5, 6) }') + await viewer.initPromise + + const doc = getIframe().contentDocument! + expect(getComputedStyle(doc.documentElement).getPropertyValue('--body-bg-color').trim()).eq('rgb(4, 5, 6)') + }) + + it('should let a theme from theme-css-src override the bundled viewer stylesheet', async () => { + await mountViewer('') + + const doc = getIframe().contentDocument! + expect(getComputedStyle(doc.documentElement).getPropertyValue('--body-bg-color').trim()).eq('rgb(1, 2, 3)') + expect(getComputedStyle(getViewerElement('#viewerContainer')!).position).eq('absolute') + }) + it('should add a title attribute to the iframe', async () => { const viewerApp = await mountViewer(` { await vi.waitFor(() => expect(doc.querySelector('#previous')?.getAttribute('title')).eq('Previous Page')) }) + it('should keep the element and the viewer styled under a page CSP that blocks inline styles', async () => { + const viewer = await mountViewerWithPageCsp("style-src 'self'", ` + ` + ) as unknown as PdfjsViewerElement + await viewer.injectViewerStyles('#outerContainer { letter-spacing: 3px }') + + const hostWindow = viewer.ownerDocument.defaultView! + const viewerWindow = viewer.iframe.contentWindow + const doc = viewer.iframe.contentDocument! + expect(hostWindow.getComputedStyle(viewer).display).eq('block') + expect(viewerWindow.getComputedStyle(doc.querySelector('#viewerContainer')!).position).eq('absolute') + expect(viewerWindow.getComputedStyle(doc.querySelector('#outerContainer')!).letterSpacing).eq('3px') + }) + it('should apply worker-src option at runtime', async () => { await mountViewer(` ` @@ -530,6 +575,36 @@ describe('Resource URL attributes', () => { } }) + it('makes the defaults absolute when a bundler rewrites them to relative URLs', async () => { + // Next.js returns `new URL('./file', import.meta.url).href` as a root-relative path. A fresh copy + // of the module is evaluated with a URL class that does the same for the module's runtime files. + const NativeURL = URL + class RelativeURL extends NativeURL { + override get href() { + return /^\/src\/(build|web|themes)\//.test(this.pathname) ? this.pathname : super.href + } + } + globalThis.URL = RelativeURL + let RelativeElement: typeof PdfjsViewerElement + try { + ({ PdfjsViewerElement: RelativeElement } = await import(/* @vite-ignore */ '/src/pdfjs-viewer-element.ts?relative-urls')) + } finally { + globalThis.URL = NativeURL + } + if (!customElements.get('pdfjs-viewer-relative-urls')) { + customElements.define('pdfjs-viewer-relative-urls', RelativeElement) + } + + document.body.innerHTML = '' + const viewer = document.body.querySelector('pdfjs-viewer-relative-urls') as PdfjsViewerElement + await viewer.initPromise + + const options = viewer.iframe.contentWindow.PDFViewerApplicationOptions.getAll() + expect(options.workerSrc).eq(new URL('/src/build/pdf.worker.mjs', location.href).href) + expect(options.cMapUrl).eq(new URL('/src/web/cmaps/', location.href).href) + expect(options.sandboxBundleSrc).eq(new URL('/src/build/pdf.sandbox.mjs', location.href).href) + }) + it('follows assets-base', () => { const viewer = new PdfjsViewerElement() viewer.setAttribute('assets-base', '/static/pdfjs') diff --git a/tests/fixtures/custom-theme.css b/tests/fixtures/custom-theme.css new file mode 100644 index 0000000..b9070d5 --- /dev/null +++ b/tests/fixtures/custom-theme.css @@ -0,0 +1,4 @@ +/* A theme for `theme-css-src`. viewer.css sets the same variable with the same specificity. */ +:root { + --body-bg-color: rgb(1, 2, 3); +} diff --git a/types/pdfjs-viewer-element.d.ts b/types/pdfjs-viewer-element.d.ts index f6b603a..8fad0c0 100644 --- a/types/pdfjs-viewer-element.d.ts +++ b/types/pdfjs-viewer-element.d.ts @@ -26,6 +26,8 @@ export declare class PdfjsViewerElement extends HTMLElement { private localeMap?; private localeResourceLink?; private viewerStyles; + private styledDocument?; + private appliedViewerStyles; private customViewerOptions; static get observedAttributes(): string[]; private formatTemplate; @@ -36,8 +38,8 @@ export declare class PdfjsViewerElement extends HTMLElement { private getCssThemeOption; private applyIframeHash; private applyViewerTheme; - private appendRuntimeStyle; - private applyQueuedRuntimeStyles; + private adoptStyleSheets; + private applyViewerStyles; private injectScript; private applyLocaleAtRuntime; private injectLocaleData; diff --git a/vite.config.ts b/vite.config.ts index 15965f4..f0418a7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,8 +5,32 @@ import { defineConfig } from 'vitest/config' import terser from '@rollup/plugin-terser' import { playwright } from '@vitest/browser-playwright' import { minify as minifyHtml } from 'html-minifier-terser' +import { bundleCss } from './scripts/bundle-css.mjs' const VIEWER_HTML_VIRTUAL_ID = 'virtual:pdfjs-viewer-html' + +// The element's default stylesheets, as JS modules that export the bundled CSS text. Consumer +// bundlers process any `.css` file the element references as the app's own CSS. +const STYLESHEET_VIRTUAL_IDS: Record = { + 'virtual:pdfjs-viewer-css': 'src/web/viewer.css', + 'virtual:pdfjs-viewer-theme-css': 'src/themes/paper-and-ink.css' +} + +const stylesheetModules = () => ({ + name: 'stylesheet-modules', + resolveId(id: string) { + return id in STYLESHEET_VIRTUAL_IDS ? `\0${id}` : null + }, + async load(id: string) { + const sourcePath = id.startsWith('\0') ? STYLESHEET_VIRTUAL_IDS[id.slice(1)] : undefined + if (!sourcePath) { + return null + } + + const css = await bundleCss(resolve(process.cwd(), sourcePath)) + return `export default ${JSON.stringify(css)};` + } +}) const RESOLVED_VIEWER_HTML_VIRTUAL_ID = '\0virtual:pdfjs-viewer-html' const rawViewerHtmlMinifier = () => ({ @@ -42,7 +66,7 @@ const rawViewerHtmlMinifier = () => ({ // https://vitejs.dev/config/ export default defineConfig({ - plugins: [rawViewerHtmlMinifier()], + plugins: [rawViewerHtmlMinifier(), stylesheetModules()], build: { minify: 'terser', rollupOptions: {