blitz-pdf
v0.1.10
Published
Render HTML and CSS to PDF with Blitz and WebAssembly in Node.js, Bun, and browsers.
Maintainers
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-pdfNode.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.pdfPdfAttachment, 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:pdfaThe 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, andverso.break-inside: avoid/avoid-page, plus legacypage-break-before,page-break-after, andpage-break-insidealiases.- Inherited
widowsandorphans(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.mjsCreates 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 packThe 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:
- Run
npm version patch --no-git-tag-versionto update both npm manifests. - Set the same version in
Cargo.tomland theblitz-pdf-wasmentry inCargo.lock. - 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.
