@otisk/preview
v3.1.0
Published
Browser bindings for the otisk PDF engine: render_page_rgba / page_count for HTML5-canvas previews, and render_document_pdf for sRGB PDF/A-2b documents (wasm-bindgen). For CMYK / PDF/X-4 print output use otisk-server.
Maintainers
Readme
@otisk/preview
Typeset HTML and CSS into pages in the browser or in Node: a pixel-exact
preview of each page for a <canvas>, a page count, and a PDF document
(sRGB, PDF/A-2b). It is the otisk engine compiled to WebAssembly, so a
preview here and a PDF from otisk-server come out of the same layout.
This package makes screen and archive documents. CMYK PDF/X-4 print
masters come from otisk-server.
Install
npm install @otisk/previewThe package is an ES module. Call its default export once to load the WebAssembly before anything else.
A page preview in the browser
import init, { render_page_rgba } from "@otisk/preview";
await init(); // fetches otisk_wasm_bg.wasm next to the module
const html = `<h1>Invoice 2026-09-04</h1><p>Thank you for your order.</p>`;
const css = `@page { size: A4; margin: 15mm } h1 { font-size: 24pt }`;
const page = render_page_rgba(html, css, null, null, null, 0, 2); // page 1 at 144 dpi
const canvas = document.querySelector("canvas");
canvas.width = page.width;
canvas.height = page.height;
canvas
.getContext("2d")
.putImageData(new ImageData(new Uint8ClampedArray(page.rgba), page.width, page.height), 0, 0);
console.log(`${page.pageCount} pages`, page.diagnostics.map((d) => `${d.code} ${d.message}`));A PDF in Node
examples/node.mjs in this package is the runnable version:
import { readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import init, { render_document_pdf, page_count } from "@otisk/preview";
const wasm = createRequire(import.meta.url).resolve("@otisk/preview/otisk_wasm_bg.wasm");
await init({ module_or_path: readFileSync(wasm) }); // Node has no fetch for file URLs
const html = `<h1>Hello</h1>`;
const css = `@page { size: A5; margin: 20mm }`;
console.log(page_count(html, css, null, null, null), "page(s)");
writeFileSync("hello.pdf", render_document_pdf(html, css, null, null));HTML and CSS
Every call takes the document as html and a stylesheet as css:
<style>elements in the document apply, in document order, as in a browser. A<style media="screen">block is skipped (this is a print engine);<style media="print">applies. One difference from a browser: a<style>inside inline<svg>styles only that SVG.cssapplies after them, so on equal specificitycsswins. Use it for a theme or overrides you keep outside the document. Pass""when the document carries all its CSS.<link rel="stylesheet">is not loaded. It raises the noticeW0040instead (an info-level diagnostic, never an error). Inline the file in a<style>element or pass its text ascss. If you link a file for a browser preview and pass the same text ascss, that is fine; the notice just confirms the link was not fetched.@importworks, inside<style>and incss, through theassetsresolver below.style="…"attributes apply, above every rule, as in a browser.
html can be a whole document or just a body fragment.
Page size and margins
Page size comes from @page { size: … } (A4 portrait when the CSS sets
none). Margins come from one of two places:
| margin_mm | Margins used |
|---|---|
| null or undefined | the document's @page { margin: … }, or 25 mm on every side if it sets none |
| a number | that many millimetres on all four sides, overriding @page |
So if your CSS sets the margins, pass null. A number wins over the CSS,
which is what you want when one template should render with different
margins, and a surprise when your layout was computed for the @page
margins: a page that sizes itself as calc(297mm - 2 * 15mm) overflows if
you pass 25. OtiskEngine takes the same value in its constructor.
Fonts and images
The engine bundles Noto Sans (with Latin, Hebrew, Arabic and CJK fallbacks)
and uses it for any family it cannot find. Everything else comes through
the assets argument: a function (url) => Uint8Array, or an object with a
fetch(url) method.
const files = {
"fonts/Inter-Regular.woff2": interRegularBytes,
"img/logo.svg": logoBytes,
};
const assets = (url) => files[url]; // return undefined for anything you don't have
render_page_rgba(html, css, null, assets, null, 0, 1);- It is called synchronously for every
@font-facesrc,<img src>,url()in CSS and@import, with the URL exactly as written. Load the bytes before you render (it cannot return a Promise). data:URLs never reach it; they are decoded inline.- Fonts: TTF, OTF, TTC, WOFF, WOFF2. Images: PNG, JPEG, WebP, SVG.
- A miss is not an error: the render goes on and reports it (
W0004for a font,W0011for an image).
To use a font in many renders without @font-face, register it on a
handle:
import { OtiskEngine } from "@otisk/preview";
const engine = new OtiskEngine(null);
engine.tryRegisterFont("Inter", interRegularBytes); // throws if the bytes are not a usable font
const page = engine.renderPageRgba(html, css, null, 0, 2); // font-family: Inter just worksDiagnostics
A render does not stop at the first problem. It sets the page with what it
has and lists what went wrong in page.diagnostics; each entry has code,
severity, message and, for CSS, line and column. Branch on code:
| Code | Meaning |
|---|---|
| W0003 | an unknown or unsupported CSS property was ignored |
| W0004 | a font was not found; the bundled face was used |
| W0011 | an image could not be loaded or decoded |
| W0040 | (info) a <link rel="stylesheet"> was not loaded |
render_page_rgba throws a string when it cannot return a page at all.
render_page_rgba_bounded throws a RasterFailure with kind, message,
diagnostics and the page count instead, and refuses a page above a pixel
budget (raster_too_large: lower scale and retry). render_document_pdf
and page_count do not return diagnostics; render a page preview to see
them.
What is supported
Paged media is the point: @page size and margins, margin boxes (running
headers and footers, page numbers), page breaks, footnotes, counters and
cross-references. Layout: block, inline, flex, grid, tables, floats,
multi-column, absolute positioning. Text: full OpenType shaping, kerning and
ligatures, hyphenation, justification, text-wrap: balance and pretty,
right-to-left scripts. Colour: CSS colours, gradients, shadows, masks,
opacity, CMYK and spot colours (previewed through output_intent).
Images: PNG, JPEG, WebP, SVG (as images and inline).
Not supported, by design: JavaScript in the document, <link
rel="stylesheet">, interactive CSS (:hover, cursor, transitions,
animations), and anything that needs a network: every external file comes
through assets.
The canvas preview approximates a few things the PDF draws exactly: a
transformed element and an opacity group paint without their transform
or group alpha, and dashed or dotted borders paint solid.
Common mistakes
- Passing a number for
margin_mmwhen the CSS sets@pagemargins. The number wins. Passnull. - Expecting
<link rel="stylesheet">to load. Inline it or pass it ascss. - An async resolver.
assetsmust return bytes, not a Promise. Fetch first, then render. - Testing the same HTML in a browser. Browsers give
bodyan 8px margin; otisk gives it none, so the page area is exactly the page minus its margins. A layout sized to fill the page area fits here and overflows by 16px in a browser's print preview. Setbody { margin: 0 }if both should agree. - Forgetting
await init(). Every function throws until the module has loaded.
Licence
Apache-2.0 for this package (see LICENSE and NOTICE). The bundle
includes third-party components listed in THIRD-PARTY-NOTICES.md.
