PDF generation
Choosing a backend
There are three ways to get a document out, and they do not offer the same thing. The table below is the short version; what each one guarantees below it is the part worth reading before you promise anything to a customer.
renderPdfPaginated (Forme) | renderPdf (Forme) | @broadpaper/pdf (Chromium) | Browser print | |
|---|---|---|---|---|
| Engine | Rust → WebAssembly, ~7 MB | the same | Headless Chromium, ~700 MB | the user's browser |
| Who decides the pages | BroadPaper | Forme | BroadPaper | the browser |
| Page numbers | Exact, per page | Not resolved per page | Exact, per page | Browser's own |
| Runs on | Node, Deno, Bun, Workers, Lambda, the browser | the same | A machine that can run a browser | the user's machine |
| Cold start | Milliseconds | Milliseconds | Seconds | None |
| Gives your code a file | Yes | Yes | Yes | No |
| Text shaping | The engine's own | The engine's own | The browser's | The browser's |
| PDF/UA, PDF/A | Yes | Yes | No | No |
Start with renderPdfPaginated. No native dependencies, runs everywhere including the user's own browser, and the editor can measure with the same engine so the canvas and the file break pages in the same places. See Browserless PDF.
Reach for @broadpaper/pdf when you need the browser's text shaping specifically — complex scripts, unusual font fallback chains — or when you want the browser as an independent check on the other backend's fidelity.
What we will not do is fake it. Raster approaches (html2canvas → jsPDF) throw away selectable text and vectors. Both engines here are real layout engines writing real PDFs.
What each one guarantees
All three PDF paths guarantee: vector output, selectable and extractable text, one page's text per page in the file, working links, and the page count and break positions BroadPaper's paginator decided (except renderPdf, which paginates itself).
None of them guarantees that two different engines draw the same pixels. Forme and Chromium shape text with different code; the same paragraph can wrap a word earlier in one than in the other. Our parity test asserts that no section drifts by more than 12 px between the two on the test corpus, and that they agree on page counts to within one page on a long document. That is a bound, not an equality, and it is the honest claim.
Four things move output that are not the template:
| What changes | What it moves |
|---|---|
Fonts. Without fonts, Forme falls back to the standard PDF faces, which have different metrics from your web fonts and carry no glyphs outside WinAnsi. | Line breaks, page breaks, missing glyphs. |
| Renderer version. Both engines improve their line breaking between releases. | Line breaks, and occasionally a page break. |
| Browser version, for the Chromium backend. | The same. |
| External assets. A remote logo or web font that fails to load, or has changed. | Whatever it was drawing. |
Pin the ones you care about: register font bytes rather than family names, pin the engine version in your lockfile, and serve assets from somewhere you control.
Byte-for-byte reproducibility
Rendering the same document twice produces byte-identical PDFs — the test suite asserts it — provided all of these are held constant:
- the template, the data and the theme;
now, since an unfixed clock puts a different generation date in the document and in the PDF's own metadata;- the registered font bytes (not just the family names);
- every external asset the document fetches;
- the renderer, and its exact version.
Change any one of them and the bytes change, usually harmlessly. Reproducibility across machines needs the same list plus the same package versions; reproducibility across engines is not offered at all, and the section above says why.
The print fallback
ctx.print() and the Export button with no pdfService and no onExportPdf open the browser's own print dialog on the laid-out preview. It is a fallback, not a backend:
- the browser re-paginates, so page breaks are its decision and not ours;
- headers, footers and page numbers come out as whatever the user's print settings say;
- your code never receives a file, so it cannot be stored, emailed or checked;
- output varies by browser and by the user's own dialog settings.
Use it to let someone put a report on paper. Do not use it to produce a document you have promised to keep.
The rest of this page covers the Chromium backend.
renderPdf
import { renderPdf } from "@broadpaper/pdf";
const { pdf, pages, warnings, durationMs } = await renderPdf({
template, data, theme, dataSources,
now: new Date("2026-09-01T09:00:00Z"), // fixed clock — see "Byte-for-byte reproducibility"
metadata: { title: "Investment review", author: "Meridian Wealth", subject: "Q3 2026", keywords: ["review"] },
network: { allowedHosts: ["cdn.example.com", "fonts.gstatic.com"] },
customBlocksScript, // optional IIFE registering custom blocks
maxPages: 500, timeoutMs: 60_000
});For servers, keep one browser and reuse it:
import { chromium } from "playwright";
import { PdfRenderer } from "@broadpaper/pdf";
const browser = await chromium.launch();
const renderer = new PdfRenderer(browser);
app.post("/reports/:id/pdf", async (req, res) => {
const result = await renderer.render({ template, data, theme });
res.type("application/pdf").send(result.pdf);
});Each render gets a fresh, isolated browser context and a strict network policy.
The HTTP service
BROADPAPER_TOKEN=secret BROADPAPER_ALLOWED_HOSTS=cdn.example.com npx broadpaper-pdf
# POST /render { template, data, theme, dataSources, metadata, now?, format?: "pdf" | "json" }
# GET /healthor Docker:
docker build -t broadpaper-pdf packages/pdf
docker run -p 4780:4780 -e BROADPAPER_TOKEN=secret broadpaper-pdfPoint the editor at it and the Export button downloads PDFs directly:
<ReportDesigner pdfService={{ url: "https://pdf.internal.example.com", token }} … />Concurrency, timeouts, page caps and body-size limits are configurable; the service narrows (never widens) the network allow-list per request.
Without a service
If no pdfService and no onExportPdf is configured, Export falls back to the browser's print dialog on the same paged DOM (printReport(layout)). That works, but it is the browser's own writer rather than either of our backends, so its page breaks are its own.
The better answer is usually to render in the browser with the WebAssembly backend and hand the user a real file — no service, and the same pagination as the canvas. See Browserless PDF.
Fidelity notes
- Measurement happens in the same engine that prints, with the same fonts. The editor on a user's machine (which may be Firefox or Safari) can differ by a line here and there; the service output is authoritative. To remove the difference entirely, have the editor measure with the engine — see Browserless PDF.
page.number/page.totalin body text are measured with placeholder digits, so a very long page count can, in principle, shift a line. Headers and footers are unaffected.- Charts are SVG: crisp at any zoom, small files.