0.93 → 0.94 — richtext(inline) type token retires; use inline: true¶
Single-line richtext shape is still a schema validation concern, not a UI
hint. Only the Quill.yaml spelling changes: type: richtext(inline) is a hard
load error; declare inline: true on a richtext field instead.
Quill.yaml¶
0.93 (retired) 0.94
subject: subject:
type: richtext(inline) type: richtext
example: Memo subject inline: true
example: Memo subject
refs: refs:
type: array type: array
items: items:
type: richtext(inline) type: richtext
inline: true
type: richtext(inline)→quill::field_parse_errorwith a hint to useinline: true.inline: trueon a non-richtextfield →quill::field_parse_error.inline: falsemay be omitted (block richtext is the default).
Shape enforcement is unchanged: coercion, validation (richtext::not_inline),
and load-time example: / default: import still require exactly one Para
line with no container and no islands.
Projections (unchanged spelling)¶
| Surface | Inline field annotation |
|---|---|
| Blueprint | # richtext(inline)<markdown> (derived from inline: true) |
QuillConfig::schema() |
type: richtext + inline: true |
build_transform_schema() |
contentMediaType: application/quillmark-richtext+json + quillmark:inline: true |
Documents and corpus wire shapes are unaffected — only quill schema authoring changes.
Migrating a field to richtext: str → content, and the plaintext helper¶
Converting an existing string/array field to richtext is not
transparent to Typst plates. A string/array field reaches the plate as a
Typst str; a richtext field reaches it as Typst content (its corpus
lowers to a markup block). So any plate or vendored package that does a string
operation on the value — str(value), value.trim(), value.starts-with(..),
upper(value), or an assert(type(item) == str) guard (e.g. a
create-auto-grid-style helper) — breaks at render time, not load time,
with no schema-level or compile-time signal. Audit every plate/package consumer
of a field before switching it to richtext.
Where the consumer genuinely needs a string, use the new plaintext(field)
helper instead of forcing the field back to string:
#import "@local/quillmark-helper:0.1.0": data, plaintext
// data.subject is `content` — renders directly:
#data.subject
// plaintext("subject") is the field's `str` — the corpus text with island
// slots (tables/images) stripped and all formatting marks dropped:
#upper(plaintext("subject"))
#assert(plaintext("subject").starts-with("RE:"))
field is the schema address — a field name ("subject"), an array element
("refs.2"), or a card path composed from the card's $path prefix
(plaintext(card.at("$path") + "$body")). It returns "" for a blank field or
an address with no richtext content. This is the sanctioned richtext→str
coercion (the same projection the pdfform backend lowers a richtext field to for
a form-field value); do not repr/stringify the content block.
richtext(inline) fields no longer emit a parbreak warning¶
An inline: true field now lowers to pure inline content (no trailing
paragraph break), so nesting it in an inline slot — par(..), a signature
line, a grid cell, measure(..) — no longer triggers Typst's non-fatal
"parbreak may not occur inside of a paragraph" warning. No plate change is
needed; block (inline omitted) richtext is unaffected.
New field types: plaintext and enum (#938)¶
Both are additive — existing quills load unchanged. They complete the text-type
2×2: enum (closed data), string (open data), plaintext (plain content),
richtext (formatted content).
plaintext¶
A content type over the same RichText corpus as richtext — same
contentMediaType, so it lowers through the identical backend path and inherits
preview↔editor navigation and per-field regions — but authored and projected
through a literal codec, not markdown: *hi* is four literal characters,
verbatim both ways, idempotent. Declared like richtext, inline: included:
subject:
type: plaintext
inline: true
example: "RE: *not* italic — the asterisks are literal"
body:
type: plaintext # multi-paragraph plain prose
A lone \n is a within-paragraph break; \n\n is a paragraph boundary. The
value carries no marks, islands, or block formatting; a wire corpus that does is
rejected (plaintext::not_plain), not stripped — the inline precedent.
The transform schema carries the richtext media type plus quillmark:plain: true
(an editor hint backends ignore).
Distinct from the plaintext(field) Typst helper above: the helper projects
any corpus to a str; the type declares a field plain at the source. A
plaintext field still reaches the plate as content, so the str-consumer
caveat above applies — use plaintext(field) where a plate needs the str.
enum¶
Promoted from a string modifier to a type token: type: enum + a required
values: list.
0.93 (deprecated modifier) 0.94
color: color:
type: string type: enum
enum: [red, green, blue] values: [red, green, blue]
It projects to JSON-Schema {type: "string", enum: [...]} — what backends
already dispatch on — so no backend or plate change. The enum: modifier on
type: string still works this release (same store, same validation) but is
deprecated. Two edges tighten:
enum:/values:on any type other thanstring/enumis now a load error, not a silent no-op (the old modifier validated nothing on, e.g., aninteger).type: enumrequires a non-emptyvalues:;values:on astringis a load error.
v1 members are string-valued — an enum is a branching key; numeric domains are
number range constraints, not enums.
string keeps its spelling but narrows to open scalar data: URLs, paths,
identifiers, keys — values a template computes with (#link / #image,
comparison, keying), not prose it lays out. Use plaintext for prose the author
navigates, enum for a closed set.
Group registry: ui.groups, validated ui.group references (#941)¶
Groups gain a card-level registry. A group is no longer just the set of
fields sharing a byte-identical ui.group string (where the display label
doubled as identity and a typo silently split a section). Each card declares
ui.groups — an ordered list of snake_case ids — and a field's ui.group
becomes a reference into it.
0.93 (label is identity) 0.94 (id is identity, label derived)
main: main:
fields: ui:
memo_for: groups: [addressing, letterhead]
ui: { group: Addressing } fields:
subject: memo_for:
ui: { group: Addressing } ui: { group: addressing }
subject:
ui: { group: addressing }
- Registry keys are snake_case ids; declaration order is display order — the
same contract fields follow (declaration order is field display order). Consumers derive the
label from the id (
addressing→ "Addressing"), exactly as a field label is derived from its key; override it with the mapping form,letterhead: { title: "Letterhead & Seal" }. Renaming a label touches one line and never breaks a reference or persisted per-group editor state. - The registry has two interchangeable forms: a bare sequence of ids
(
[addressing, letterhead]) when no label needs overriding, or a mapping of id → attributes when one does. Both fold to the same ordered registry and re-emit as the mapping form inQuillConfig::schema(). ui.groupis validated: a value with no matching registry key isquill::unknown_group; a registry id that is not snake_case isquill::invalid_group_id; a repeated id isquill::duplicate_group. Because a registry is authoritative when present, there is no implicit fallback to mix with — an undeclared reference simply fails.- Implicit groups are deprecated, not yet removed. A
ui.groupwith noui.groupsregistry keeps today's exact semantics — the value is the label and identity, groups order by first appearance — and now emits aquill::implicit_groupwarning. It becomes an error in a future release. Migrate by adding aui.groupsregistry and lowercasing eachui.groupto its id. (No slug is invented during the deprecation window: implicit identity stays the literal label, so nothing a consumer might persist changes shape before you choose explicit ids.) - The blueprint is unchanged in shape — it still clusters by group and emits no banner; only its group-ordering input switches from first-appearance to registry order, which for a migrated quill is identical.
The bundled quills (usaf_memo, cmu_letter, classic_resume) are migrated to
the registry form as worked examples.
ui.group is card-level only; nested properties keep authored order (#941)¶
Two independent schema-authoring fixes to the ui.* block, landed ahead of the
registry above. Both touch only Quill.yaml; documents, wire shapes, and the
blueprint grammar are unchanged.
ui.groupin a nested position is now a load error. Grouping clusters a card's top-level fields; the blueprint's grouping pass never descended into an object's properties or an array's items, so agroupthere was silently inert. It now fails withquill::nested_group_not_supported. Agroupon a card-level field (undermain.fieldsor a card kind'sfields) is unaffected — that is where grouping has always applied.
0.93 (silently ignored) 0.94 (load error)
address: address:
type: object type: object
properties: properties:
street: street:
type: string type: string
ui: { group: Location } # move the group up to the card-level
# field, or drop it
- Typed-dictionary and typed-table-row properties now render in declaration
order. Nested properties previously lived in a
BTreeMapand rendered alphabetically, discarding authored order one level down. Properties now preserve declaration order, the same rule top-level fields follow (see theui.orderremoval below — declaration order is the one ordering contract at every depth). Blueprints and forms for a quill whose nested properties were authored out of alphabetical order will reorder to match the Quill.yaml.
ui.order retires; declaration order is the ordering contract (#941)¶
Field display order stops being a ui knob and becomes purely structural:
the order fields (and nested properties) are declared in Quill.yaml is their
display order, carried by the schema's ordered field maps through parsing,
schema() emission, the blueprint, and seeding. There is no derived integer to
read or author.
0.93 (stamped + overridable) 0.94 (declaration order)
main: main:
fields: fields:
title: # to move a field, move its block
type: string special_field:
ui: { order: 5 } type: string
ui.orderis removed. An authoredui: { order: N }on any field — card-level or nested — is a load error (quill::field_parse_error) with a hint to reorder the fields inQuill.yaml. Reorder by moving the field's block.- The emitted schema no longer carries
order:.QuillConfig::schema()(the surface editors and MCP/LLM consumers read) previously stamped an auto-generatedui.orderinteger onto every field; that key is gone. Read key order of thefields/propertiesobjects instead — it is declaration order, preserved on the wire. A consumer that sorted byui.ordershould walk the map in key order and drop the sort. - Card kinds emit in declaration order too —
schema().card_kindswas alphabetized (aBTreeMap) and now preserves theQuill.yamlorder. - A field with no authored
ui:now has nouiblock. Order stamping used to fabricate one (ui: { order: N }) even when the author wrote none; a field with only structural metadata now serializes without auikey.
The addressed richtext write surface + corpus codec (#925)¶
The richtext write surface was a grid: every verb stamped out once per address
(main / card-at-index), and the fix window for it was still open (nearly the
whole write surface is unreleased). #925 collapses the grid into one codec
layer plus a handful of addressed verbs, and — the field gap that motivated it —
gives richtext fields the anchor-preserving diff_import writer they lacked.
All of this is pre-release reshaping: only three released names churn (the
deprecations below); everything else is added or retired outright.
Layer 1 — the document-free corpus codec¶
Four pure functions (no document, no handle) — wasm free functions / Python module functions:
| Codec | wasm | Python |
|---|---|---|
| markdown → corpus | importMarkdown(md): RichText |
import_markdown(md) -> dict |
| corpus → markdown | exportMarkdown(rt): string |
export_markdown(rt) -> str |
| cold-import + diff | rebase(base, md): { corpus, delta } |
rebase(base, md) -> dict |
| map a position | mapPos(delta, pos, assoc): number |
map_pos(delta, pos, assoc) -> int |
exportMarkdown(body) replaces the eager bodyMarkdown / fieldMarkdown /
cardFieldMarkdown projections — dropped from the Card DTO and the always-
emitted CardWire.body_markdown before either shipped. The precompute is no
longer cheap (the delimiter-safety fix makes to_markdown re-parse every
rendered line), so it is on-demand now. Delta is plain, structured-clone-able
data ({ops: [{retain}|{insert}|{delete}]}) that stores in a change record and
maps positions through with mapPos.
Layer 2 — the addressed content verbs¶
An address locates a richtext value: { card?, field? } in wasm (absent
field = body, absent card = main); the same axis as kwargs in Python
(card=, field=).
| Verb | wasm | Python | Semantics |
|---|---|---|---|
| install | doc.install(addr, rt) |
doc.install(rt, card=, field=) |
value: store exactly this corpus (anchors of the old value gone) |
| revise | doc.revise(addr, md) → Delta |
doc.revise(md, card=, field=) -> dict |
edit: diff_import, anchors rebase, returns the delta receipt |
| applyChange | doc.applyChange(addr, bundle) |
doc.apply_change(bundle, card=, field=) |
editor splice: { delta?, lineOps?, markOps? } |
(An earlier draft added a fourth addressed verb, commit(addr, value, quill),
for typed field writes. #932 deleted it pre-release: typed writes are the
typed writer's job, not the corpus lane's,
and the addressed commit only duplicated commitField / commitCardField
behind an Addr. The corpus lane is content-only — install / revise /
applyChange, no schema in sight.)
The cold (anchor-losing) path is spelled at the call site —
install(addr, importMarkdown(md)) — so anchor loss is visible in source;
revise is the default for "here's new markdown." revise stays a fused verb
(not rebase + install) for atomicity against interleaved mutation.
Retired pre-release (the grid the addressed verbs replace — no deprecation):
| Retired | Use instead |
|---|---|
wasm setBody / setCardBody (corpus writes) |
install(addr, rt) |
wasm replaceCardBody (markdown card body) |
revise({ card: i }, md) |
wasm fieldMarkdown / cardFieldMarkdown |
exportMarkdown(fieldCorpus) |
wasm Card.bodyMarkdown / CardWire.body_markdown |
exportMarkdown(card.body) |
Python set_body / set_card_body |
install(rt, card=, field=) |
Python replace_card_body |
revise(md, card=i) |
Python field_markdown / card_field_markdown / body_markdown |
export_markdown(corpus) |
Core mirrors the vocabulary, not the addressing (wasm-bindgen cannot export
&mut Card, pyo3 classes carry no lifetimes, so only the bindings need the
runtime Addr): Card::install_body(rt) / revise_body(md) -> Delta replace
the set_body_corpus / replace_body / import_body_delta trio, and the field
twins Card::install_field(name, rt) / revise_field(name, md) -> Delta
(new — the field-level diff_import) close the gap where a richtext field had
no anchor-preserving markdown writer. install/revise on a field are
schema-blind (the corpus-writer stratum splices without the quill, like
apply_field_richtext_change); the typed door that enforces richtext(inline)
is the writer's commit_field (reached as writer.set in the bindings — WASM's
commitField ABI, Python's direct core call), and a violation on the
schema-blind path otherwise surfaces at validate/render.
The three released-API deprecations¶
Only these three shipped at v0.92.1, so only these get a one-cycle alias:
| Deprecated (aliased for one cycle) | Alias for |
|---|---|
wasm replaceBody(md) |
revise({}, md) (delta discarded) |
Python replace_body(md) |
revise(md) (delta discarded) |
Python update_card_body(index, md) |
revise(md, card=index) |
Typed field writes: commit_field and the schema-bound writer (#893)¶
The type a write commits belongs in the schema, not the method name — so
there is one typed writer per address that dispatches on the field's
FieldSchema and never grows a per-type sibling. An earlier iteration of this
cycle shipped a richtext-specific writer (set_field_richtext / wasm
setRichtextField / updateCardRichtextField); those were removed
pre-release and folded into the one typed writer below.
| Removed (pre-release) | Use instead |
|---|---|
Card::set_field_richtext(name, &json, inline) |
Card::commit_field(name, value, &field_schema) |
wasm setRichtextField(name, value, inline?) |
wasm commitField(quill, name, value) |
wasm updateCardRichtextField(index, name, value, inline?) |
wasm commitCardField(quill, index, name, value) |
| — (Python had no richtext field writer) | Python quill.writer(doc).set(name, value) / .card(index).set(name, value) (the standalone Document.commit_field / commit_card_field this section first added were deleted by #932 — see below) |
The corpus read twin is Card::field_richtext(name); the markdown projection
is the on-demand exportMarkdown(fieldCorpus) codec (the eager field_markdown
/ wasm fieldMarkdown / cardFieldMarkdown projections retired in #925, above).
The incremental splice is Card::apply_field_richtext_change(name, ..), exposed
at the boundary as applyChange({ field }, bundle).
commit_fieldis not richtext-specific. It commits any field type: a richtext value imports/adopts the corpus (storing the canonical corpus object, so a corpus-only mark likeunderlineand identity ids survive where a markdown projection would drop them), a scalar coerces to its declared type ("3"→3), an array/object coerces element-wise. The write is strict — a mismatch the render floor would silently coerce (aboolinto aninteger, a non-object into anobject) fails now withEditError::FieldConform; richtext fields keep theFieldRichtextDecode/FieldRichtextNotInlinevariants.nullpasses through (null ≡ absent) and reads back as the empty corpus.- The schema carries
inline. A richtextFieldSchemawithinline: trueenforcesrichtext(inline)at the commit — no write-side flag. - The schema-bound writer is the front door.
quill.writer(&mut doc)([TypedWriter]) resolves each field's type itself, so callers issue one verb with no type token:w.set("qty", "3")?,w.card(2)?.set("desc", v)?,w.set_all([..])?(all-or-nothing).setstrict-commits a schema field and rejects a name the schema does not declare withEditError::UnknownField— on the typed path an undeclared name is a typo, not a fallback, so it fails at the write rather than landing silently in the opaque store (#918). Opaque storage stays available on purpose through the rawset_field/setField/setCardFieldverbs. In the bindings the writer is a real object —quill.writer(doc)in WASM and Python alike — that re-borrows the quill + document per call, since wasm-bindgen / pyo3 objects carry no lifetime (#932). The quill owns the schema, so it is the factory on every surface. WASM keeps the per-callcommitFieldverbs as the ABI its writer delegates to; Python's writer calls core directly, so its standalonecommit_fieldwas deleted (#932). Both return nothing on success and throw on an undeclared name. set_fieldis unchanged. The opaque path (store verbatim, coerce at render) stays for keystroke-level state and DB-row batch generators; the split isset_field= coerce at render vscommit_field= canonicalize now, fail now. A richtext field authored as a markdown string in a hand-written.mdfile still imports at render — no file needs editing.
The wire and the storage DTO are unaffected.
[TypedWriter]: the writer module in quillmark-core.
Card bodies close the corpus write surface (#892, reshaped by #925)¶
The non-main card body — the one address commitCardField(quill, index, "$body",
…) can never reach, since $body's reserved $-prefix fails the field-name
check — is written through the addressed verbs like any other: install({ card:
i }, rt) (corpus, value semantics) or revise({ card: i }, md) (markdown, edit
semantics). A corpus-native editor writes every address — main body, main/card
richtext fields, and card bodies — with no per-address method to forget. Card
bodies read back as corpus via cards[i].body (markdown via
exportMarkdown(cards[i].body)). (The intermediate setCardBody /
replaceCardBody writers from an earlier draft of this cycle were retired
pre-release into install / revise.)
On-disk identity is markdown-lossy by design¶
A corpus-valued field projects to a markdown string in emitted card-yaml
(Document::to_markdown), keeping the human-authored surface
markdown-clean rather than embedding a {text, lines, marks, islands} tree. So
identity marks (anchors, island ids) and corpus-only formatting persist across
compiles and the storage DTO (to_json/from_json — the lossless carrier),
but not across a markdown save-and-reload, which re-imports a fresh corpus.
Persistent field anchors (identity marks on corpus field content) are a compile
/ DTO guarantee, not a card-yaml one.
Card-write verbs are mechanical twins of their main-card names (#895)¶
Every card-indexed write now reads verb + Card + noun, sharing its verb
with the main-card writer it twins — the naming rule is mechanical, no per-name
memorization. Three older card writers used update where their main-card
twins used set / replace; they are renamed (no deprecation alias — the
surface is pre-release):
| Renamed | To | Main-card twin |
|---|---|---|
wasm updateCardField / py update_card_field |
setCardField / set_card_field |
setField / set_field |
wasm updateCardFields / py update_card_fields |
setCardFields / set_card_fields |
setFields / set_fields |
Behavior is unchanged — only the names move. setCardField stores opaquely
(the card twin of setField). The card body writer this section originally
renamed (updateCardBody → replaceCardBody) was superseded by #925's
addressed revise({ card: i }, md); Python keeps update_card_body as a
one-cycle deprecated alias for it (it was a released name), while the unreleased
replaceCardBody / replace_card_body were retired outright.
The opaque/typed field write-verb set stays small — set (store opaque),
commit (typed, per #893), remove — with body/richtext writes handled
by the addressed install / revise / applyChange verbs (#925). The already-
mechanical twins (setCardExt, removeCardField, commitCardField) are
unaffected.
wasm Card read/write split: body: RichText on read, CardInput on write (#917)¶
The wasm .d.ts Card shape typed body: RichText | string for both
directions. A read is always canonical RichText (the runtime normalizes on
the way out — never a lazy string), so the union only ever widened the read
type: TS callers had to narrow card.body on every access, and JS callers got
no signal at all. The read and write shapes are now two types:
Read (Card) |
Write (CardInput) |
|
|---|---|---|
body |
RichText (always corpus) |
RichText \| string (markdown imports) |
non-kind fields |
present | optional (absent → default) |
| returned by | main / cards / removeCard / seedMain / seedCard / makeCard |
— |
| accepted by | — | pushCard / insertCard |
- Read
card.bodyis nowRichText. No narrowing, no provenance guess. For the markdown projection callexportMarkdown(card.body)(the eagerbodyMarkdownprojection was dropped in #925). pushCard/insertCardacceptCardInput.bodystill takes a markdown string, and — new — every field butkindis optional, so a baredoc.pushCard({ kind: "note", body: "…" })type-checks (it always ran; the old required-everythingCardparam just mistyped it).- Every
Cardis a validCardInput.pushCard(removeCard(0)),pushCard(seedCard("note")), andpushCard(makeCard(…))all still type-check — a card read from one document pushes straight into another. - Breaking for TS only. A variable annotated
Cardthat held a freshly-built{ kind, body: "md", payloadItems: [] }forpushCardshould be re-annotatedCardInput(or left inferred). Runtime behavior, the storage DTO, and the Python binding (dynamically typed;card.bodyalready returns the corpus dict) are unchanged. (bodyMarkdownis no longer aCardfield — #925.)
CardInput is not the pre-0.88 flat input DTO (a divergent shape, removed in
0.87 → 0.88): it is structurally Card with body widened and the read-only
projections made optional, so the two shapes stay aligned.
Live field edits go through commitField + apply(doc) (#886)¶
There is no incremental field-delta verb. An earlier unreleased iteration added
an experimental applyFieldDelta / mapFieldPos / revision surface — a
per-field change log that mapped a captured position forward across edits; #886
removed it before release. Anchoring a caret or selection across edits is the
editor's own transaction mapping (a ProseMirror / CodeMirror StepMap), not
a parallel core-side position map: positionAt (point → corpus position) and
locate (corpus position → caret rect) are exact inverses over the current
compile and are the whole bidirectional cursor bridge, needing no forward map.
A live editor writes a field through the typed writer
(quill.writer(doc).set(field, value), or the ABI commitField) — or the body
with install / revise — and recompiles with apply(doc). Typst recompiles incrementally either way
(Source::replace + comemo), so a whole-field write costs the same on the
compile as a splice would have — the delta path bought no incrementality it did
not already have. CorpusHit / FieldRegion carry no revision.
The two-tier binding surface (#932)¶
The bindings grew three overlapping write families (the commit* verbs, the
addressed content verbs, the opaque set* primitive) with no stated default.
932 does not add a family — it names a default and promotes the verb that¶
already served it. One sentence decides where every verb lives:
Tier 1 speaks names, values, and markdown. Tier 2 speaks addresses, corpora, and receipts.
A consumer who never needs anchor identity never meets an Addr, a corpus
object, or a Delta. A consumer who does crosses one marked door.
Tiers are strata, not a partition¶
Tier 1 is sugar over tier 2 and the typed-commit path, not a walled-off
region: writer.setBody(md) is revise({}, md) with the receipt discarded,
and writer.set is commitField with the quill bound once. Anything tier 1
writes, tier 2 can write with more control. So the decision tree picks a
default, not a cage — a live editor legitimately writes fields through the
typed writer and bodies/splices through the addressed verbs in the same
interaction. The decision tree:
- Have a quill? Use
quill.writer(doc)— the documented default. Typedset/set_all,setBody,addCard,removeCard,card(i). Names in, markdown in, diagnostics out. - Corpus surgery with anchors? The addressed
install/revise/applyChangeverbs + theimportMarkdown/exportMarkdown/rebase/mapPoscodec — the corpus lane, unchanged and content-only. - No quill? The opaque
setField/setCardFieldprimitive — verbatim storage, coercion deferred to render.
What moved¶
| Change | Detail |
|---|---|
quill.writer(doc) is the front door |
WASM patches writer(doc) onto the re-exported Quill prototype (the Quill === CoreQuill identity holds; the writer owns no wasm handle); Python adds Quill.writer(doc) -> Writer; core already had Quill::writer(&mut doc). |
| Writer gaps closed | setBody / set_body, addCard / add_card (fused make + typed-commit + push, transactional), removeCard, card(i).setBody — mirrored into core's TypedWriter so the parity rows read identical. |
Reads live on Document, quill-free |
get(name) / getMarkdown(name?) — reads need no schema, so they sit on Document, not the writer. getMarkdown re-coins, lazily and by name, the projection the eager fieldMarkdown / bodyMarkdown getters dropped in #925 (the drop was of the eager precompute, not the capability). |
commit(addr, value, quill) deleted |
Unreleased; it only duplicated commitField / commitCardField behind an Addr, dragging a schema into the content-only corpus lane. The typed door is the writer. |
commit* demoted to ABI |
commitField / commitFields / commitCardField(s) stay as the stable ABI under the writer's set / set_all, dropped from the documented surface. |
The hand-written WASM runtime becomes the real API and the wasm class its ABI — committed to, not just tolerated. See BINDINGS.md for the parity table that now governs core-vs-bindings drift.
One idiom difference to know¶
The typed writer is the one shape pyo3 carries worst. Core's TypedWriter holds
&mut Document under the borrow checker; WASM's DocumentWriter and Python's
Writer cannot — they re-borrow per call (a captured quill + document, each
write reconstructing the core writer). The guarantee does not cross the boundary:
a held borrow becomes a convention (writers and card cursors are ephemeral —
bind, write, discard). The parity table classes this row idiom, not
identical.
pdfform's form.json slims to a binding layer — form@0.2.0 (#940)¶
form.json used to restate what the quill schema already carried: a bound
field's type, choice options, and multiline flag duplicated the schema
field's kind, enum values, and ui.multiline. Worst was the silent failure —
a widget bound to a nonexistent schema_field rendered blank with no error.
form@0.2.0 removes the duplication. A bound field carries only where it
sits and what it binds; its widget kind, options, multiline, and tooltip are
derived from the resolved FieldSchema at load. Unbound widgets (a
signer-filled signature) move to a separate widgets section and keep their own
type, since they have no schema field to inherit from.
// form@0.1.0 (retired) // form@0.2.0
{ "schema": "quillmark/form@0.1.0", { "schema": "quillmark/form@0.2.0",
"fields": [ "fields": [
{ "name": "FavoriteColor", { "name": "FavoriteColor",
"schema_field": "favorite_color", "schema_field": "favorite_color",
"page": 0, "rect": { … }, "page": 0, "rect": { … } }
"type": "choice", ], // kind, options, multiline,
"options": ["red","green","blue"] "widgets": [ // tooltip all derived
}, { "name": "Signature",
{ "name": "Signature", "type": "signature",
"page": 0, "rect": { … }, "page": 0, "rect": { … } }
"type": "signature" } ]
] } }
What to change in a form.json¶
- Bump
schematoquillmark/form@0.2.0. Aform@0.1.0file is rejected at load withpdfform::form_schema_version. - On each bound field (one with a
schema_field), deletetype,options, andmultiline— they are derived. Keepname,schema_field,page,rect.tooltipis now an optional override; drop it to inherit the schema field'sdescription. - Move every field without a
schema_field(signatures, signer-filled boxes) into a new top-levelwidgetsarray. These keeptype(andmultiline/optionswhere the type calls for it).
Widget kind is derived from the schema field's capability¶
The projection keys on capability, not the type token — so both type: enum
and the deprecated string + enum: modifier yield a dropdown:
| Resolved schema field | Widget kind |
|---|---|
has enum values (any spelling) |
choice, options = the enum values |
boolean |
checkbox |
string, number, integer, datetime, richtext, plaintext |
text |
| array of the above | text (elements newline-joined) |
object, or array of objects |
load error pdfform::unbindable_field |
Two new load-time errors replace silent blanks¶
Binding now runs at Backend::open, so a bad reference fails loudly instead of
rendering an empty widget:
pdfform::dangling_binding— aschema_fieldpath that does not resolve against the schema (bad root, wrong-shape descent, missing key), naming the failing segment.pdfform::unbindable_field— a path that resolves to a shape no widget can render (anobject, or an array of objects).
$cards absolute-index addressing is removed¶
A bound field addressing a card must use $cards.<kind>.<i>.<field>.
$cards.<i>.<field> (absolute index) is gone: a widget's kind must be derivable
at load, and only the kind names which schema field the slot binds.