Skip to content

0.89.0 → 0.90.0 — engine-free Quill, split WASM, one core type

A structural release that decouples holding a quill from rendering one, and splits the WASM package so an editor can load and validate without downloading Typst.

Three connected changes:

  1. Quill is engine-free data. It no longer holds a backend. A new Engine is the render dispatcher; rendering and backend-dependent capability move onto it. The backend-existence check moves from load time to render time.
  2. The WASM package ships split builds behind one canonical API. There is exactly one public import — the root @quillmark/wasm. It is the canonical layer: it re-exports Quill / Document from the internal Typst-less core build verbatim and adds an Engine that lazily loads the Typst backend on first render. Engine-free editor/validation code (Quill.fromTree, Document.fromMarkdown) loads only that small core binary (~0.66 MB gzip); no backend is loaded until you render.
  3. QuillSource and Quill collapse into one core type, Quill (Rust surface only — bindings already hid QuillSource).

WASM / JS

Construction: the engine no longer loads quills

engine.quill(tree) is removed. Construct a quill directly — no engine needed:

- import type { Quill } from '@quillmark/wasm';
+ import { Quill } from '@quillmark/wasm';   // value import — needed for Quill.fromTree

- const engine = new Quillmark();
- const quill = engine.quill(tree);
+ const quill = Quill.fromTree(tree);

Quill (and Document) are re-exported verbatim from the internal core build, so the single root @quillmark/wasm import is the only place you get them — no backend is loaded to construct or validate a quill.

Note: Quill must be a value import (not import type { Quill }). Projects using verbatimModuleSyntax or aggressive type-stripping will hit a compile error on the static call site until the import is changed.

Quill.fromTree accepts the same Map<string, Uint8Array> (or plain object) as before. It is the full parse-and-validate step — it throws on a malformed or schema-invalid tree. A Quill is now portable, validated data: backendId, metadata, schema, blueprint, validate, seedDocument / seedMain / seedCard all work without an engine.

Testing note: Code that previously mocked engine.quill(tree) to return a fake quill must now stub the static constructor directly. With a single public entry point there is exactly one Quill class to spy on — the wrong-build vi.spyOn trap no longer exists:

import { vi } from 'vitest';
import { Quill } from '@quillmark/wasm';

const spy = vi.spyOn(Quill, 'fromTree').mockImplementation((tree) => {
  return fakeQuill as unknown as Quill;
});

Rendering and capability move onto the engine

render, open, and capability are no longer methods on Quill; they live on the new Engine and take the quill as the first argument. The Engine methods are async — the first call lazily loads the backend binary:

- const result  = quill.render(doc, opts);
- const session = quill.open(doc);
- const canPreview = quill.supportsCanvas;
+ import { Engine } from '@quillmark/wasm';
+ const engine  = new Engine();
+ const result  = await engine.render(quill, doc, opts);
+ const session = await engine.open(quill, doc);
+ const canPreview = await engine.supportsCanvas(quill);

RenderSession is unchanged: session.render(opts), pageSize, paint, pageCount, backendId, supportsCanvas, warnings. (engine.open is now async, but the session methods it returns stay synchronous.)

supportedFormats left metadata

Quill.metadata is now pure config (identity only). The backend's output formats are a resolved-backend capability — read them from the engine:

- const formats = quill.metadata.supportedFormats;
+ const formats = await engine.supportedFormats(quill);   // OutputFormat[]

The [key: string]: unknown index signature has been removed from QuillMetadata. Code that reads quill.metadata.supportedFormats now produces a compile-time error (Property 'supportedFormats' does not exist on type 'QuillMetadata') instead of silently returning undefined at runtime.

If you genuinely need to read arbitrary extra quill: YAML keys, cast to Record<string, unknown> (e.g. (quill.metadata as Record<string, unknown>).myKey) as the escape hatch.

Backend errors surface at render time, not load time

Quill.fromTree now succeeds even when the declared backend isn't available (the core build has no backend at all). The UnsupportedBackend error moves to the first engine call — open / render / supportedFormats / supportsCanvas.

One entry point

// Everything: Quill/Document (schema + validation, no backend loaded) plus
// the Engine that renders them.
import { Document, Quill, Engine } from '@quillmark/wasm';

The package exports map has exactly one subpath (.) — there is no /core or /render subpath, and the root no longer exports a Quillmark class. Quill and Document are re-exported verbatim from the internal Typst-less core build, so engine-free editor/validation code loads only that small core binary; the Typst backend is a separate private build with its own linear memory, lazily loaded on the first render. The Engine clones the quill and document into that memory and frees the clones for you, so you never hold a backend handle or cross the seam yourself.

Rust

Constructors and rendering

- let engine = Quillmark::new();
- let quill  = engine.quill_from_path(path)?;     // or engine.quill(tree)
- let result = quill.render(&doc, &opts)?;
- let formats = quill.supported_formats();
+ let quill  = quillmark::quill_from_path(path)?;  // or Quill::from_tree(tree)
+ let engine = Quillmark::new();
+ let result = engine.render(&quill, &doc, &opts)?;
+ let formats = engine.supported_formats(&quill)?;  // now returns Result
  • Quillmark::quill / quill_from_path methods are removed. Use the core constructor Quill::from_tree(tree) -> Result<Quill, Vec<Diagnostic>>, or the quillmark::quill_from_path free function for filesystem loading (which surfaces a RenderError; fs stays out of core).
  • render, open, supported_formats, and the new supports_canvas are now methods on Quillmark, each taking &quill. supported_formats returns Result<_, RenderError> (it resolves the backend).
  • validate, compile_data, dry_run, seed_*, backend_id stay on Quill.

QuillSource is gone; Backend::open takes &Quill

QuillSource was renamed to Quill and absorbed the wrapper's methods; the orchestration Quill is deleted. Backends now receive &Quill:

  impl Backend for MyBackend {
-     fn open(&self, plate: &str, source: &QuillSource, json: &serde_json::Value)
+     fn open(&self, plate: &str, source: &Quill, json: &serde_json::Value)
          -> Result<RenderSession, RenderError> { … }
+
+     // Optional: override if your backend can paint to a canvas.
+     fn supports_canvas(&self) -> bool { false }
  }

If you reached the inner data via quill.source(), drop the call — quill.config() / quill.metadata() / quill.plate() are now direct on Quill.

Python

The Python binding moves to the same engine-free shape as Rust and WASM: a Quill is portable, validated config data; render and capability live on the Quillmark engine, against a quill.

# Before (0.89): load via the engine, render via the quill
engine = Quillmark()
quill  = engine.quill_from_path("path/to/quill")
result = quill.render(doc, OutputFormat.PDF)
formats = quill.supported_formats
canvas  = quill.supports_canvas

# After (0.90): load engine-free, render via the engine
engine = Quillmark()
quill  = Quill.from_path("path/to/quill")          # was engine.quill_from_path
result = engine.render(quill, doc, OutputFormat.PDF)  # was quill.render(doc, ...)
formats = engine.supported_formats(quill)          # was quill.supported_formats
canvas  = engine.supports_canvas(quill)            # was quill.supports_canvas
session = engine.open(quill, doc)                  # was quill.open(doc)

Unchanged on Quill: backend_id, quill_ref, schema, blueprint, metadata, validate(doc), and the seed_* / $ext methods.

Two contract changes:

  • quill.metadata no longer carries supportedFormats and is now a pure, infallible config read — it never resolves a backend, so it never raises. Read capability from engine.supported_formats(quill) instead.
  • Backend resolution moves from load time to render time. Quill.from_path succeeds for a quill whose declared backend isn't registered; the UnsupportedBackend error surfaces from the first engine call (render / open / supported_formats). supports_canvas is non-raising (False for an unregistered backend). Unreachable in the stock build, which always registers Typst.

@quillmark/quiver

Coordinated release

@quillmark/quiver must be updated alongside this WASM release. A consumer who updates @quillmark/wasm to >=0.90.0 must also update @quillmark/quiver to >=0.14.0 at the same time — the older Quiver still calls the removed engine.quill(tree) method.

Code that bypassed Quiver should migrate to getQuill, not fromTree

Code that previously called engine.quill(tree) directly — bypassing a Quiver instance — should migrate to quiver.getQuill(ref), not to a manual tree-assembly + Quill.fromTree sequence (Quiver no longer exposes any public tree-fetch method):

// wrong — duplicates Quiver's resolution, caching, and construction
const tree = buildTreeByHand(files); // hand-assembled file tree
const quill = Quill.fromTree(tree);

// right — Quiver handles resolution, construction, and caching
const quill = await quiver.getQuill(ref);

Holding a QuillClass reference (e.g. storing wasmModule.Quill on a service field) is only needed when constructing quills outside of a Quiver context (e.g. a raw-tree endpoint, a test fixture). Inside a Quiver consumer, getQuill is the entire entry point.

Quiver cache semantics changed

Under 0.89, Quiver.getQuill(ref, { engine }) produced a separate Quill instance per engine. Under 0.90 there is one Quill per canonical ref for the lifetime of the Quiver instance, shared across every engine that calls getQuill. The tree is fetched once, Quill.fromTree is called once, and the result is cached. This halves the number of instances in multi-engine deployments (e.g. a live-preview engine and a thumbnail-worker engine on the same page).

Tests that asserted per-engine isolation behavior (e.g. "distinct engines get distinct quill objects") should be removed; the shared-instance guarantee is the new invariant.

A Quiver quill goes straight into engine.render

@quillmark/quiver@0.14.0 materializes quills from @quillmark/wasm. getQuill(ref) returns a Quill you use for schema inspection, validation, and blueprint access — and for rendering. That same quill passes directly into engine.render; the Engine does the memory crossing internally:

import { Engine } from "@quillmark/wasm";

const engine = new Engine();

// one quill — schema, validation, AND rendering
const quill = await quiver.getQuill(ref);
quill.schema;                          // ✓
const doc = quill.seedDocument();
quill.validate(doc);                   // ✓
const result = await engine.render(quill, doc, { format: "pdf" }); // ✓

Do not re-materialize the quill by hand. The 0.89-era dance of fetching a raw tree and re-running Quill.fromTree(tree) plus a Document.fromJson(coreDoc.toJson()) round-trip to "cross into render memory" is an anti-pattern — the Engine clones into the backend's memory for you. getQuill(ref) + engine.render(quill, doc) is the whole story.

Raw tree bytes. If you genuinely need the quill's underlying file tree (e.g. to inspect or re-pack it), call quill.toTree() on the quill getQuill already returned — it round-trips with Quill.fromTree. There is no separate tree-fetch escape hatch: Quiver no longer exposes getTree, and you do not need it to render.

See also

This release moves render onto the engine but does not change when a mismatched $quill is rejected — that enforcement landed in 0.88 → 0.89. Read it too if you are jumping straight from 0.88.