Browserless PDF
@broadpaper/pdf drives headless Chromium. That gives the highest fidelity and keeps the editor and the PDF in exact agreement, but it needs a ~700 MB browser on the machine that renders, which rules out edge and most serverless runtimes.
Either backend can be put behind HTTP with @broadpaper/server, so a .NET, Java, Python or Go application can render without a JavaScript runtime of its own.
@broadpaper/forme is an alternative backend built on Forme, an MIT-licensed layout engine written in Rust and compiled to WebAssembly. No browser, no native dependencies, about 7 MB, and it runs in Node and in the browser itself. Other runtimes that load WebAssembly may well run it too, but they are not tested, and about 7 MB of engine is past some serverless and edge platforms' size limits.
pnpm add @broadpaper/forme @broadpaper/core @broadpaper/blocksThe engine comes with it. Nothing to download at install time, no post-install step, and no browser to keep patched.
import { renderPdfPaginated } from "@broadpaper/forme";
import { createRegistry } from "@broadpaper/blocks";
const { pdf, pages, paged, warnings } = await renderPdfPaginated({
template,
registry: createRegistry([riskProfileBlock]),
data,
theme,
dataSources,
// The theme's `fonts` are embedded; nothing about them is repeated here.
metadata: { title: "Investment review" }
});Two entry points
renderPdfPaginated() — recommended. BroadPaper owns pagination exactly as it does with Chromium; Forme only measures and draws. Two passes through the engine: one onto a page tall enough that nothing overflows, which yields a box for every node, line and table row, and one that draws the pages our paginator decided. Every break rule stays ours — keep together, keep with next, orphans and widows, repeated table headers, explicit page breaks, splitting multi-column rows — and page numbers are resolved per page, so {{ page.number }} of {{ page.total }} is exact.
renderPdf() — the engine paginates. One pass, so it is faster, and it uses Forme's own Knuth-Plass line breaking and Fixed running regions. Page breaks will not match the editor, and page-number tokens resolve once rather than per page. Use it for throughput when neither matters.
@broadpaper/pdf (Chromium) | renderPdfPaginated() | renderPdf() | |
|---|---|---|---|
| Who paginates | BroadPaper | BroadPaper | Forme |
| Page count on the demo | 4 | 4 | 5 |
| Page numbers | Exact | Exact | Not resolved per page |
| Dependency | ~707 MB browser | ~7 MB WASM | ~7 MB WASM |
| Demo report | ~800 ms + launch | ~400 ms | ~250 ms |
| Edge / serverless | No | Yes | Yes |
| PDF/UA, PDF/A | No | Yes | Yes |
Neither engine promises to draw the same pixels as the other — they shape text with different code. What each one guarantees is the detail, including the conditions under which output is byte-for-byte reproducible.
What is translated
Every block renderer returns a VNode tree, so the translation happens once at that seam and covers custom blocks automatically — a block never knows which backend is running.
- Containers, rows and columns →
Viewwith flexbox / grid - Headings and text →
Heading/Text - Tables →
Tablewithis_headerrows, column widths, and headers repeated per page - Charts →
Svg, taking the vector markup our chart renderer already produces - Images and logos →
Image - Header and footer regions →
Fixedrunning regions, so page numbers repeat - Page breaks →
PageBreak
CSS with no equivalent in the engine is reported in warnings rather than dropped silently, so you can see exactly what a given template loses.
Fonts
Forme embeds and subsets the fonts in the theme you render with — the same theme.fonts the designer uses, so nothing is declared twice. Without any, it falls back to its standard faces, which changes metrics and therefore line breaks.
The engine reads TrueType and OpenType only, so give each face a .ttf or .otf among its sources; it skips WOFF and WOFF2 and warns about a face with nothing else. See Themes for the rules.
For a font the theme does not declare, fonts hands files to the engine directly, ahead of the theme's. src there accepts a path (Node), a data: URI or raw bytes:
fonts: [{ family: "Noto Sans Arabic", src: "./fonts/NotoSansArabic-Regular.ttf", weight: 400 }]Accessibility and archival conformance
await renderPdf({ ..., tagged: true, pdfUa: true, pdfA: "2b", metadata: { lang: "en-GB" } });Both need an embeddable font — a TrueType or OpenType face in theme.fonts, or in fonts. Anything missing is reported in warnings.
Measuring the canvas with the engine
By default the editor measures with the browser, which is instant and needs nothing extra. That leaves the canvas and the PDF laid out by two different engines, so a paragraph can break a line — and occasionally a page — in one place on screen and another in the file.
Pass createMeasurer and the canvas measures with the engine instead. Then a break you see is a break you get:
import { init, renderSerializedDocWithLayout } from "@formepdf/core/browser";
import { FormeEditorMeasurer } from "@broadpaper/forme";
let ready: Promise<void> | null = null;
<ReportDesigner
createMeasurer={() =>
new FormeEditorMeasurer({
render: async (doc) => {
ready ??= init();
await ready;
return await renderSerializedDocWithLayout(doc);
}
})
}
…
/>Design mode keeps measuring with the browser whatever you pass. Its badges, dashed outlines and drop targets are styled by the document stylesheet, which an engine outside the browser never sees and so cannot measure — and a canvas paginated against heights it does not draw clips its own content. Preview mode draws what the PDF draws, so that is where the engine measures and where the agreement matters.
What it costs: the WebAssembly module has to load before the first layout, and engine measurement is slower than reading the DOM. FormeEditorMeasurer caches by section content and sends only what changed, in one call rather than one per section, so an edit re-measures the section you touched and nothing else.
It measures in the active theme's fonts, read from theme.fonts like everything else, so the canvas and the file agree about where lines wrap. A face with no TrueType or OpenType source is reported through onWarning; the canvas draws it and the engine measures a standard face, and the two will disagree.
Rendering in the browser
The same module can produce the file, with no server at all. Supply onExportPdf and the editor's Export button hands the user a real PDF paginated by exactly the code that paginated the canvas:
<ReportDesigner
onExportPdf={async (ctx) => {
const { pdf } = await renderPdfPaginated({
template: ctx.template,
registry,
data: ctx.data,
theme: ctx.theme,
dataSources: ctx.dataSources,
renderer: async (doc) => {
ready ??= init();
await ready;
return await renderSerializedDocWithLayout(doc);
}
});
downloadBlob(new Blob([pdf.slice()], { type: "application/pdf" }), "report.pdf");
}}
…
/>This is what the demo does. It suits evaluation, internal tools and anywhere the data should not leave the browser. Render on a server when you need scheduled or bulk output, or when the data is not in the browser to begin with — the same call works in Node.
Bundler setup
@broadpaper/forme has a browser export condition, so a bundler resolving it gets a build that instantiates the engine's WebAssembly explicitly. Nothing above needs configuring for that: pass no renderer and the package loads the engine itself the first time you render.
Where the .wasm comes from is the one thing a bundler has to get right. Left alone, the engine asks for forme_bg.wasm next to its own module, which Vite and Webpack answer by emitting the file as a build asset. If yours does not — or the file is served from a CDN — say where it is, once, before the first render:
import { setFormeWasm } from "@broadpaper/forme";
setFormeWasm(new URL("/assets/forme_bg.wasm", location.origin));It takes a URL, a Response, raw bytes or a compiled WebAssembly.Module.
Why this is not the engine's default browser build. That one is wasm-pack's bundler target and instantiates at module load through import * as wasm from "./forme_bg.wasm". Angular's builder refuses that outright —
WASM/ES module integration imports are not supported with Zone.js applications— so reaching the engine through this package used to oblige an Angular application to go zoneless. Zoneless is a decision about your change detection, and a reporting SDK has no business being what forces it.
If you import @formepdf/core yourself, rather than letting this package load it, you are back to that build and your bundler must handle a .wasm import. For Vite:
import wasm from "vite-plugin-wasm";
export default defineConfig({
plugins: [react(), wasm()],
// The module initialises with a top-level await, which every browser we
// target supports natively.
build: { target: "esnext" },
optimizeDeps: { exclude: ["@formepdf/core"] }
});Webpack needs experiments.asyncWebAssembly. Next.js needs the same in its webpack config, and the import kept out of any component that renders on the server. Angular needs @formepdf/core/worker, which is what this package uses for you.
Current status
Supported and tested. Both backends agree on page counts across the test corpus — to within one page on a long document, which is the bound the parity test enforces — and the editor, when it measures with the engine, places every section on the same page as the PDF does. Chromium remains the fidelity reference the parity tests measure against, but nothing in the product needs a browser to lay out a page. See docs/ARCHITECTURE.md §15 for the decision record and the translation defects that had to be fixed to get there.
Licensing
Both backends render identically with and without a licence, with one difference: an unlicensed PDF carries a BroadPaper evaluation mark on every page. A small line in the bottom margin says where to get a key; in this backend it is a link. Set BROADPAPER_LICENSE, to a free 60-day evaluation key from broadpaper.com/eval or to a paid licence, and both disappear. Nothing else changes, nothing is gated, and the SDK makes no network call either way — see Licensing.