@quillmark/wasm
v0.124.0
Published
WebAssembly bindings for Quillmark, a schema-driven document engine
Readme
Quillmark WASM
WebAssembly bindings for Quillmark.
Maintained by TTQ.
Overview
Quillmark in browsers and Node, over explicit in-memory trees
(Map<string, Uint8Array> / Record<string, Uint8Array>).
The package has one import surface: @quillmark/wasm, whose init resolves to
Quill and Document, plus an Engine that renders them.
Quill and Document are the internal Typst-less core build's own classes,
handed out verbatim by init, so editor/validation code (Quill.fromTree,
Document.fromMarkdown) loads only that small core binary: no backend is
loaded until you render. The Engine hides everything else: the render build,
carrying both backends (typst, acroform), is a private WASM binary with its
own linear memory, lazily loaded on the first render. The Engine clones a
Quill / Document into that memory as data and frees the clones: you never
hold a backend object or cross a memory boundary yourself.
Build
bash scripts/build-wasm.shThe script builds two variants, the core (no backend) and the render build
(default features, both backends), each with --target web and --weak-refs
enabled (see
Initialization and Lifecycle). It then asserts
none of them carries a .wasm ESM import or a top-level await.
Test
bash scripts/build-wasm.sh
cd crates/bindings/wasm
npm install
npm testUsage
import { init, Engine } from "@quillmark/wasm";
const { Quill, Document } = await init(); // see Initialization
const quill = Quill.fromTree(tree); // no engine needed: build + validate
const engine = new Engine(); // loads a backend lazily on first render
const markdown = `~~~
$quill: my_quill
$kind: main
title: My Document
~~~
# Hello`;
const parsed = Document.fromMarkdown(markdown);
const result = await engine.render(quill, parsed, { format: "pdf" });Initialization
init resolves to the core surface: Quill, Document, and the free
functions. Everything after the await is the synchronous surface the rest of
this README describes.
import { init, Engine } from "@quillmark/wasm";
const { Quill, Document } = await init();The same line works everywhere: the binary streams from a URL in a browser and
is read off disk under Node, chosen by the package's #quillmark-env subpath
import rather than a runtime environment check. No bundler plugin is required:
the builds are --target web, so nothing in the package graph imports a .wasm
module or carries a top-level await, and a static import of this package is
safe anywhere, SSR included.
init is idempotent and concurrency-safe: every non-conflicting call returns
the same promise, so several entry points may each await init() for one
instantiation. Destructure at every entry point (route loader, hydration
path, worker) rather than threading one result around. A failed init clears the
memo, so a retry works. Each realm initializes its own copy, a Worker included.
Backends need nothing. Engine instantiates a backend inside its lazy load,
on the first render against it.
Overriding the source. init(source) accepts bytes, a Response, a
WebAssembly.Module, or a URL, for hosts that route assets themselves or embed
the binary. Pass it on the first call; a later call passing a different source
rejects with runtime::init_conflict rather than silently ignoring it. Passing
the same value again is fine, so several entry points may each
await init(BYTES) against one constant.
Both failures reject. runtime::init_conflict and runtime::init_failed
alike ride the returned promise, so one catch around await init(...) covers
the gate. The core surface has no static export, so a call site that skips the
await has no name to call.
Vite's dev server pre-bundles dependencies, which moves the package away from its binary. Exclude it:
// vite.config.js
export default { optimizeDeps: { exclude: ["@quillmark/wasm"] } };A load failure surfaces as runtime::init_failed, whose hint names that line.
API
new Engine(options?)
Create the render dispatcher. Routes each quill to its backend by
quill.backendId, lazily loads that backend binary, and renders: cloning the
quill/document into the backend's memory and freeing the clones internally.
render, open, load, and supportedFormats are async (the first call
may load a backend). Pass { backends } to register or override backend descriptors.
Each entry is a descriptor ({ [backendId]: { load, formats } }) where load
is the lazy thunk returning the backend module and formats is the
required static capability manifest. A malformed descriptor throws at
new Engine(...), naming the backend id, and so does any options key other
than backends.
The format probe is always free. supportedFormats depends only on
quill.backendId, and answers from the descriptor's required formats
manifest: never loading the multi-MB backend binary and never cloning the
quill. Use it as a non-failing pre-render probe.
An editor does not wait on the render build. open reads the document
before it awaits the backend load, so a session opened while the build loads
compiles the document as it stood at the call. Mount the editor on core, start
the load, and open once it lands:
void engine.load(quill); // start the fetch as soon as the quill resolves
mountEditor(quill, doc); // core alone
await engine.load(quill); // memoized: the same load
const session = await engine.open(quill, doc); // one compile, current documentload reads only quill.backendId and clones nothing. A failed load rejects
with runtime::backend_load_failed, and the next call retries.
The two doors: Document.fromMarkdown vs quill.parse / quill.conform
Document.fromMarkdown is the quill-free transport door (migrations, $ext
stamping, a quill that will not load, opening a document to fix its $quill).
It needs a root ~~~ block carrying a $quill line, and a content field rests
as authored.
quill.parse is the bound door, and the primary ingestion path. It is
Document.fromMarkdown followed by conform: the returned document's declared
content fields rest at one form per codec (a richtext field as the canonical
content object, a plaintext field as its literal string), so getStored
answers "content object or string?" by the field's declared codec rather than by how the
document was built. Parse warnings and the conform::* warnings both ride
doc.warnings.
quill.conform(doc) is the same walk in place on a document that arrived any
other way (fromStored, a stored row), returning the conform::* Diagnostic[]
([] when everything rested). It is idempotent and a byte no-op on an
already-canonical document, YAML comments included, so calling it on every load
is safe. A value the strict write refuses stays as authored under a warning. Both throw
when the document declares a $quill this quill does not answer to, before any
mutation.
const doc = quill.parse(markdown); // rests canonical
const stale = Document.fromStored(row);
const diags = quill.conform(stale); // converges in placeStorage compatibility across versions
Persist doc.toStored(), not doc.toMarkdown(): the DTO wire format is frozen
per schema version, whereas Markdown syntax evolves, and toMarkdown output
is normalised rather than byte-equal to the source. Document.storageVersionOf
discriminates the two formats without exceptions as control flow: it answers
undefined for anything that is not a storage DTO.
const doc = Document.storageVersionOf(content)
? Document.fromStored(content)
: Document.fromMarkdown(content);The schema value (quillmark/[email protected]) is the model version,
not the running crate version. It is a hand-set constant, bumped only when
the Document model itself changes, so every 0.124.x patch release reads
and writes that same value.
- Upgrading is safe. A newer build reads documents an older build's
writer produced. Each schema version's wire format is frozen and never
changes; when the model does change, the new build ships a migration that
converts old payloads on
fromStored. A document you commit as your canonical on-disk format keeps loading across crate upgrades: there is no need to pin old wasm to read old data. The exception is a row a host authored a content construct of its own into — a linekind, container, marktypeor islandtypeoutside the vocabulary. Those are refused from 0.113 on; see that release's migration guide. An islandlossis not one of them: 0.115 drops the key, so a row spelling one opens whatever the class and comes back without it. - Downgrading is not.
fromStoredrejects an unknown (i.e. newer)schemaversion rather than guessing at a format it predates. Don't feed documents written by a newer build back into an older one.
To detect a version mismatch before parsing, use the static accessors:
const v = Document.storageVersionOf(blob); // undefined | string
if (v && v !== Document.currentStorageVersion()) {
// payload is from a build with a different model version
}storageVersionOf does not validate the payload: it only reads the
schema field, returning undefined for non-JSON, non-objects, or
payloads that don't carry one. Use it to distinguish "wrong version" from
"corrupt" when fromStored throws.
In short: persist the toStored string, upgrade freely, never downgrade. The
full design (including how migrations are added) is in
prose/canon/DOCUMENT_STORAGE.md.
Cards, seeds, and addresses
To render a form editor, read field definitions from quill.schema (walk
fields in key order: declaration order is display order) and the authored
values from the Document payload: there is no separate form-view projection.
quill.validate(doc) scores it without invoking the backend.
quill.exampleDocument() returns the quill's example, a filled-in page whose
values are made up, pinned to the quill's name@version; undefined when
the quill has no example.md at its root.
quill.seedDocument() returns a starter document: one card per kind, each
carrying the kind's seed from Quill.yaml and no other field;
quill.seedMain() and quill.seedCard(kind) seed one card. All
return the read Card shape of doc.main / doc.cards, which doc.insertCard
accepts directly:
doc.insertCard(quill.seedCard("note")); // seed → append
doc.insertCard({ kind: "note", body: "Plain **markdown**." }); // bare inline
doc.insertCard({ kind: "note" }, 0); // insert at index 0
doc.insertCard({ // fields → payload items
kind: "note",
payloadItems: [{ type: "field", key: "x", value: 1 }],
});Reads and writes are two aligned shapes. A read Card always has body:
Content (canonical content, never a raw string): no narrowing, no guessing
whether the body was normalized. The write shape CardInput widens body to
Content | string (a markdown string imports to the content) and makes every
field but kind optional. Every Card is a valid CardInput, so insertCard
still takes exactly what cards / removeCard / seedCard return. A fresh
card is an object literal: one { type: "field", key, value } per field in
payloadItems, in the order they should appear.
One address for the whole surface. Reads and writes navigate by an Addr:
{ card?, field? }, absent card = main, absent field = body, and a bare
string is shorthand for { field }. So doc.storeField("qty", 3) targets the
main card's qty, doc.storeField({ card: 2, field: "qty" }, 3) a composable
card's. Reads are total over the field axis (getStored → undefined for
an absent field; only an out-of-range card throws); field writes throw on a body
address. getStored is the verbatim transport read, distinct from the interpreted
quill.reader(doc).get; bodyMarkdown is the body markdown read (a CardAddr; a field's
markdown is read through quill.reader(doc).get(field)). A content field's stored
form rests per its declared codec through the bound door (a richtext field as
the content object, a plaintext field as its literal string), and as authored
through the transport door. For the Content either way read
quill.reader(doc).getContent(addr), which decodes through the codec the
field's declared type names. Card-scoped verbs take a
CardAddr ({ card? }) first: doc.getExt({ card: 2 }), and the batch below.
Batch mutation: doc.storeFields({}, {...}) / doc.storeFields({ card: index }, {...})
apply a whole object atomically: on any invalid field nothing is applied and
the thrown error carries one diagnostic per offending field (path = field
name). The address is first (never shape-overloaded, since card is a legal
field name), and parses strictly: a stray key throws rather than silently
reading as {}. The main card is {}, or MAIN_CARD_ADDR (from
@quillmark/wasm), a frozen alias that spells the intent:
doc.storeFields(MAIN_CARD_ADDR, {...}).
Typed writes: commit* is the default, store* is the quill-free primitive
A Document holds only a $quill reference, not the resolved schema, so typed
writes go through the schema-bound writer while the quill-free opaque store sits
on Document itself (store = verbatim, set = typed):
quill.writer(doc): the typed door whenever a quill is in hand. Bind the schema once and issue bareset/setAll/reviseBody/reviseField/addCard/card(i). Each resolves the field's schematype, coerces the value to its canonical form ("3"→3, a markdown string → a richtext content), and fails now on a mismatch instead of at render. A name the schema does not declare throwsUnknownFieldrather than falling to the opaque store: on the typed path an undeclared name is a typo, not a fallback. The batch form (setAll) is all-or-nothing: an undeclared name aborts the whole write and its per-field diagnostics name every offending field, so a whole-form submit surfaces every typostoreFieldswould silently absorb. (The raw wasm class carries the quill-taking_commitField/_commitFields/_addCard/_reviseFieldABI the writer delegates to, hidden from the.d.ts.)store*: the deliberate quill-free primitive.doc.storeField(addr, value)/doc.storeFields(cardAddr, {...})validate only the field name/depth/kind and store the value verbatim, no quill required. Reach for it on purpose when you want the opaque store: quill-agnostic storage/migration infra that has no bundle and must write regardless of a drifted schema; store-now-validate-later editors holding in-progress input thatcommitwould reject; or verbatim passthrough of fields the schema doesn't own. It is the lower layer, not a lightercommit: a typo'd field name stores silently and only surfaces atquill.validate/ render.
Per-keystroke cost is the same either way (both mutate the in-memory Document
in place; no seam is crossed), so steering to the writer buys the type check for
free.
DocumentWriter / CardWriter: bind the quill once
quill.writer(doc) binds the quill's schema to the document once, so a form
editor or MCP writer that holds both issues bare verbs (the writer forwards to
the per-call _commit* ABI):
const ed = quill.writer(doc); // Rust `quill.writer(doc)` twin; new DocumentWriter(quill, doc) also works
ed.set("subject", "Q3 results"); // strict-committed to the schema type
ed.setAll({ qty: "3", subject: "Q3" }); // all-or-nothing batch
ed.reviseField("subject", "Q3 **results**"); // typed AND anchor-preserving; returns { delta, warnings }
ed.set("titel", "x"); // throws UnknownField: a typo, not a fallback
ed.card(2).set("body", "**note**"); // composable card, resolved by its $kindDocumentWriter / CardWriter are pure JS holding references to your existing
quill and doc: no WASM handle of their own, nothing to free(). card(i)
is lazy: it never throws; an out-of-range index throws IndexOutOfRange at the
write.
DocumentReader / CardReader: the read twin
quill.reader(doc) carries the writer's ephemerality and schema authority:
const v = quill.reader(doc);
v.get("subject"); // the values form: every content leaf as its codec's text, else as stored
v.getContent("subject"); // the same read as a `Content`, whichever lane stored it
v.bodyMarkdown(); // the main body markdown (quill-free)
v.card(0).get("body"); // a card field, resolved by its $kind
v.resolve(); // the render view: blank-filled, coerced, each field tagged with its rungget projects and getContent returns the Content; both decode through the codec
the field's declared type names, which is why they bind the quill and the
verbatim doc.getStored does not. An undeclared name throws UnknownField, a
type that is not a content leaf throws FieldNotContent, and an undecodable
value throws FieldDecode; an absent field reads back undefined and a
present-null null. A read never coerces a scalar (qty: "3" reads "3");
resolve() is the coerced view.
engine.render(quill, parsed, opts?, today?) vs. engine.open(quill, parsed, today?)
Use engine.render for one-shot exports (PDF/SVG/PNG): compiles, emits
artifacts, done. Use LiveSession (returned by engine.open) for
reactive previews: the session is a persistent compiler. paint / render /
regions / fieldAt read its current compile without recompiling, and update(doc)
recompiles in place on each edit, returning a ChangeSet whose dirtyPages
tells you which pages to repaint (dirty ∩ visible). update is transactional:
on throw, every read keeps serving the last-good compile. Don't open a session
per export, and don't re-open per edit: update instead.
Both take the render date as an optional last argument (YYYY-MM-DD, default
the local date): what a today date field and a plate's datetime.today()
render as. A session keeps the date it opened with, so a preview left open past
midnight renders yesterday's until it is reopened.
opts takes format, ppi, pages and regions, on engine.render and
session.render alike. Any other key throws rather than reading as absent,
today included, so engine.render(quill, doc, { today }) rejects instead of
rendering the local date. The date goes last:
await engine.render(quill, doc, { format: "pdf" }, "2026-03-14");A document that compiles to zero pages still produces a valid session
(pageCount === 0); paint(ctx, 0, scale) and pageSize(0) then throw. Branch on
pageCount === 0 to render a "no pages to preview" UI rather than relying on
the throw.
Their warnings differ in reach. engine.render returns every
quill.validate(doc) warning but validation::declined_construct, which the
compile raises as backend::declined_construct, then the compile's own;
session.render and session.warnings carry the compile's alone, so read
quill.validate(doc) beside them. Neither carries the load's: those stay on
doc.warnings, and a revise's on its receipt.
Canvas Preview
session.paint(ctx, page, scale) rasterizes a page directly into a
CanvasRenderingContext2D (main thread) or
OffscreenCanvasRenderingContext2D (Worker), skipping PNG/SVG byte
round-trips.
scale is backing-store pixels per point. The painter owns canvas.width /
canvas.height, reducing scale where it must so neither exceeds 16384 px;
consumers own canvas.style.*. A canvas styled to fill its page box
needs nothing back from the paint:
canvas.style.width = "100%"; // the box sets the display size
const cssPxPerPt = canvas.clientWidth / session.pageSize(0).widthPt;
if (cssPxPerPt > 0) { // 0 while the canvas has no layout box
session.paint(canvas.getContext("2d")!, 0, cssPxPerPt * window.devicePixelRatio);
}- Fold
devicePixelRatio, in-app zoom, andvisualViewport.scaleintoscale. - A canvas with no layout box (
display: noneon it or an ancestor, detached, in a zero-width container) hasclientWidth0, and a 0 scale throwsbackend::invalid_raster_scale: skip the paint, and paint when aResizeObserverreports a width. paintwrites the whole backing store withputImageData, which ignores the 2D context transform,globalAlpha, and clip. Give each visible page its own<canvas>: no compositing, sub-rect, or transform reaches throughpaint.paintis always a full repaint, and there is no per-page raster cache. Keep a page's canvas alive while it stays near the viewport: an idle canvas retains its pixels for free, whereas pooling one canvas across pages re-renders on every scroll.pageCountandpageSize(page)read the current compile, not the session: cache them between committedupdates only. After one, the count isChangeSet.pageCount, and every page inChangeSet.dirtyPagesneeds itspageSizere-read.- In a Worker, pass an
OffscreenCanvasRenderingContext2D. AnOffscreenCanvashas no layout box to measure, so the main thread posts the scale. Loading the WASM module inside the Worker is the host's responsibility. paint/pageSizethrow on a page the compile does not have, a zero-page compile included, naming the index and thepageCountthat excludes it. That throw is the whole contract: open the session and handle it.
Schema model
A field declares no required key: nothing is required.
default: — what an unanswered field renders. With one, quill.blueprint
renders that value under a type-only # <type> annotation and the render path
uses it when the document omits the field. Without one, the blueprint leaves the
cell empty and an absent field blank-fills.
An unanswered field draws no diagnostic. Partial documents are accepted, and
engine.render(quill, doc) throws only for malformed input.
Errors
Every method that can fail throws a QuillmarkError: a JS Error with
.diagnostics attached. The type and a guard are exported from the root:
import { isQuillmarkError, type QuillmarkError } from "@quillmark/wasm";
try {
const result = await engine.render(quill, doc);
} catch (e) {
if (isQuillmarkError(e)) {
for (const d of e.diagnostics) console.error(d.severity, d.message);
} else {
throw e; // not a quillmark failure: programming error, re-throw
}
}Delivery follows the function, not the failure. A synchronous method throws;
a promise-returning one rejects. The promise-returning surface is init and the
four Engine verbs (render, open, load, supportedFormats), so a programming
error reached through one of them (a foreign handle, an unregistered backend)
rejects like any other failure. Nothing here both returns
a promise and throws, so a .catch on a promise-returning call is a whole
guard.
QuillmarkError is a structural interface, not a class: the WASM layer
throws a real Error and attaches the property, so there is no constructor to
instanceof against. Narrow with isQuillmarkError, which also works on errors
from any build or WASM instance in the page.
diagnostics is always non-empty: length 1 for most failures, length N for
backend compilation errors, and message is derived from it. diagnostics[0]
is an error. A failed Typst engine.render or engine.open also carries the
quill's load warnings after its errors, so read each entry's severity. The
same shape applies to every throw site:
Document.fromMarkdown: parse errors (missing root$quillmetadata, YAML errors,parse::input_too_largefor inputs > 10 MiB).Documentmutators (storeField,insertCard, the writer'sset, etc.): mutator failures carry a namespacededit::*codeondiagnostics[0](edit::invalid_field_name,edit::unknown_field,edit::index_out_of_range,edit::field_coercion_failed, …). Route ondiagnostics[0].code, never on message text.engine.render/session.render: backend compilation failures and validation errors.- An object argument carrying a key its verb does not read: the render
options,
new Engineoptions, anAddr, aCardInput. Every own string key counts, a non-enumerable one and one holdingundefinedincluded. The argument must be a plain object, its prototypenull,Object.prototypeof any realm, or a null-prototype object whose keys count too, so aMap, a class instance orObject.create({ … })throws. A payload item, the fields objectstoreFieldstakes, and a$extor$seedvalue read their own enumerable keys alone: a non-enumerable or inherited key there is neither read nor refused. The diagnostic names the key or what was passed, and carries nocode: it is a call site to fix, not a condition to route on. engine.render(quill, parsed)against a quill whose name differs (quill::name_mismatch) or whose version falls outside the document's selector (quill::version_mismatch): a throw, never a warning.- The four
Engineverbs against a quill whose declaredbackend:is not in the registry:engine::backend_not_found, the code core raises for the same condition, hinting the registered ids. - The
Engineverbs,session.update, and the writer/reader binds against a value that is not one of this copy's handles — the wrong type, or the right class from a second copy of@quillmark/wasm:runtime::not_a_quill/runtime::not_a_document, hintingnpm ls @quillmark/wasmfor the second case. Two copies are two WASM memories and twoQuill/Documentclasses; dedupe to one. Elsewhere a foreign handle meets wasm-bindgen's ownexpected instance of …, which is not aQuillmarkError.
Lifecycle
Handles begin at init, which instantiates the core build;
Engine instantiates a backend on the first render against it.
The wasm bindings are built with --weak-refs, so dropped Document,
Quill, and LiveSession handles are reclaimed by FinalizationRegistry
without manual .free() discipline. .free() is still emitted as an eager
teardown hook for callers that want deterministic release.
engine.render and engine.open read the quill and doc handles
synchronously, before their first await, so freeing a handle as soon as the
call returns: try { return engine.render(quill, doc); } finally
{ doc.free(); }: is safe even on the first render, while the backend
binary is still loading.
The package floor is Node 24+ (engines: { node: ">=24" }) and current
evergreen browsers; --weak-refs itself only needs Node 14.6+. The using
sugar (explicit resource management) is on that floor and optional;
an explicit try / finally is the equivalent, and the form that also runs
in a browser that hasn't shipped it:
const session = await engine.open(quill, doc);
try {
for (let p = 0; p < session.pageCount; p++) {
session.paint(canvases[p].getContext("2d")!, p, scale);
}
} finally {
session.free();
}Changelog
See the changelog and the GitHub Releases page for release notes and version history.
License
Apache-2.0
