npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vivliostyle-pdf

v0.3.1

Published

Browser-only PDF export via vivliostyle pagination + custom DOM-to-PDF emitter

Downloads

2,123

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-lib
import {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.pdf

Implementation notes:

  • @vivliostyle/print does not await printCallback (verified in the installed dist bundle). The app therefore passes removeIframe: 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: block on 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 that src/main.ts expands 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-counter mechanism 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-face families (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:e2e

The 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 PageMode is set to /UseOutlines so viewers open the bookmarks sidebar automatically.
  • Viewer preferences: DisplayDocTitle makes 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.embedSourceHtml controls this: off by default, true for a default document.html name, 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-face fonts (any family/weight/style; weight ranges and italic/oblique matched per CSS Fonts 4, vivliostyle Fnt_n aliases 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-face fonts cover the text. To add a fallback family, drop the TTFs into public/fonts/ and extend FALLBACK_FONT_FILES in src/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-family list first, then the bundled fallbacks, then any embedded font. Characters no available font covers render as .notdef boxes, 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:

  1. EmitOptions.woff2WasmUrl — a URL string, or the raw wasm bytes as an ArrayBuffer. This is what applications should pass when they serve the wasm themselves (e.g. Fidus Writer serves it from its static files).
  2. <EmitOptions.baseUrl>woff2/woff2.wasm — the baseUrl option (defaults to Vite's BASE_URL in the demo build, or the consuming page's base URL). The demo ships the wasm at public/woff2/woff2.wasm, so it resolves automatically on GitHub Pages and in the local demo.
  3. In Node (e.g. tests), fonteditor-core resolves its own packaged woff2.wasm from 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 smcp glyph 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-image markers are embedded at their natural size; if a browser scales them (e.g. ::marker with 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/or pdfOptions: {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 in public/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 in public/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.