Skip to content

0.92 → 0.93 — the !must_fill marker replaces the <must-fill> blueprint sentinel

The blueprint placeholder model is rebuilt around two orthogonal axes — value and marker — replacing the single <must-fill> string sentinel. The changes are author- and consumer-visible but require no document rewrites: old documents that carry the literal <must-fill> string now render it as ordinary content rather than failing.

Blueprints show !must_fill, not <must-fill>

QuillConfig::blueprint no longer writes the <must-fill> string into Unendorsed cells. It stamps the canonical !must_fill YAML tag (already part of the document model — see the Markdown Specification §3.4) on the field instead (illustrative — field names and types abbreviated):

0.92                              0.93
~~~                               ~~~
$quill: cmu_letter@0.1.0          $quill: cmu_letter@0.1.0
$kind: main                       $kind: main
# The recipient's name.           # The recipient's name.
name: <must-fill>  # array        name: !must_fill # array<string>
date: <must-fill>  # datetime       - Mr. John Doe
~~~                                 - 123 Main St
                                  date: !must_fill # datetime<…>
                                  ~~~
  • An Unendorsed scalar field with an example: inlines the example as the marker's suggested value (name: !must_fill <example>) — there is no longer a separate # e.g. line for that field. An Unendorsed array field carries its example as block-style items under the marker (see "Blueprint emission is unified on to_markdown" below).
  • An Unendorsed field with no example: emits a bare marker (date: !must_fill).
  • A richtext Unendorsed field emits a bare bio: !must_fill # richtext<markdown> — no |- block scalar wrapping a sentinel line.
  • Typed-table / typed-dict Unendorsed leaves carry !must_fill; the container key itself is untagged (you tag the leaves, not the container).
  • Endorsed fields (those with a default:) render the concrete default value with a type-only # <type> annotation (see "The ; delete-ok inline annotation is removed" below), and an example: on an Endorsed field still surfaces as a # e.g. hint.

The ; delete-ok inline annotation is removed

The blueprint's inline annotation is now purely structural: # <type>[<format>]. The ; delete-ok suffix that previously tagged Endorsed cells is gone entirely. Shippability is carried by the value cell alone:

  • An Endorsed field (one with a default:) renders its concrete default value with a type-only annotation — e.g. title: "Curriculum Vitae" # string (previously # string; delete-ok), count: 0 # integer, refs: [] # array<object>, and an endorsed richtext default as an inline quoted string bio: "## About\n\n…" # richtext<markdown>.
  • An Unendorsed field (no default:) still carries the !must_fill marker on its value line with # <type> (markers were never tagged delete-ok).

The reader's single rule is now: a !must_fill marker present → fill it; a concrete value present → shippable as-is (delete or blank the line to fall back to the default).

Action: consumers that parsed or string-matched the ; delete-ok tag in blueprint output should stop. There is no ; delete-ok tag anymore.

Blueprint emission is unified on to_markdown

QuillConfig::blueprint no longer hand-formats YAML. It builds a Document and emits it through the canonical Document::to_markdown — the same emitter the parse/round-trip path uses. The blueprint round-trips by construction, and its formatting now matches to_markdown exactly:

  • One-space inline gap. Inline annotations use a single space before # (name: !must_fill # string), not two.
  • Block-style sequences. An Unendorsed array's example renders as block items under the marker rather than an inline flow sequence: recipient: !must_fill # array<string> then - Mr. John Doe per item.
  • Inline-quoted richtext. An Endorsed richtext default renders as an inline double-quoted scalar with \n escapes — no |- block scalar. The empty default is the inline empty string bio: "" # richtext<markdown>.
  • default: {} expands. An empty typed-dictionary default now expands to the field's zero-filled shape (every key shown with its type-empty value, all unmarked) instead of emitting a bare address: {} # object. A non-empty partial default is still rendered verbatim, and arrays are unchanged (default: [] stays inline []).

Action: consumers that string-matched blueprint output for the two-space # gap, inline flow example arrays, |- markdown block scalars, or a bare {} empty-object cell should update to the forms above. Parsing the blueprint as a Document (the intended path) is unaffected — all forms round-trip.

Bare-null and field: now fall back instead of failing

A present-null field value — field:, field: null, or field: ~ — is now treated exactly like an omitted field: null ≡ absent. It validates clean (no TypeMismatch) and zero-fills at render (authored › default: › type-zero). The previous behavior — where a present-null fired a TypeMismatch whose message told you to "delete this entire line" — is gone.

Bare scalars stringify into string fields

A bare boolean, integer, or number written where a string is expected is now coerced to its canonical scalar token (true, 47, 1.0) instead of firing a TypeMismatch. It is unambiguously representable as text, and this helps authors — LLMs especially — who naturally write verified: true or build_number: 47 for a string-typed field. Null and collections are excluded.

The leniency is scoped to document payloads. A quill author's own default:/example: literals stay strict, so the blueprint keeps quoting ambiguous string literals.

Action: consumers that relied on a TypeMismatch to catch a bare scalar in a string field (the "quote the value" hint) should expect the value to coerce instead. The quotable_actual / "quote the value" diagnostic branch is gone.

validation::must_fill_sentinel (fatal) → validation::must_fill (non-fatal)

The fatal MustFillSentinel diagnostic, the <must-fill> string constant, and the coercion sentinel pass-through are all retired. In their place:

  • Quill::validate emits validation::must_fill at Severity::Warning for every !must_fill marker present — root and nested, main card and composable cards — regardless of whether the marker carries a value.
  • The marker never gates render. A document carrying outstanding markers renders fine: each marked cell uses its suggested value or zero-fills. A strict consumer (e.g. an LLM authoring loop) treats any outstanding marker as "not done."

Action: consumers that previously matched on the fatal validation::must_fill_sentinel code, or that scanned rendered/authored text for the literal <must-fill> string, should switch to the non-fatal validation::must_fill warning and stop gating render on it.

validation::field_absent is removed

The completeness signal validation::field_absent is gone — the ValidationError::FieldAbsent variant, its code, and its diagnostic machinery are removed. Field absence is no longer a validation concern: a merely incomplete document, or one with present-null fields, validates clean and zero-fills at render.

Action: consumers that read validation::field_absent as a doneness hint should drop it; use the !must_fill markers (via validation::must_fill) as the outstanding-work signal instead.

No literal-string escape hatch

Because the placeholder is now a YAML tag, not a string sentinel, there is no collision with content and no quoting escape-hatch to learn. The literal text !must_fill written as a value (note: "!must_fill") is ordinary content; a real marker is the YAML tag attached to a field (note: !must_fill). The two are structurally distinct.

Content fields are a RichText corpus; type: markdown retires

Rich content is no longer a markdown string. Every content field is a canonical RichText corpus — one USV text with line attributes, anchored marks, and embedded islands — and markdown becomes an import/export projection of it. Backends lower the corpus directly (Typst → markup + source map; pdfform → .text); no render path re-parses markdown.

The field type is richtext, not markdown. type: markdown is now a hard schema-load error (quill::field_parse_error); it no longer silently aliases to rich content. Declare type: richtext; the single-line variant is type: richtext with inline: true (one paragraph, no blocks or islands). The compact type: richtext(inline) spelling is also accepted at 0.93 and retires in 0.93 → 0.94.

  • Blueprint. A richtext field annotates as # richtext<markdown> (# richtext(inline)<markdown> when inline; # array<richtext<markdown>> in an array) — the <markdown> slot names the projection the value is written in.
  • Plate JSON. $body (root and per-card) and every richtext payload field cross the backend seam as canonical RichText-JSON corpus objects, not markdown strings (see the Markdown Specification §5).
  • Storage. The document storage schema bumps to quillmark/document@0.93.0, embedding each body as the canonical richtext corpus instead of a markdown string. Blobs written under @0.92.0 and earlier are read-only: their markdown body cold-imports to a corpus and migrates forward on read, so stored documents need no rewrite.

Action: rename type: markdowntype: richtext in every Quill.yaml (add inline: true where you want a single-line field). A stale type: markdown fails schema load; documents and stored blobs need no change.

Backend::supports_canvas is removed — capability is derived

The hand-set Backend::supports_canvas() trait method is gone. Canvas capability was expressed in two independently-settable places — the static flag and the SessionHandle seam (page_size_pt / render_rgba) — which could disagree. Capability is now derived from the seam alone, so it cannot drift from what paint actually does:

  • LiveSession::supports_canvas() (new, core) is the authoritative, session-level answer: true exactly when the session exposes page geometry through the canvas seam (and has at least one page to paint).
  • quillmark_core::formats_support_canvas(formats) (new, core) is the pre-session hint, derived from output formats (a backend that emits PNG or SVG can paint). The engine's supports_canvas(&quill) now uses it.

The engine method Quillmark::supports_canvas(&quill) and the WASM engine.supportsCanvas(quill) / LiveSession.supportsCanvas surfaces are unchanged in shape — only their derivation changed — so host code that calls them needs no edits.

Action (out-of-tree backends only): a Backend impl that overrode supports_canvas() should delete the override and instead implement the SessionHandle canvas seam (page_size_pt + render_rgba) on its session — capability follows automatically. Code that called Backend::supports_canvas() directly on a trait object should call LiveSession::supports_canvas() (with a session) or formats_support_canvas(backend.supported_formats()) (pre-session).

// 0.92                                    0.93
impl Backend for MyBackend {              impl Backend for MyBackend {
    fn supports_canvas(&self) -> bool {       // (removed — derived from the seam)
        true                              }
    }                                     impl SessionHandle for MySession {
}                                             fn page_size_pt(&self, p: usize) ->  {  }
                                              fn render_rgba(&self, p, s) ->  {  }
                                          }

Field regions become a session query keyed on the quill schema field; clicks resolve via fieldAt

Two changes to the region sidecar.

1. Regions are primarily a session query; a render carries them only on request. An interactive canvas/SVG preview holds a session (it paint()s) and reads the geometry off the compiled session — no render, no discarded artifact — re-reading it after each committed apply. A one-shot render's RenderResult.regions is now empty unless RenderOptions asks (regions: true), for consumers without a session in hand — static overlays over an exported SVG, PDF post-processing, coverage probes. The sidecar always describes the whole document: page indices are document-space even under a pages subset render.

// 0.92                                      0.93 — interactive preview
const r = await engine.render(quill, doc);  const session = await engine.open(quill, doc);
const regions = r.regions;                  const regions = session.regions();

                                            // 0.93 — one-shot consumer, no session
                                            const r = await engine.render(quill, doc, { regions: true });
                                            const regions = r.regions;
// 0.92                                          0.93
let regions = session.render(&opts)?.regions;    let regions = session.regions();

An export-only consumer that never read regions now pays nothing for them by default.

2. Regions key on the quill schema field, not the backend widget — and the two navigation directions split into two queries. A region maps a rendered field back to the quill schema field — the address the editor uses — with a lookup, not a guess: regions() answers field → rectangles (scroll to / highlight), the new fieldAt(page, x, y) answers point → field (click → focus in the editor). RenderedRegion (core) and the WASM FieldRegion lose name, kind, fieldType, and value for a single field:

// 0.92                                          0.93
{                                                {
  "name": "Signature",        // AcroForm /T       "field": "signature_block",  // schema path
  "page": 0,                                       "page": 0,
  "rect": [324, 490.5, 524, 540.5],                "rect": [324, 490.5, 524, 540.5]
  "kind": { "type": "field",                     }
           "fieldType": "signature",
           "value": null }
}
  • field is the quill schema field path (e.g. signature_block, $cards.indorsement.1.from — the card form is kind + 0-based ordinal, $cards.<kind>.<n>.<field>).
  • Content fields are covered by span tracking — through any placement context. The backend evaluates each markdown body, markdown[] element, and card content field at its own generated call site; the rendered glyphs carry that origin, and the page geometry is recovered from the laid-out frames (true rendered extent, not a declared size). Because the origin rides the glyph, a value passed through a package that rebuilds its content (a show-rule pass that buffers and re-emits paragraphs, like the memo package's AFH auto-numbering) still resolves — no plate-side recovery step. An empty/whitespace-only body draws nothing to bound and surfaces no region.
  • Direct scalar references are covered — per reference site. Every data.<field> / data.at("field") expression in the plate is a tracked site (#data.subject in a header and a footer surfaces both), including through a wrapping expression (#upper(data.subject)) when the field is its only reference. Not tracked: expressions mixing several fields (data.from + ", " + rank has no single owner), values passed through an intermediate binding (#let s = data.x), and card scalars read from the per-card loop variable — one shared card.from expression carries no per-instance identity; bind a widget (form-field(..., field: card.at("$path") + "from")) where a card scalar needs a region.
  • tagged() is removed. Both of its jobs are structural now: package rebuilds cannot drop glyph spans, and scalars region at their reference sites. Delete the import and unwrap the calls — tagged("$body")[#mainmatter[#data.at("$body")]] becomes #mainmatter[#data.at("$body")]. A plate still calling tagged fails the compile (unknown symbol).
  • regions() returns the first placement of a content value, not every placement. A value placed at two sites surfaces its first placement only (one region per page that placement touches, so highlighting still covers continuation pages of a page-spanning body); a scalar referenced at several sites still surfaces each site (distinct expressions are distinct origins); content plus a field:-bound widget still surfaces both. field therefore remains not unique — group by field. For clicks, don't hit-test the region list: use the new fieldAt(page, x, y) session query (Rust: LiveSession::field_at), which hit-tests the compiled document and resolves every placement, in the same PDF-pt bottom-left coordinates as rect.
  • Only schema-addressable fields surface a region. A Typst form-field emits a region only when it binds a schema path via field: (e.g. signature-field("Signature", field: "signature_block")); a widget that binds none has only a /T name — a backend identifier, not a schema address — so it produces no region. (Same rule pdfform already follows: a widget with schema_field: null is decorative and emits nothing.) field: validates against the schema address grammar at compile time — a widget bound to a name the schema doesn't declare (a 0.92-style AcroForm /T string, say) is a compile error, not a silent no-region widget. An index suffix (refs.2) is a valid address only on an array-typed field — any array, the same shallow-path grammar the pdfform resolver binds; subject.0 on a scalar subject is a compile error even though the name is declared. Each card dict carries its canonical address prefix as $path, so plates compose card paths without reimplementing the kind+ordinal grammar.
  • value and fieldType are gone. Regions are geometry for overlays and cross-navigation, not a compositing input — both canvas backends already bake values into a complete raster, so nothing read them. A field's value lives in the editor (and, for PDF output, the AcroForm /V); its type lives in the quill schema.

Action (consumers): get regions from session.regions() instead of renderResult.regions; read region.field where you read region.name; drop any use of region.kind / fieldType / value. region.field is not unique in the list — group entries by field before routing. Resolve clicks with session.fieldAt(page, x, y) (PDF pt, bottom-left) instead of hit-testing the region list — the list carries only first placements.

Action (plate authors): delete tagged from the helper import and unwrap its calls; content and scalar regions are automatic. Keep (or add) field: bindings on widgets — they are now the only way to region a card scalar per instance.

Action (out-of-tree backends): override SessionHandle::regions() on your session (default empty) to return geometry. field_at() defaults to hit-testing your regions() — complete if they enumerate every placement; override it with a real document hit-test if they don't. FieldSpec (the quillmark-pdf stamp spine) gains a schema_field: Option<String>; set it when you build specs (a field with None emits no region). stamp/flatten now return plain Vec<u8> (StampResult is removed), and RegionKind is removed from quillmark_core.

plate_file moves to the typst: section; Backend::open drops the plate parameter

A plate is a Typst notion, not a universal one — the pdfform backend has no plate and ignored the parameter. The plate is no longer hoisted into core or the backend trait. Each backend now reads its own static inputs from the quill's file bundle, exactly as pdfform already read its form.pdf / form.json.

Quill.yaml: plate_file moves out of quill: into typst:. It was always a Typst-only setting; it now lives under the backend-named section like packages. A plate_file left under quill: is now a hard quill::unknown_key error.

# 0.92                          0.93
quill:                          quill:
  name: my_quill                  name: my_quill
  backend: typst                  backend: typst
  version: "1.0.0"                version: "1.0.0"
  description: A format           description: A format
  plate_file: plate.typ
                                typst:
                                  plate_file: plate.typ

It surfaces in Quill.metadata() as typst_plate_file (like any other typst: key), instead of the former dedicated handling.

Core drops the plate. Quill::plate(), the plate field, and QuillConfig::plate_file are removed. Core no longer reads any template at load time, so a missing or non-UTF-8 plate is now a render-time error (typst::plate_missing / typst::invalid_utf8) rather than a load-time quill::plate_missing.

Backend::open loses its first parameter. The signature is now open(&self, source: &Quill, json_data: &serde_json::Value). A backend reads whatever it needs from source.files() / source.config().

Action (out-of-tree backends): drop the plate_content parameter from your open impl and read your own inputs from source. The Typst backend reads the file named by source.config().backend_config["plate_file"] (an empty plate when unset).

WASM build feature rendertypst (plus new pdfform builds)

The WASM crate's engine feature was renamed and split. The single render feature is gone; the engine half (the Quillmark / LiveSession types) is now gated on typst or pdfform:

  • typst (the new default) — the Typst-backed engine + canvas preview, the exact surface the old render feature shipped.
  • pdfform — the Typst-free PDF-form backend (a tiny, Typst-less engine bundle). It ships the web-sys canvas painter over its always-linked hayro raster seam, so it reports supportsCanvas == true, mirroring typst.

Action (from-source WASM builders only): replace --features render with --features typst (or build the new pdfform variant). The published JS API surface and the canonical Engine runtime are unchanged.

RenderSession collapses into LiveSession; sessions gain apply()

The frozen, single-compile RenderSession and the live preview session are one type. LiveSession (core, quillmark, and the WASM class) serves reads (render, paint, pageSize, regions) from its current compile and takes edits via a transactional apply — on failure every read keeps serving the last-good compile. Immutability between commits is an invariant of apply, not a separate type. The session surface is experimental, so this is a source-level rename with no stable-API break:

// 0.92                                      0.93
let session: RenderSession = ...;            let mut session: LiveSession = engine.open(&quill, &doc)?;
                                             let cs: ChangeSet = session.apply(&quill.compile_data(&doc2)?)?;
                                             // cs.page_count, cs.dirty_pages
// 0.93 (WASM) — don't re-open per edit
const session = engine.open(quill, doc);     // or `await engine.open(...)` via the runtime
const { pageCount, dirtyPages } = session.apply(editedDoc);
  • SessionHandle::apply (backend seam) defaults to a backend::apply_unsupported error; both built-in backends implement it (Typst incrementally against a persistent compilation world, pdfform as a cheap full re-resolve + re-flatten).
  • ChangeSet { page_count, dirty_pages } names the pages an edit visibly changed; a preview repaints dirty ∩ visible. Removed pages are implied by page_count.
  • quillmark_typst::typst_session_of is removed (callerless): the canvas path dispatches generically through the page_size_pt / render_rgba seam.
  • The Typst backend now evicts comemo's process-global cache after every compile (entries older than 10 compiles), bounding memory over long editing sessions. Behavioral only; no action.

Action: rename RenderSessionLiveSession at use sites; sessions used for edits must be mut. For live previews, call apply(doc) instead of re-opening a session per edit.

RenderOptions.flatten is removed — pdfform PDF output is always an AcroForm

The RenderOptions.flatten field (pub flatten: Option<bool>, core) is removed. The pdfform backend's PDF deliverable is now unconditionally an interactive AcroForm, produced by stamp — there is no runtime flag to bake values into content streams for the PDF output instead. Value-flattening survives only as internal raster machinery (SVG/PNG/canvas) — always linked in the pdfform backend to back a complete page raster — never a PDF deliverable.

The field is dropped from every binding that carried it: WASM RenderOptions loses the flatten field (and its .d.ts/.js prose), and the Python binding drops its hardcoded flatten argument.

Action: stop setting flatten in RenderOptions — pdfform PDF output is always an AcroForm; consumers that wanted flattened values already get them baked into the SVG/PNG raster.

RenderError is a struct; Severity::Note is removed; compile warnings surface

The error system is reworked (see prose/proposals/error-system-rework.md while in flight):

  • RenderError's nine variants are removed. It is now a struct carrying a non-empty Vec<Diagnostic> (RenderError::new / from_diag; diagnostics() / into_diagnostics() unchanged). Route on each diagnostic's namespaced code (parse::*, validation::*, quill::*, typst::*, backend::*, engine::*) instead of matching variants. The bindings already flattened every variant to one exception shape, so only Rust callers matching variants are affected.
  • Multi-error messages are count-based everywhere. RenderError's Display, Python's str(exc), and WASM's Error.message all follow one rule: the primary diagnostic's message for a single diagnostic, an "<N> error(s): <first message>" aggregate for more. Variant-specific prefixes ("Compilation failed with…", "Validation failed with…") are gone.
  • severity: "note" disappears from the wire. Severity is two values — error (blocks the stage that emits it) and warning (never does). No producer ever emitted a note; deserializing "note" is now an error.
  • Typst compile warnings surface instead of dying on stderr. Font fallback, overfull pages, and other non-fatal Typst diagnostics now arrive in RenderResult.warnings (after parse warnings) and on LiveSession.warnings.
  • LiveSession.warnings is the current compile's warnings, not an open-time snapshot: refreshed by each committed apply, kept at last-good on a failed apply. The uncalled LiveSession::with_warnings builder is removed; a backend exposes warnings by overriding the new SessionHandle::warnings() seam (default empty).

Action: replace RenderError::<Variant> { diags } construction with RenderError::new(diags) / RenderError::from_diag(d) and variant matches with code checks; drop any handling of Severity::Note / "note"; expect warnings arrays that were previously always empty to be populated, and re-read session.warnings after each committed apply.

The .NET binding is removed

The .NET binding is dropped entirely. The supported bindings are now Python, WASM, and the CLI. There is no C#/.NET surface to migrate; a consumer on the .NET binding must move to one of the remaining bindings.

$body is absent from the transform schema for a body-disabled kind

build_transform_schema no longer injects $body into properties for a kind (main or a card kind) that declares body.enabled: false. Previously $body was always present — an address the system promised could never hold content, yet still validated and compiled as a tagged/form-field target. Absence now cascades: $body drops out of the __meta__ address tables, so tagged("$body") / form-field(field: "$body") on a body-disabled kind is a compile-time error, matching Quill::validate's existing hard error on authored body content for the same kind.

Action: a plate that does tagged(card.at("$path") + "$body")[..] (or the main-level equivalent) on a kind with body.enabled: false must drop the $body placement — there is no address for it to route to. No in-tree plate did this.

New: programmatic construction surface

Additive; nothing to migrate. A document can be built in memory without Markdown text (see prose/canon/PROGRAMMATIC.md):

  • Blank-canvas constructorDocument::new(quill_ref) (Rust), Document(quill_ref) (Python), new Document(quillRef) (WASM): a main card carrying only $quill, an empty body, no composable cards. Absent fields resolve at render (schema default, else type-empty zero), so nothing the caller did not set reaches the output. The blank counterpart of seed_document(), which stays example-filled.
  • Atomic batch mutationCard::set_fields (Rust); doc.set_fields(dict) / doc.update_card_fields(index, dict) (Python); doc.setFields(obj) / doc.updateCardFields(index, obj) (WASM). The whole batch validates before anything applies; on violation nothing is applied and the single raised error carries one diagnostic per offending field with path set to the field name.
  • Rust scalar conversionsQuillValue gains From impls for &str, String, bool, integers, f64, and serde_json::Value; set_field / set_fill / set_fields take impl Into<QuillValue>, so card.set_field("qty", 3) compiles as written.