vivliostyle-pdf
v0.3.1
Published
Browser-only PDF export via vivliostyle pagination + custom DOM-to-PDF emitter
Downloads
2,123
Maintainers
Readme
vivliostyle-pdf
Live demo: https://fiduswriter.github.io/vivliostyle-pdf/ — click "Generate PDF"; everything happens client-side, no print dialog.
Technical prototype: browser-only PDF export without the print dialog.
Use as a library
npm install vivliostyle-pdf @pdfme/pdf-libimport {PDFDocument} from "@pdfme/pdf-lib"
import {
emitPdfFromVivliostyleWindow,
printHTML
} from "vivliostyle-pdf"
printHTML(html, {
removeIframe: false,
printCallback: iframeWindow => {
void (async () => {
const bytes = await emitPdfFromVivliostyleWindow(
iframeWindow,
message => console.log(message),
{
sourceHtml: html,
metadata: {title: "My document"},
// Optional: claim PDF/A-4 and/or PDF/UA-2 conformance.
// pdfOptions: {pdfA: "4", pdfUa: 2}
}
)
// e.g. download or upload the bytes
})()
}
})vivliostyle paginates an HTML/CSS Paged Media document inside a hidden iframe. A custom DOM→PDF emitter then walks the paginated output and re-renders it as a real vector PDF with @pdfme/pdf-lib (an API-compatible, actively maintained fork of pdf-lib). No server, no print dialog.
Architecture
HTML + CSS Paged Media
│
▼ printHTML() from @vivliostyle/print
vivliostyle pagination (hidden iframe, one container div per page,
including running headers, page counters, footnotes, TOC page refs)
│
▼ printCallback fires when pagination completes
DOM→PDF emitter (src/pdf-emitter.ts)
• measures each page container (px → pt, 1 px = 0.75 pt, Y flipped)
• paints background-color rects, then solid borders, then images
(PNG/JPEG embedded directly, SVG rasterized via canvas at 2x)
• positions every word individually from Range.getClientRects(), so
browser line breaking/justification is preserved exactly
• draws strike lines for text-decoration: line-through, and synthesizes
small caps (Chromium applies font-variant-caps only at render time;
the DOM keeps lowercase text) fitted to the measured word width
• synthesizes list markers (vivliostyle renders ::marker internally,
invisible to a DOM walk)
• attaches link annotations, hand-built as PDF dictionaries via the
low-level object API (doc.context.obj/register + page.node.addAnnot):
external links get URI actions, internal links GoTo destinations to
the page+top of their target element (two-pass, since a target may
be emitted after the link; vivliostyle rewrites internal hrefs to
"#viv-id-<encoded doc URL>:0023id" — the emitter strips that prefix)
• sets document metadata (Title/Author/Subject/Keywords/Creator/
Producer/Language, from the source document's <title>/<meta> tags),
builds a nested PDF outline (bookmarks) from the h1–h6 headings with
XYZ destinations, sets viewer preferences (PageMode /UseOutlines,
DisplayDocTitle), and embeds the pre-pagination source HTML as a
file attachment
• resolves fonts the way the browser does: every `@font-face` rule in
the paginated document (inlined by the Fidus print exporter, with
`documentstylefile_set` asset URLs already absolute) is discovered,
fetched, normalized to embeddable sfnt bytes (WOFF unwrapped in pure JS;
WOFF2 decoded via fonteditor-core's WASM build of Google's woff2) and
embedded subsetted via @pdfme/pdf-lib
+ foliojs fontkit — then CSS font matching (family → style → weight
band, vivliostyle `Fnt_n` aliases stripped) picks the right cut per text
run. Identical fonts are embedded once (semantic dedup). Libertinus
Serif / JetBrains Mono TTFs in public/fonts/ serve only as the last-resort
fallback and as the demo's active font. Characters missing from a run's
font are re-drawn from another embedded font that covers them.
│
▼
Uint8Array → Blob → download as demo.pdfImplementation notes:
@vivliostyle/printdoes not awaitprintCallback(verified in the installed dist bundle). The app therefore passesremoveIframe: false, runs the async emitter inside the callback, and removes the iframe itself afterwards.- vivliostyle's viewer only displays the first page on screen (the rest
would be revealed by print CSS, which never runs). The emitter restores
display: blockon hidden page containers before measuring. - The paginated document is loaded from a
blob:URL and vivliostyle resolves resource URLs against it (ignoring<base>), so the demo document uses a__BASE__placeholder thatsrc/main.tsexpands to the app's absolute deployment root. - Baselines are approximated as
rect.bottom − descent(fontSize)using fontkit metrics — see Limitations.
Demo document coverage
The demo document (src/demo-document.html) deliberately exercises:
- running page headers (
string-set+@top-center) and page-number footers (@bottom-center+counter(page)/counter(pages)), - footnotes (
float: footnote,::footnote-call,::footnote-marker), - a table of contents with
target-counter(attr(href url), page)and leader dots, - cross references in the body text ("Table 2 (page N)", "Section 2
(page N)", "Figure 1 (page N)") — forward and backward, pointing at a
heading, a table, and figures — using the same
target-countermechanism as the TOC, - external hyperlinks (vivliostyle.org, pdf-lib.js.org) — in the generated PDF these are clickable Link annotations (URI actions), as are the TOC entries and cross references (internal GoTo jumps),
- three tables (simple, styled with header background/borders, and one spanning a page break) and three figures (two SVG, one PNG),
- inline styles: bold, italic, bold-italic, strikethrough
(
text-decoration: line-through, drawn as a vector line by the emitter), small caps (synthesized by the emitter), and monospace code spans plus a<pre>block set in JetBrains Mono, - headings with CSS-counter numbering, nested lists, a blockquote, inline
code and a dark
<pre>block, and a bibliography, - a "Typography, Direction & Decoration" section exercising the added
features: custom
@font-facefamilies (DejaVu Sans, Noto Sans Arabic/Hebrew — discovered by the emitter, no hardcoded registry), right-to-left text (Arabic and Hebrew, including Arabic mixed with a Western number), text-decoration breadth (dashed/dotted/double/wavy underlines, overline, dotted strikethrough), border-style breadth (dashed/dotted/double boxes) and rounded chips/badges, and deep outline levels (h4–h6 bookmarks).
The e2e test (test/e2e.spec.ts) verifies these features in the generated
PDF by extracting per-page text and annotations with pdfjs-dist
(@pdfme/pdf-lib, like pdf-lib, cannot read them back): it checks the
running header and page-number footer on every page, resolved TOC page
numbers, cross-reference numbers against the actual pages of their
targets, footnote bodies on the same page as their calls, external Link
annotations for both URLs, GoTo annotations whose resolved destinations
match the targets' actual pages, small-caps/strikethrough/code text
presence, metadata fields, the outline tree (nesting + resolved
destinations), the embedded HTML attachment, the PageMode/Lang/
DisplayDocTitle catalog entries, and zero console errors.
Develop
pnpm install
pnpm run gen:assets # regenerate public/images/figure-2.png
pnpm run dev # vite dev server at /vivliostyle-pdf/
pnpm run build # tsc + vite build → dist/
pnpm run preview # serve dist/ at /vivliostyle-pdf/Test
pnpm exec playwright install chromium # once
pnpm run test:e2eThe e2e test builds the app, serves dist/, clicks "Generate PDF" in
chromium, captures the download and asserts: %PDF- magic, size > 20 KB,
page count > 5 (parsed with @pdfme/pdf-lib), and no console errors — plus
the feature-level text/annotation assertions described above.
Deployment (GitHub Pages)
.github/workflows/pages.yml builds and deploys dist/ to GitHub Pages on
every push to main. The vite base is /vivliostyle-pdf/, so asset URLs
work both on Pages and locally. Requires the repo's Pages source to be set
to "GitHub Actions".
Beyond the print dialog
Because the emitter writes the PDF itself, it can add structures that
window.print() never produces:
- Document metadata: Title, Author, Subject and Keywords come from
the source document's
<title>and<meta name="author|description| keywords">tags (parsed from the raw HTML — vivliostyle's iframe does not retain the source<head>); Creator, Producer, the document language (/Lang en-US) and creation/modification dates are set too. - Outline (bookmarks): every h1–h6 heading becomes a bookmark,
nested by heading level, with an XYZ destination to the page and
vertical position of the heading. The outline tree is built with the
low-level object API (all item refs are registered first, then
Title/Parent/Dest/Prev/Next/First/Last/Count are wired up), and the
catalog's
PageModeis set to/UseOutlinesso viewers open the bookmarks sidebar automatically. - Viewer preferences:
DisplayDocTitlemakes viewers show the document title (rather than the file name) in the window title bar. - Source attachment (demo only): the demo embeds the pre-pagination HTML
source as a file attachment (
demo.html, matching the downloaded PDF's name), so the demo PDF is self-contained and its source can be extracted with any PDF tool (pdfdetach, Acrobat's attachments panel, etc.).EmitOptions.embedSourceHtmlcontrols this: off by default,truefor a defaultdocument.htmlname, or a string to pick the attachment filename.
Limitations
- Approximate baselines: text is placed at
rect.bottom − descent(fontSize)using fontkit metrics, which can be off by a fraction of a point versus the browser's true line boxes. This is intentionally kept rather than a shared per-line baseline, because each run's measured rect already reflects its own line box and descent (forcing one baseline would regress sized runs). - Fallback fonts: text maps to the document's own
@font-facefonts (any family/weight/style; weight ranges and italic/oblique matched per CSS Fonts 4, vivliostyleFnt_naliases stripped). When nothing matches — or a configured font can't be embedded — text falls back to Libertinus Serif (Regular/Bold/Italic/BoldItalic) or JetBrains Mono (Regular/Bold). WOFF is unwrapped to sfnt in pure JS and embedded; WOFF2 is decoded to sfnt via fonteditor-core's WASM decoder (see below). If a fallback font file is missing/unfetchable it is skipped with a warning rather than failing the export, so apps that do not serve them still export fine when the document's own@font-facefonts cover the text. To add a fallback family, drop the TTFs intopublic/fonts/and extendFALLBACK_FONT_FILESinsrc/pdf-emitter.ts. - Per-glyph fallback: characters missing from a run's chosen font (e.g.
math symbols absent from a monospace document font) are split off into
separate segments drawn with an embedded font that covers them: the other
families of the run's
font-familylist first, then the bundled fallbacks, then any embedded font. Characters no available font covers render as.notdefboxes, like today.
WOFF2 support and the decoder WASM
WOFF2 fonts are decoded to sfnt using
fonteditor-core's WASM build
of Google's woff2 reference decoder, so
WOFF2 @font-face fonts are embedded with their real glyphs. The decoder is
loaded lazily (only when a WOFF2 font is encountered) and gracefully —
if the WASM cannot be loaded, WOFF2 fonts fall back to the bundled fallback
fonts with a warning instead of failing the export.
The decoder WASM (woff2.wasm, ~710 KiB) is served like any other static
asset; emitPdfFromVivliostyleWindow() locates it in this order:
EmitOptions.woff2WasmUrl— a URL string, or the raw wasm bytes as anArrayBuffer. This is what applications should pass when they serve the wasm themselves (e.g. Fidus Writer serves it from its static files).<EmitOptions.baseUrl>woff2/woff2.wasm— thebaseUrloption (defaults to Vite'sBASE_URLin the demo build, or the consuming page's base URL). The demo ships the wasm atpublic/woff2/woff2.wasm, so it resolves automatically on GitHub Pages and in the local demo.- In Node (e.g. tests),
fonteditor-coreresolves its own packagedwoff2.wasmfrom inside the package, so no configuration is needed.
Consumers that bundle vivliostyle-pdf with a non-Vite toolchain (rspack,
webpack, tsc) should either pass woff2WasmUrl/baseUrl or copy the wasm
(available in the published package under public/woff2/woff2.wasm) to a
served location.
- Small caps are synthesized: lowercase is drawn as uppercase at 80% size,
fitted to the measured width; real
smcpglyph substitution isn't available through pdf-lib/@pdfme/pdf-lib. - Partial painting: backgrounds/borders are painted in document order — a simplification of the CSS stacking model. Solid, dashed, dotted and double borders and solid background colors are drawn, with rounded corners on background fills; box-shadow, text-shadow, gradients, outlines, and groove/ridge/inset/outset borders are not.
- SVGs are rasterized (2× canvas), not kept vector.
- Browser-inserted hyphenation hyphens are not in the DOM, so a line broken at an auto-hyphen is drawn without the hyphen glyph.
- Marker image sizing:
list-style-imagemarkers are embedded at their natural size; if a browser scales them (e.g.::markerwith a sized image) the PDF uses the intrinsic size instead. - PDF/UA-2 tagging and PDF/A-4 conformance are optional: pass
pdfOptions: {pdfUa: 2}and/orpdfOptions: {pdfA: "4"}(or"4f"when files are embedded). They are not applied by default.
Next steps
sup/sub(and mixed-size runs) keep the per-run descent heuristic; a line-dominant shared baseline is intentionally not applied because it would regress sized runs (see FEATURES.md §9).- Border-radius on bordered outlines (backgrounds are rounded already); groove/ridge/inset/outset border styles; box-shadow/text-shadow/outline.
- Math: native MathML is converted to SVG through MathJax and drawn as vector
ops (see test/math.spec.ts). Producers can still use pre-rendered SVG
<img>s if they prefer (see test/math-svg.spec.ts). - Per-codepoint glyph fallback (Latin/CJK inside a script font whose cut lacks
them), TrueType collections (.ttc),
unicode-range,size-adjust/descent-override descriptors, OpenType feature control. - Keep SVGs vector (@pdfme/pdf-lib
page.drawSvg()drops<marker>arrowheads and sizes<text>with fallback metrics today) and follow the CSS paint-order spec for overlapping content. - Reuse the emitter for Fidus Writer's client-side PDF export.
Licenses
- Code: LGPL-3.0 (see
LICENSE). Note that the runtime dependencies carry their own licenses — vivliostyle is AGPL-3.0; @pdfme/pdf-lib is MIT (like the pdf-lib it forks); fontkit is MIT; pdfjs-dist (dev/test only) is Apache-2.0. - Libertinus Serif + JetBrains Mono: SIL Open Font License 1.1
(
public/fonts/OFL.txt; JetBrains Mono attribution inpublic/fonts/NOTICE-JetBrainsMono.txt). - Noto Sans Arabic + Noto Sans Hebrew (demo/test RTL coverage): SIL Open Font
License 1.1 (also covered by
public/fonts/OFL.txt; attribution inpublic/fonts/NOTICE-Noto.txt). - DejaVu Sans (demo/test custom-font coverage): Bitstream Vera / Arev Fonts
license (permissive, NOT OFL) — see
public/fonts/NOTICE-DejaVu.txt.
