@otisk/preview
v2.4.0
Published
Browser raster-preview bindings for the otisk PDF engine: render_page_rgba / page_count for HTML5-canvas previews (wasm-bindgen). Preview only — emits no PDF; use otisk-server for print output.
Maintainers
Readme
otisk-wasm — WASM bindings for otisk
Thin wasm-bindgen wrapper around otisk::Engine, so browser (and other
wasm32-unknown-unknown) hosts can drive the otisk pipeline.
Two shapes ship from this crate (Issue 483). A default build exports PDF bytes (
render_pdf, below). The published@otisk/previewnpm package does not — it is built--no-default-features --features raster-previewand carries the canvas-preview surface only. WASM can't runlcms2, so a browser-emitted PDF would announce PDF/X-4 while carrying the preview's RGB approximation; the print PDF comes from the native path (otisk-server/ theotiskCLI). See Size budget for what the split costs, andcrates/otisk/tests/issue_483_preview_bundle_surface.rsfor the gate.
// Issue 30 baseline + Issue 115 widening: an **optional** 4th argument
// crosses a JS-side `AssetResolver` into the engine. The 3-arg call
// `render_pdf(html, css, margin)` remains byte-identical to the
// Issue 31 parity baseline; passing `null` / `undefined` for the 4th
// arg is equivalent. When present, `assets` must be either a bare
// `function(url): Uint8Array` callable or an object with a synchronous
// `fetch(url): Uint8Array` method — every engine-side `@font-face`,
// `<img>`, `background-image`, `@color-profile` lookup is funneled
// through it.
#[wasm_bindgen]
pub fn render_pdf(
html: &str,
css: &str,
margin_mm: f32,
assets: Option<JsValue>, // bindgen-side: omittable in JS
) -> Result<Vec<u8>, JsValue>;JS call sites:
// 3-arg legacy form — unchanged, empty resolver.
const pdf = render_pdf(html, css, 10);
// 4-arg widened form — supply a resolver. `null` / `undefined` are
// equivalent to omitting the arg.
const pdf = render_pdf(html, css, 10, {
fetch(url) { /* return Uint8Array */ }
});See src/lib.rs and the issue specs at
.plans/pdfx4-engine/issues/30-wasm-bindings-crate.md (baseline) and
.plans/pdfx4-engine/issues/115-wasm-asset-resolver-crossing.md
(resolver crossing) for the full rationale.
Build (browser bundle)
# Full surface — PDF export included. What the demo UI uses.
wasm-pack build crates/otisk-wasm --target web --out-dir ../../examples/web-ui/pkg --release
# Preview-only — exactly what CI publishes as `@otisk/preview` (Issue 483).
wasm-pack build crates/otisk-wasm --target web --out-dir pkg --release \
-- --no-default-features --features raster-preview--no-default-features is what drops pdf-export; a plain
--features raster-preview is additive and leaves the PDF exports in.
This produces examples/web-ui/pkg/otisk_wasm_bg.wasm plus the
otisk_wasm.js glue module. Serve the demo UI with any static server:
python3 -m http.server 4173 --directory examples/web-uiThe pkg/ output directory is git-ignored.
Build (raw .wasm, no JS glue)
For CI gate parity with the issue spec — no wasm-pack required:
cargo build -p otisk-wasm --target wasm32-unknown-unknown --locked --releaseArtefact at target/wasm32-unknown-unknown/release/otisk_wasm.wasm.
Size budget
SPECIFICATION.md §4.2: < 5 MB compressed (target; < 10 MB acceptable).
gzip is the number that matters — it's what npm reports and what CDNs serve
by default; brotli sits well under it but can't be relied on to be negotiated.
The shipped artefact is the @otisk/preview bundle: wasm-pack build --release
-- --no-default-features --features raster-preview (the tiny-skia raster
backend + the usvg/resvg/fontdb SVG stack, without the PDF emitter).
Measured 2026-08-21 on wasm-pack 0.13 / stable, gzip -9 and brotli -q 11:
| build | raw | gzip | brotli |
|---|---|---|---|
| default features (bindgen + pdf-export) | 9.50 MB | 4.64 MB | 2.99 MB |
| default + raster-preview (what was published before Issue 483) | 9.53 MB | 4.66 MB | 3.01 MB |
| --no-default-features --features raster-preview (pre-GH-#33) | 7.36 MB | 3.44 MB | 2.70 MB |
| --no-default-features --features raster-preview (with WebP, pre-GH-#62) | 7.42 MB | 3.46 MB | 2.70 MB |
| --no-default-features --features raster-preview (published, with WOFF2) | 7.69 MB | 3.57 MB ✅ | 2.79 MB |
The last row is newer than the rest: measured 2026-09-10 on wasm-pack
0.15 / rustc 1.98.1, with the same flags and the same gzip -9 /
brotli -q 11. Its base was re-measured on that toolchain too (see the
WOFF2 table below), so the delta it reports is like-for-like even though
the rows above it were taken on wasm-pack 0.13.
Two things that table says and the previous one could not:
- The raster backend is nearly free — 20 KB gzip on top of the default
build.
tiny-skiareuses the geometry the layout already computed. - The PDF emitter is not — dropping it takes 2.18 MB raw / 1.22 MB
gzip (−26 %) out of the published bundle, which moves
@otisk/previewfrom 4.66 MB to 3.44 MB against the §4.2 budget. Issue 483 was a correctness decision, not a size one, but the size followed it.
WOFF2 is the biggest single feature cost so far (GH #62) — the last
row above. Three builds on this recipe, all measured 2026-09-10 on
wasm-pack 0.15 / rustc 1.98.1, so the deltas are like-for-like:
| commit | what is in it | gzip | Δ vs base |
|---|---|---|---|
| eba810a4 | before the decoder | 3 476 780 B | — |
| 4f25c9b4 | decoder present, to_sfnt still refuses wOF2 | 3 477 047 B | +267 B |
| 36965291 | to_sfnt + FontFace::validate routed at the decoder | 3 573 147 B | +96 367 B / +94.1 KB |
| this branch | + the hardening of the adversarial review | 3 576 570 B | +99 790 B / +97.5 KB |
The middle row is the point of the split: an unreachable decoder is dead
code the linker drops, so the format work cost nothing until a door
called it. Wiring the doors is what pays for brotli plus the glyf /
loca / hmtx reconstruction — GH #29 measured +79 KB for
brotli-decompressor alone, so the reconstruction itself is ~15 KB on
top.
The decoder-side estimate written here before the wiring landed was
+94.7 KB; the measured number was +94.1 KB, 606 B under it. The
last row is the review hardening — a tighter decompression ceiling and a
Cow over the table slices, a checked_add on loca offsets, a bound
on the checkSumAdjustment write, a layout window for a transformed
glyf's declared length, and four refusals for undefined containers —
which costs +3 423 B gzip (+0.10 %), raw 7 690 650 B, brotli
2 786 586 B. The bundle sits at 72 % of SPECIFICATION.md §4.2's 5 MB
budget — as GH #29 said when it refused WOFF2 on outline-fidelity
grounds, size was never the decider.
PDF-as-image costs +27 KB gzip (GH #79). Reading a source PDF for
<img src="logo.pdf"> has to happen on both backends, not just the
print one: <img> sizing, object-fit and the aspect ratio are computed
in layout, so a PDF the print path can size and the preview path cannot
is a layout divergence (Issue 297). Measured 2026-09-13 on wasm-pack
0.15 / rustc 1.98.1, same recipe, one build immediately before the change
and one immediately after:
| build | raw | gzip | brotli |
|---|---|---|---|
| before otisk-pdfsrc | 7 749 614 B | 3 598 096 B | 2 802 748 B |
| with otisk-pdfsrc | 7 816 322 B | 3 625 847 B | 2 822 740 B |
| Δ | +66 708 B (+0.86 %) | +27 751 B (+0.77 %) | +19 992 B (+0.71 %) |
That leaves the bundle at 72.5 % of SPECIFICATION.md §4.2's 5 MB
budget, up from 72.0 %.
The number is small because the reader is small, and the reader is small
because it is not lopdf. lopdf 0.36 is already a workspace
dependency (behind compressed-objstm), and reaching for it was the
obvious move — but it does not build for this target at all:
$ cargo build -p otisk-pdf --features compressed-objstm \
--target wasm32-unknown-unknown
error: The wasm32-unknown-unknown targets are not supported by default;
you may need to enable the "wasm_js" configuration flag.
--> getrandom-0.3.4/src/backends.rs:194:17lopdf → rand 0.9 → getrandom 0.3, which hard-errors without a JS
backend cfg. Enabling that backend would put js-sys-shaped randomness
into a renderer whose contract is hermetic determinism, and it would
land on the cargo build --workspace --exclude otisk-server --target
wasm32-unknown-unknown gate that runs on every commit. So the reader is
otisk-pdfsrc: thiserror plus miniz_oxide (already linked for the
image tier's ICC recovery), and nothing else. The 27 KB is the parser
itself.
WebP costs almost nothing (GH #33). Enabling the image crate's webp
codec in otisk-image moved the published bundle by +68 314 B raw /
+12 795 B gzip (+0.37 %) / +863 B brotli — 3 444 838 → 3 457 633 B gzip,
measured on 2026-09-09 with the same recipe, one build immediately before
the change and one immediately after.
The reason it is that cheap is that the decoder was already in the
bundle: resvg (pulled by otisk-page's raster-preview feature, for
the SVG preview path) depends on image-webp directly, so a nested SVG
<image href="*.webp"> already decoded while a top-level HTML <img> did
not. Cargo unifies to one copy of image-webp, so what the webp feature
adds is the image crate's thin ImageDecoder shim over it, not a codec.
At the time it was measured the bundle sat at 69 % of the §4.2 5 MB
budget, unchanged to the percentage point; the WOFF2 row above moved
that to 71 %.
The size comes from the Cargo release profile, not wasm-opt — wasm-pack
already runs wasm-opt -O, and forcing -Oz moves gzip by < 0.1%. The lever is
the workspace [profile.release] (root Cargo.toml): opt-level = "s",
lto = true, codegen-units = 1, strip = true. Without it (cargo defaults:
no LTO, codegen-units = 16, symbols retained) the raster-preview bundle is
~5.09 MB gzip — over budget. The native otisk binary opts out of these size
settings via its own otisk-release profile (see the profile rationale in the
root Cargo.toml), so shrinking the bundle never costs native throughput.
Host-target build
The crate is also exposed as an rlib so cargo build -p otisk-wasm
on the host triple works (useful for cargo doc, future host-side
integration tests, and IDE indexing). The cdylib artefact only matters
for the wasm32-unknown-unknown target.
parity_export feature (Issue 31)
The crate ships a second WASM entry point — a raw C-ABI
render_pdf_raw — gated behind the parity_export feature. The Phase 2
parity harness (crates/otisk/tests/wasm_parity.rs) builds the artefact
with --no-default-features --features parity_export and drives it
through wasmtime without the wasm-bindgen JS glue. This surface is
internal — the wire format is not a stability contract, and browser
hosts should always use the default render_pdf.
cargo build -p otisk-wasm --target wasm32-unknown-unknown \
--no-default-features --features parity_export --release