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 onto_markdown" below). - An Unendorsed field with no
example:emits a bare marker (date: !must_fill). - A
richtextUnendorsed field emits a barebio: !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-okinline annotation is removed" below), and anexample: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 stringbio: "## About\n\n…" # richtext<markdown>. - An Unendorsed field (no
default:) still carries the!must_fillmarker on its value line with# <type>(markers were never taggeddelete-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
examplerenders as block items under the marker rather than an inline flow sequence:recipient: !must_fill # array<string>then- Mr. John Doeper item. - Inline-quoted richtext. An Endorsed
richtextdefault renders as an inline double-quoted scalar with\nescapes — no|-block scalar. The empty default is the inline empty stringbio: "" # 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 bareaddress: {} # 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::validateemitsvalidation::must_fillatSeverity::Warningfor every!must_fillmarker 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 everyrichtextpayload 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.0and 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: markdown → type: 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'ssupports_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;
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 }
}
fieldis 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 (ashow-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.subjectin 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 + ", " + rankhas no single owner), values passed through an intermediate binding (#let s = data.x), and card scalars read from the per-card loop variable — one sharedcard.fromexpression 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 callingtaggedfails 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 afield:-bound widget still surfaces both.fieldtherefore remains not unique — group byfield. For clicks, don't hit-test the region list: use the newfieldAt(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 asrect.- Only schema-addressable fields surface a region. A Typst
form-fieldemits a region only when it binds a schema path viafield:(e.g.signature-field("Signature", field: "signature_block")); a widget that binds none has only a/Tname — a backend identifier, not a schema address — so it produces no region. (Same rule pdfform already follows: a widget withschema_field: nullis 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/Tstring, 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.0on a scalarsubjectis 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. valueandfieldTypeare 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 render → typst (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 oldrenderfeature shipped.pdfform— the Typst-free PDF-form backend (a tiny, Typst-less engine bundle). It ships theweb-syscanvas painter over its always-linked hayro raster seam, so it reportssupportsCanvas == true, mirroringtypst.
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 abackend::apply_unsupportederror; 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 repaintsdirty ∩ visible. Removed pages are implied bypage_count.quillmark_typst::typst_session_ofis removed (callerless): the canvas path dispatches generically through thepage_size_pt/render_rgbaseam.- 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 RenderSession → LiveSession 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-emptyVec<Diagnostic>(RenderError::new/from_diag;diagnostics()/into_diagnostics()unchanged). Route on each diagnostic's namespacedcode(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'sDisplay, Python'sstr(exc), and WASM'sError.messageall 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.Severityis two values —error(blocks the stage that emits it) andwarning(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 onLiveSession.warnings. LiveSession.warningsis the current compile's warnings, not an open-time snapshot: refreshed by each committedapply, kept at last-good on a failedapply. The uncalledLiveSession::with_warningsbuilder is removed; a backend exposes warnings by overriding the newSessionHandle::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 constructor —
Document::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 (schemadefault, else type-empty zero), so nothing the caller did not set reaches the output. The blank counterpart ofseed_document(), which stays example-filled. - Atomic batch mutation —
Card::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 withpathset to the field name. - Rust scalar conversions —
QuillValuegainsFromimpls for&str,String,bool, integers,f64, andserde_json::Value;set_field/set_fill/set_fieldstakeimpl Into<QuillValue>, socard.set_field("qty", 3)compiles as written.