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

blitz-pdf

v0.1.10

Published

Render HTML and CSS to PDF with Blitz and WebAssembly in Node.js, Bun, and browsers.

Readme

blitz-pdf

HTML and CSS to vector PDF/A-3u, powered by Blitz and Krilla, compiled to WebAssembly.

Runs in Bun, Node.js, and browsers. Text is selectable and searchable, fonts are subsetted and embedded, and shapes remain vectors. No Chromium, GPU, native addon, or rendering service. Source images remain images.

This is an early implementation; see the limitations below.

Install

npm install blitz-pdf
# or
bun add blitz-pdf

Node.js and Bun

import { renderPdf } from "blitz-pdf";

const pdf = await renderPdf(`
  <style>body { font-family: sans-serif } h1 { color: #2457db }</style>
  <h1>Hello, PDF</h1>
  <p>Selectable text: café, office, €.</p>
`, { format: "A4", margin: "15mm", title: "Example" });

// Node.js
await (await import("node:fs/promises")).writeFile("example.pdf", pdf);

// Bun
// await Bun.write("example.pdf", pdf);

The first render loads the bundled WASM automatically. The npm package includes JavaScript, TypeScript declarations, and the WASM containing the fonts. Consumers do not need Rust. The package is ESM, targeting Node.js 20+ and modern Bun.

Browser

import { renderPdf } from "blitz-pdf/browser";

const bytes = await renderPdf("<h1>Made in the browser</h1>");
const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
const link = document.createElement("a");
link.href = url;
link.download = "example.pdf";
link.click();
// Revoke the URL when it is no longer needed.

Bundlers should copy the WASM referenced through new URL(..., import.meta.url). For a custom deployment, set its location before the first render:

import { init, renderPdf } from "blitz-pdf/browser";
await init({ wasm: new URL("/assets/blitz_pdf_bg.wasm", location.href) });
const pdf = await renderPdf("<p>Ready</p>");

The binary is exported as blitz-pdf/wasm. Serve it with Content-Type: application/wasm. init also accepts bytes, a Response, or a compiled WebAssembly.Module. Concurrent callers share initialization; failed loads can be retried.

Asset I/O is asynchronous. HTML layout and PDF serialization perform synchronous CPU work. Use a Web Worker or server worker thread when the calling thread must stay responsive. Each worker gets its own WASM instance. Shared memory and cross-origin isolation are not required.

Rendering in a worker

renderPdfInWorker has the same signature as renderPdf, but layout and PDF serialization run in a fresh worker: a Web Worker in browsers and a worker_threads worker in Node.js and Bun. The calling thread stays responsive:

import { renderPdfInWorker } from "blitz-pdf"; // or "blitz-pdf/browser"

const pdfs = await Promise.all(documents.map(html =>
  renderPdfInWorker(html, { format: "A4", assets: async url => (await fetch(url)).arrayBuffer() })));

The WASM is compiled once into a WebAssembly.Module and posted to every worker. Workers only instantiate it, so they skip WASM compilation. init({ wasm }) sets the source for this shared module as well. Each render creates one worker and waits for its termination on success or failure before settling. No idle engine or font collection is retained. Bound concurrent calls in the application when rendering many documents at once.

For applications that explicitly choose to retain engines between documents, createPdfWorkerPool remains an opt-in API. It is not used by renderPdfInWorker. A pool retains initialized engines and bundled fonts while idle, so it uses more resident memory. Assets, custom fonts, metadata and attachments remain local to each render. Configure its lifetime and close it when no longer needed:

import { createPdfWorkerPool } from "blitz-pdf";

const pool = createPdfWorkerPool({ maxWorkers: 4, idleTimeoutMs: 300_000 });
try {
  const pdf = await pool.renderPdf(html, { format: "A4" });
} finally {
  await pool.close(); // on application shutdown, not after every document
}

maxWorkers and maxJobsPerWorker must be positive safe integers; idleTimeoutMs must be an integer from 0 to 2147483647 (0 disables reuse). close() rejects new submissions, drains already accepted renders, and waits for worker termination. CPU work is not interrupted. Applications must bound their asset resolvers with their own deadlines so shutdown can finish.

Options are validated on the calling thread. An assets function also runs there, so it can use closures, credentials, and caches; the worker receives only the returned bytes. Errors are reported the same way as for renderPdf. Bundlers must emit dist/worker.js through its new Worker(new URL(..., import.meta.url)) reference; Vite and webpack do this automatically.

To manage your own workers, post the shared module from getWasmModule() and pass it to init({ wasm: module }) inside the worker.

Options

renderPdf(html: string, options?: PdfOptions): Promise<Uint8Array>

| Option | Default | Meaning | | --- | --- | --- | | format | "A4" | A0–A6, Letter, Legal, Tabloid, or Ledger | | width, height | From format | Supply both for custom dimensions; omit format | | landscape | false | Swap width and height | | preferCSSPageSize | false | Let CSS @page size override an explicit format or dimensions | | margin | 0 | One length or { top, right, bottom, left }; omitted sides are zero | | media | "print" | CSS media queries: print or screen | | title | Unset | PDF metadata title; metadata.title takes precedence | | metadata | {} | Author, subject, keywords, dates, language, document ID, and custom XMP schemas | | attachments | [] | Direct embedded files with MIME types and PDF/A relationships | | pageNumbers | false | true or { template, align, fontSize } for an automatic text footer | | headerTemplate / footerTemplate | Unset | HTML repeated in the top/bottom page margin, with .pageNumber and .totalPages placeholders | | baseUrl | https://blitz-pdf.invalid/ | Resolve relative asset URLs | | assets | {} | Resource map or (url) => data / Promise<data>; data is a UTF-8 string, Uint8Array, or ArrayBuffer | | fonts | [] | Additional static TTF, OTF, WOFF, or WOFF2 font bytes | | maxPages | 100 | Reject larger documents; maximum 1000 |

Lengths accept numbers in CSS pixels (96 px = 1 inch), or strings with px, pt, mm, cm, or in. PDF dimensions use 72 points per inch. Page dimensions are limited to 19200 CSS pixels; margins must leave printable content. getEngineVersion() returns the package version and pinned Blitz revision.

CSS page settings

Unqualified @page rules support size, margin, and the four individual margin properties. For example, this label uses a 105 mm by 148 mm page:

const pdf = await renderPdf(`
  <style>
    @page { size: 105mm 148mm; margin: 5mm; margin-bottom: 8mm; }
    html, body { margin: 0; }
  </style>
  <h1>Shipping label</h1>
`);

size: A4 landscape, size: landscape, square sizes such as size: 100mm, and absolute length calculations are supported. Rules follow stylesheet and @import order, honor !important, and use the selected print/screen media. CSS page margins override the corresponding option margins; unspecified sides keep their option values. Percentages resolve against the corresponding page dimension. Margins must be non-negative and leave printable content.

CSS size is used automatically when neither format nor width/height is provided. Explicit dimensions take precedence by default. Set preferCSSPageSize: true to let CSS size override them. size: auto keeps the option size, or A4 when no size was supplied. Content is laid out again at the chosen dimensions; it is not scaled from an A4 layout.

Named formats are A0, A1, A2, A3, A4, A5, A6, Letter, Legal, Tabloid (11 by 17 inches), and Ledger (17 by 11 inches). landscape: true swaps the option dimensions. An explicit CSS orientation takes precedence when CSS controls the size.

The page geometry is uniform throughout a document. Named pages, :first, :left/:right page selectors, CSS page margin boxes, cascade layers, and custom-property substitution inside @page are not implemented.

Document metadata

const pdf = await renderPdf(html, {
  metadata: {
    title: "Project report",
    author: ["Ada Example", "Example GmbH"], // A single string also works.
    subject: "Project delivery and supporting documents",
    keywords: ["project", "delivery"],
    creator: "My application",
    language: "de-DE",
    creationDate: "2026-10-13T10:00:00+02:00",
    modificationDate: new Date("2026-10-14T08:00:00Z"),
    documentId: "project-42-report", // Stable identity across revisions.
  },
});

Standard metadata is synchronized between the PDF information dictionary and XMP. language also sets the document catalog's language. producer defaults to blitz-pdf and its version and can be overridden. Creation time defaults to the start of the render; modification time defaults to creation time and must not precede it. Dates accept a valid Date or a timestamp with an explicit timezone, with at most three fractional-second digits. They are stored in UTC at whole-second precision. documentId is a stable input from which the engine derives the PDF document ID; it is not copied literally into the ID field.

The original top-level title option remains supported. metadata.title overrides it, including an empty string to omit the title. Metadata strings must contain valid XML characters and fit in 16 KiB of UTF-8 per field. Author and keyword lists allow up to 256 entries and 16 KiB when joined. CR/CRLF line endings are normalized to LF to keep Info and XMP consistent.

Embedded files

const pdf = await renderPdf(html, {
  attachments: [
    {
      name: "data.xml",
      data: xmlString, // UTF-8 text, Uint8Array/Buffer, or ArrayBuffer.
      mimeType: "text/xml",
      description: "Structured document data",
      relationship: "Alternative",
    },
    {
      name: "supporting-data.csv",
      data: "item,quantity\nExample,2\n",
      mimeType: "text/csv",
      relationship: "Supplement",
    },
  ],
});

Files are embedded directly, without going through the HTML asset resolver. data is the file's content, never a filename or URL to load. Use readFile, fetch(...).arrayBuffer(), or File.arrayBuffer() before calling renderPdf. The library snapshots supplied bytes and metadata before asynchronous work.

Each attachment has a Unicode filename, a MIME type, an uncompressed size and modification date, and a document-level /AFRelationship. It appears both in the catalog's /AF array and in the /EmbeddedFiles name tree used by PDF viewers. relationship accepts Source, Data, Alternative, Supplement, or Unspecified (the default). MIME type defaults to application/octet-stream, description to the filename, and modification date to the document's modification date. MIME parameters such as ; charset=utf-8 are not accepted. Set compress: true or false to control compression; by default the engine chooses.

Up to 100 files and 64 MiB of combined attachment contents are allowed, separately from HTML asset limits. Filenames must be at most 255 UTF-8 bytes, without directories, colons, or control characters. Duplicate filenames, including Unicode normalization equivalents, are rejected. Zero-byte files are supported. Attachment contents are preserved without interpreting or validating their format.

Custom XMP schemas

metadata.xmp adds application-specific text properties and their PDF/A extension schema descriptions to the existing XMP packet. Standard metadata and PDF/A identification remain present.

const pdf = await renderPdf(html, {
  metadata: {
    xmp: [{
      name: "Project metadata",
      namespaceUri: "urn:example:project:1.0#",
      prefix: "project",
      properties: [{
        name: "ProjectId",
        value: "PR-0042",
        description: "Identifier of the project this document belongs to",
        category: "external", // Default; use internal for content-derived values.
      }],
    }],
  },
});

Every property is declared as XMP Text; values and descriptions are escaped as XML. Schema names and property descriptions must be non-empty. Prefixes and property names use ASCII XML names without colons or an xml prefix. Namespace URIs must be absolute ASCII URIs with whitespace and XML delimiters percent-encoded. Built-in namespaces/prefixes are reserved. Duplicate prefixes, namespace URIs, or property names within one schema are rejected. Each schema requires at least one property. Limits are 32 schemas, 256 properties in total, and 1 MiB of combined schema text, in addition to the per-field limit. Structured XMP values and raw XMP packet replacement are not currently exposed.

Factur-X / ZUGFeRD example

The generic attachment and XMP APIs can express Factur-X/ZUGFeRD packaging. For example, with matching HTML and already-validated EN 16931 CII invoice XML:

const pdf = await renderPdf(invoiceHtml, {
  attachments: [{
    name: "factur-x.xml", data: invoiceXml, mimeType: "text/xml",
    description: "Invoice XML", relationship: "Alternative",
  }],
  metadata: {
    title: "Invoice RE-0042", language: "de-DE",
    xmp: [{
      name: "Factur-X PDFA Extension Schema",
      namespaceUri: "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#",
      prefix: "fx",
      properties: [
        { name: "DocumentFileName", value: "factur-x.xml", description: "Embedded invoice filename" },
        { name: "DocumentType", value: "INVOICE", description: "Document type" },
        { name: "Version", value: "1.0", description: "Schema version" },
        { name: "ConformanceLevel", value: "EN 16931", description: "Invoice profile" },
      ],
    }],
  },
});

These settings follow the PDF embedding and XMP requirements for Factur-X 1.x / ZUGFeRD 2.1+. The XMP Version is 1.0, not the release number. Choose the profile and relationship for your document: Data for MINIMUM/BASIC WL; normally Alternative for BASIC, EN 16931, or EXTENDED. Source applies in the specified cases where the PDF is derived from XML and the recipient is outside Germany. The XML filename and XMP DocumentFileName must agree. See the FeRD specification and validation artifacts for the version/profile you use.

The renderer does not generate invoice XML, validate XSD/Schematron rules, infer profiles, or check agreement between PDF and XML. Applications supply and validate these inputs. PDF/A conformance alone does not establish invoice conformance.

The runnable example accepts your HTML, XML, and profile:

npm run example:zugferd -- invoice.html invoice.xml "EN 16931" output/pdf/invoice.pdf

PdfAttachment, AttachmentRelationship, PdfMetadata, PdfDate, PdfXmpSchema, and PdfXmpProperty are exported TypeScript types in Node/Bun and browser entry points.

Resources and fonts

The renderer requests resources through assets or decodes data URLs. Supply an asset map or a loading function; network and file access are controlled by your function. Missing resources fail with an error. Linked CSS, nested imports, images, and @font-face use the same resolver.

const pdf = await renderPdf(`
  <link rel="stylesheet" href="invoice.css">
  <img src="logo.png" width="80">
  <h1>Invoice</h1>
`, {
  baseUrl: "https://example.com/documents/",
  assets: {
    "invoice.css": "h1 { color: navy }",
    "logo.png": new Uint8Array(await (await fetch("/logo.png")).arrayBuffer()),
  },
});

For dynamic loading, assets can be an async function in Bun, Node, browsers, and workers:

const pdf = await renderPdf(`
  <link rel="stylesheet" href="invoice.css">
  <img src="logo.png" width="80">
  <h1>Invoice</h1>
`, {
  baseUrl: "https://example.com/documents/",
  assets: async (url) => {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status} loading ${url}`);
    return response.arrayBuffer();
  },
});

The callback receives an absolute URL string, resolved against the document or referring stylesheet. Return UTF-8 text, a Uint8Array (including a Node/Bun Buffer), or an ArrayBuffer; synchronous returns also work. For filesystem assets, use a file: base URL and return readFile(new URL(url)) in your callback. AssetData, AssetMap, and AssetResolver are exported TypeScript types.

Blitz discovers resources as it parses and lays out the document, including nested CSS imports, font faces, and CSS background images. Loading completes before pagination and PDF serialization. Each absolute URL is loaded once per render and cached for that render; data URLs bypass the callback. Up to eight independent callbacks run concurrently. Concurrent renders have separate caches. Browser callbacks follow the browser's normal CORS rules.

Returning null or undefined rejects the render as a missing asset. A thrown error or rejected promise includes the resource URL and preserves the original error as cause. Invalid return types and assets exceeding the combined 64 MiB limit also reject. Already-running callbacks may finish after another callback fails; their results are discarded. Apply fetch timeouts in your loader when needed. The existing asset-map API continues to work.

Noto Sans regular, bold, italic, and bold italic are the bundled defaults; Noto Sans Mono backs monospace. Noto Sans CJK regular and bold cover CJK text, including supplementary ideographs. DejaVu families remain available when explicitly requested. Other generic families fall back to Noto Sans. There is no system font discovery. Fonts passed in fonts use their internal family names; use @font-face with an asset URL for a CSS alias.

Missing glyphs automatically fall back to other registered fonts, including fonts loaded asynchronously through @font-face. Requested CSS families and script-specific fallbacks take precedence, then registered fonts are tried in registration order. Bundled Unifont and Unifont Upper are the last resorts for broader Unicode coverage; monochrome Noto Emoji covers emoji sequences. This works for body text, generated CSS content, headers, footers and page numbers, through both renderPdf and renderPdfInWorker, without editing CSS font stacks.

Supply a suitable script font (such as a Noto family) for higher-quality shaping and typography in complex scripts; it takes precedence over Unifont. The fallback search is not restricted to a language or Unicode plane. Bundled font coverage is finite; supply additional fonts for characters they do not contain. A glyph must exist in at least one available font, including for custom private-use characters. Missing glyphs continue to fail PDF/A validation. No text is removed or replaced to hide a missing glyph.

Input limits: 16 MiB HTML, 64 MiB supplied assets, 64 MiB attachments, 32 MiB supplied fonts, and 1024 resource requests. These are practical limits, not a complete sandbox; isolate untrusted documents in a terminable worker with application limits.

Unicode text

HTML strings and UTF-8 assets preserve the text produced by layout, including non-Latin scripts, supplementary-plane characters, combining marks, and ligatures. Visually identical sequences such as é / é, fi / fi, and composed / decomposed Hangul can coexist in one document. Their source mappings remain separate in body text, form values, repeated table headers, margin templates, and page numbers. CSS whitespace handling and text-transform still apply normally.

PDF Unicode maps and ActualText spans preserve the original sequences when multiple characters share a glyph or a cluster uses multiple glyphs. Accurate copy/paste requires a reader that supports ActualText; extractors that ignore it, including PDF.js, can normalize ligatures or repeat combining clusters. The Unicode regression suite verifies extraction with Poppler's pdftotext.

Unicode support does not imply that one font covers every character. There is no system-font discovery or automatic font download. For example, provide a static CJK font and select its family to render Chinese, Japanese, or Korean:

import { readFile } from "node:fs/promises";

const pdf = await renderPdf(
  '<p style="font-family: \'Noto Sans CJK SC\'">中文 日本語 한국어</p>',
  { fonts: [await readFile("NotoSansCJKsc-Regular.otf")] },
);

Use @font-face and assets for a CSS family alias. Fonts must contain the required glyphs and permit embedding; missing glyphs still reject PDF/A output.

PDF/A-3 conformance

Every successful render targets PDF/A-3u (ISO 19005-3, level U). The PDF 1.7 output includes embedded fonts and Unicode mappings, an sRGB ICC output intent, device-independent colors, PDF/A identification in XMP, document IDs, and a UTC creation timestamp. Text remains selectable; no page or text rasterization is used. Transparency and source images remain supported.

Krilla's archival validator runs during serialization. Nonconforming inputs, such as a missing glyph or a font with restricted embedding permissions, fail the render instead of producing a mislabeled file. Use fonts licensed for embedding. Level U does not require a structure tree; tagged PDF/A-3a is not implemented.

The independent conformance test captures PDFs from the Bun integration suite and validates each with veraPDF. It also requires a deliberately nonconforming control PDF to fail validation.

# Development/CI only; Java 21 and unzip are required for installation.
npm run setup:verapdf
npm run test:pdfa
# Or use an existing installation:
VERAPDF_BIN=/path/to/verapdf npm run test:pdfa

The installer pins veraPDF 1.30.3 and verifies its SHA-256 checksum. Reports and captured PDFs are saved under output/pdfa-validation/. Java and veraPDF are not runtime dependencies of the npm package.

Page breaks

Pagination uses the printable area after margin. Supported controls in normal vertical flow include:

  • break-before / break-after: page, always, avoid, avoid-page, left, right, recto, and verso.
  • break-inside: avoid / avoid-page, plus legacy page-break-before, page-break-after, and page-break-inside aliases.
  • Inherited widows and orphans (positive integers; default 2).
  • Automatic protection of text lines, table rows, images, and SVG boxes.
  • Repeated table header and footer groups, with space reserved before splitting rows.
<style>
  h2 { break-after: avoid; }
  .chapter { break-before: page; }
  .card { break-inside: avoid; }
  p { widows: 3; orphans: 3; }
</style>

Selectors, external stylesheets, print/screen queries, variables, specificity, and !important use Stylo's real cascade. Adjacent forced breaks coalesce; first/last break rules do not add accidental blank pages. Left/right/recto/verso may intentionally insert a blank page, respecting the document's direction. All pages, including intentional blanks, count toward maxPages.

Forced breaks take precedence over keep-together rules. Blocks larger than a page are allowed to fragment, and impossible widow/orphan constraints are relaxed to make progress. A text line that cannot fit the printable height fails explicitly. Keep-with-next protects a heading even when the following paragraph spans multiple pages.

Trailing <br> elements and preserved blank lines keep their layout height; the extra cursor line emitted by the text engine after a final break is not paginated. Overflow clips constrain line, glyph, and table-row fragmentation. Fully clipped glyph runs are omitted from the PDF's selectable text.

Pagination failures include a code at the start of Error.message:

  • PAGINATION_LINE_TOO_TALL: a line exceeds the available printable height.
  • PAGINATION_NO_PROGRESS: no valid advancing break exists, including chains of overlapping lines that individually fit the page.
  • PAGINATION_FORCED_BREAK_INTERSECTS_LINE: an explicit break cuts a line.
  • PAGINATION_TABLE_GROUPS_TOO_TALL: repeated groups leave no room for rows.

These messages include the zero-based page index, document-space page top, available height, and, when associated with a DOM line, element tag/ID, internal node ID, zero-based line index and measured visible line range. Coordinates and heights are CSS pixels. Painted glyph runs without a DOM association and errors without a blocking line report those fields as unavailable.

The local Blitz layout patch preserves fractional page geometry, corrects table-cell line spacing and empty-paragraph margin collapse, and stretches td { max-width: 0 } cells to the columns allocated by the table. Ordinary content inside display: table containers receives anonymous row/cell wrappers. See the layout regressions and pagination investigation.

The first <thead> and <tfoot> (or elements styled as table-header-group and table-footer-group) repeat on every fragment of their table. Their actual layout, column widths, backgrounds, and selectable text are retained. Use display: table-row-group to disable repetition. Headers stay with the first body row and footers with the last where they fit. Repetition stops when the table ends, and the extra space counts toward maxPages. Nested tables remain part of their containing row rather than independently repeating their groups.

Pagination accounts for the subpixel difference between CSS dimensions and Blitz's rounded box edges, avoiding an extra page for an exactly fitting fractional-height label.

Page numbers

const pdf = await renderPdf(html, {
  format: "A4",
  margin: "17mm",
  pageNumbers: {
    template: "RE-2026-1042 · Seite {page} von {pages}",
    align: "right",
    fontSize: "8pt",
  },
});

pageNumbers: true defaults to "{page} / {pages}", right alignment, and 12 CSS pixels (9pt). The template is a single line containing {page} and optionally {pages}. Alignment accepts left, center, or right; font size accepts the usual lengths, from 1 to 48 CSS pixels. A template that does not fit the page width fails explicitly.

The renderer reserves a footer band of 2 × fontSize + 8px above the bottom margin before pagination. Numbers use the final page count, including intentional blanks. The footer shares the document's font collection, Unicode fallbacks and shaping, preserving selectable text and PDF/A-3u. It works with async assets in every runtime. PageNumberOptions is an exported TypeScript type.

Repeated HTML headers and footers

const pdf = await renderPdf(reportHtml, {
  margin: { top: "18mm", bottom: "15mm", left: "12mm", right: "12mm" },
  headerTemplate: `
    <div style="font: 10pt/14pt sans-serif; padding-top: 4mm">
      <img src="logo.svg" width="24" height="24"> Quarterly report
    </div>`,
  footerTemplate: `
    <div style="font: 9pt/12pt sans-serif; text-align: right">
      Page <span class="pageNumber"></span> of <span class="totalPages"></span>
    </div>`,
  assets: { "logo.svg": logoSvg },
});

Templates occupy the top/bottom margins between the left and right margins, including on intentional blank pages. Supply enough margin height for each template; overflow fails with an error. Both start at the top of their margin band, so use CSS padding or flex layout to position their contents within it. Body content does not overlap those bands. pageNumbers can also be used; its automatic footer remains above the bottom margin.

Each template is a separate HTML document with zero default body margin and a transparent background. Template-local inline styles, <style>, and linked stylesheets are supported. Body CSS does not cascade into templates. They share the document's asset resolver, loaded font collection, supplied fonts, base URL, and media type. URLs are resolved and deduplicated across the whole render, including async CSS, fonts, and images. Scripts are not run. Text stays selectable and graphics use the same vector PDF pipeline as the body.

Elements with class pageNumber or totalPages have their contents replaced with the final page number/count before layout. Templates are limited to 1 MiB of HTML each. Omit a template or pass an empty string to disable it.

German invoice example

npm run example:german-invoice
# Or run the same generator using Bun:
bun examples/german-invoice.mjs

Creates output/pdf/german-invoice.pdf: three A4 pages, 24 itemized services, German dates and currency, supplier/customer details, invoice and order numbers, a service period, a 3% project discount, 19% VAT, payment details, and automatic “Seite X von Y” footers. The final amount is 25.631,23 EUR. Calculations use integer cents and half-up rounding. The stylesheet loads through the async assets callback. The generator also writes the HTML/CSS and calculated totals alongside the PDF for inspection.

The example source and scope explain the fictitious data and the German invoice fields. This is a PDF/A-3u example without embedded XML. Use the generic attachment and XMP APIs with independently generated, matching invoice XML for a structured e-invoice. Its 2026 domestic B2B scenario assumes the recipient agrees to PDF delivery under the applicable transition rule. § 14 UStG, § 27(38) UStG.

Current limits

  • CSS support follows Blitz, whose coverage is evolving.
  • Text retains shaped glyphs and Unicode cluster mappings, including ligatures. Unrecognized or ambiguous text runs are rejected rather than assigned guessed Unicode. Non-whitespace ASCII controls (U+0000–U+0008, U+000B, U+000E–U+001F, U+007F) are ignored in rendered text after HTML/CSS parsing. Normal whitespace processing and errors for missing printable glyphs are preserved.
  • Pagination fragments one continuous vertical layout. Per-page geometry, repeated fixed-position body elements, nested multicolumn fragmentation, and independent fragmentation of parallel flex/grid flows are not implemented. Oversized boxes/images may span pages; box decorations are sliced rather than cloned.
  • CSS filters, blurred shadows, non-default variable font instances, and repeating radial gradients are rejected. Use static font faces. There is no automatic raster fallback.
  • Gradients become sRGB PDF shadings; advanced CSS interpolation spaces may differ. SVG shapes stay vector; upstream SVG text may become vector outlines.
  • Scripts are not executed. Interactive forms, link annotations, accessibility tags, encryption, and signatures are not generated.

Development

Install Node.js 22.13+ for development tooling, Bun, and rustup. The repository pins Rust 1.99.0 and its WASM target in rust-toolchain.toml. The Unicode integration tests also require Poppler's pdftotext (brew install poppler on macOS or apt-get install poppler-utils on Debian/Ubuntu).

npm ci
cargo install wasm-bindgen-cli --version 0.2.129 --locked
npm run build
bun test
npm run test:node
npm run example:node
# or: bun run example:bun
npm pack

The wasm-bindgen CLI version must match Cargo.lock. Dependencies and upstream commits are locked. npm pack verifies artifacts and renders a PDF before making the tarball; it does not publish. Install locally using npm install ./blitz-pdf-<version>.tgz.

The pinned Stylo source in vendor/stylo enables paged-media properties in the Servo build. vendor/krilla adds custom XMP schema support and independent modification dates. vendor/blitz-paint exposes source layouts in paint order so Unicode mappings retain each run's identity. vendor/README.md records the patches; the sources and licenses are included in the npm archive.

bun test exercises the compiled module through its public package export. Tests extract Unicode with PDF.js and Poppler and inspect embedded fonts, vector paths, absence of page images, dimensions, pagination, assets, media queries, and error recovery. For browser verification, run npm run test:browser and open http://127.0.0.1:4173. CI builds WASM, runs Bun and Node tests, validates the captured PDFs with veraPDF, benchmarks, and packs an npm artifact.

Publishing releases

Push a tag matching the package version, such as v0.1.1, to start Publish to npm. The workflow checks that the tag, npm manifests, and root Rust package versions agree. It then builds WASM from source, runs the Bun and Node tests, validates PDF/A-3u with veraPDF, runs benchmarks, and publishes the verified archive. Release builds do not restore dependency or compiler caches.

Publishing uses npm trusted publishing with GitHub OIDC and provenance. The npm trust must permit direct publishing from iteufel/blitz-pdf, workflow publish.yml, with no environment restriction. No NPM_TOKEN repository secret is needed. Stable versions use the npm latest tag; prereleases use next. After publishing, the workflow allows up to five minutes for registry propagation and checks that npm serves the exact archive it built, comparing SHA-512 integrity. Retrying an already-published version succeeds only when its archive is identical; a different archive fails.

For the next patch release:

  1. Run npm version patch --no-git-tag-version to update both npm manifests.
  2. Set the same version in Cargo.toml and the blitz-pdf-wasm entry in Cargo.lock.
  3. Commit the changes, create the matching Git tag, and push both:
git add package.json package-lock.json Cargo.toml Cargo.lock
RELEASE_VERSION=$(node -p "require('./package.json').version")
git commit -m "chore: release $RELEASE_VERSION"
git tag "v$RELEASE_VERSION"
git push origin main "v$RELEASE_VERSION"

To retry a failed release, rerun its GitHub Actions run or use gh workflow run publish.yml --ref v<version>. Manual runs require a matching version tag too. Published npm versions cannot be overwritten; subsequent releases need a new version.

Font startup

Bundled WOFF2 assets are decoded to SFNT by build.rs. The WASM registers immutable slices of those bytes directly, avoiding runtime WOFF2 decompression and a second font-sized heap allocation. The original fonts, family ordering, Unicode coverage and document-specific font isolation are preserved. This trades a larger WASM download for faster cold renders; serve the WASM with HTTP compression in browsers. User-supplied WOFF/WOFF2 fonts are still decoded at runtime.

Performance benchmarks

bun run bench
bun run bench --iterations=50 --warmup=5
bun run bench --mode=worker --iterations=50 --warmup=5
bun run bench --mode=fresh-worker --iterations=20 --warmup=1
bun run bench --iterations=50 --warmup=5 --json > benchmark-results.json

--mode=main (default) renders on the calling thread, worker reuses one worker, and fresh-worker retires the worker after each document to expose font startup costs.

Benchmarks measure WASM initialization separately, then first-render, mean, median, p95, throughput, and output size for small HTML, a styled invoice, 100 paragraphs, matching asset-map/async-loader documents with nested CSS and a font, and the complete three-page German invoice with page numbers. The async benchmarks use in-memory data to measure resolver overhead without network latency. Warmed samples include parsing, styling/layout, painting, font subsetting, and PDF serialization. Resource fetches, file writes, and process startup are excluded. Each iteration renders a new document. JSON records the runtime, platform, sample count, and RSS.

Results depend on hardware, engine, fonts, and document complexity. Run on your deployment hardware. CI records results without a timing threshold on shared runners.

License

MIT. See THIRD_PARTY_NOTICES.md and licenses/ for dependencies and fonts. This is an independent integration.