0.112 → 0.113 — the pdfform backend becomes acroform, the content vocabularies close, every ~~~ block is a card, reader.get answers in the values form, resolve on the reader, and the storage DTO verbs become toStored / fromStored¶
The pdfform backend is acroform: the crate, the backend id, the cargo
feature and the pdfform::* diagnostic namespace all take the new name, and
there is no alias. Every Quill.yaml declaring backend: pdfform loads clean
and stops rendering with engine::backend_not_found, which no type checker
reports and no test that loads without rendering reaches — so it leads the
sections below.
The content vocabularies close: a stored row holding a line kind,
container, mark type, island type or loss outside the built-ins stops
opening. Nothing in a type checker or a compile reaches that one either — it is
a fact about rows already written — so it follows.
Every column-zero ~~~ block is a card, whatever its info string. A
~~~rust fence in a document body opened an ordinary code block and now opens
a card. And --- front matter no longer opens the root block: --- is
CommonMark's at every position, so a document fenced that way fails with
MissingQuill naming the ~~~ to write instead. These are the other two
breaks a type checker cannot see, and both are about documents already
written, so they follow immediately.
A Content read omits a zero Container.instance, where it spelled one on
every container: the second spelling of the canonical form is gone, and what a
binding hands back is now what storage holds. The value is unchanged — absent is
zero — so this too is a break a type checker cannot report, only a host reading
the key off a read can.
Four more changes. reader.get answers in the values form: a field whose content
sits inside an array or an object reads as text rather than as stored content
objects, and a present-null reads null — one more break a type checker will not
report. quill.resolve(doc) moves onto the reader as reader.resolve().
And the storage DTO verbs are renamed: toJson / fromJson /
loadJson become toStored / fromStored / loadStored (Python:
to_stored / from_stored), and tryFromJson is deleted rather than renamed.
And a Python edit::* diagnostic anchors at its DocPath, the spelling
WASM already carried.
A pruning sweep lands alongside them, each break listed in its own section
below: five retired Quill.yaml keys lose their tailored migration message, an
implicit ui.group becomes a load error, a quill carries the load's warnings
(and the two loader doors that returned them go), .quillignore stops being
read and the ignore set becomes fixed, pdfform::form_schema_version
retires, a vendored Typst package without typst.toml is skipped rather than
given a synthesized spec, three CLI flags go, a block-only island takes its own line in the
model rather than on the way out, fifteen Rust items with no caller leave
quillmark-core and quillmark-content, a payload's nested comments move off
its items and onto the payload, a comment indented inside a multi-line plain
scalar ends it rather than folding away, the crate-compatibility ceremony
— #[non_exhaustive], the Backend seal, public register_backend — is
withdrawn and quillmark-core's one cargo feature goes with it, canvas
capability is deleted at every layer now that a session
paints by construction,
pdfform drops its SVG and PNG output formats, a placed card is reached as a
CardMut rather than a &mut Card, the $ext namespace verbs
collapse into the whole-map three, the producer render option goes while
the /Producer stamp it overrode stays, and a stamped widget carries a baked
/AP — so a flat rasterizer draws its value instead of an empty box, and the
second PDF the form backend flattened for its canvas is deleted. A second sweep prunes binding
surface: quill.metadata loses its <backend>_<key> mirror and the CLI's
info --json with it, five owner calls leave both bindings (tryFromJson,
makeCard, setCardKind, renderTimeMs, formatDiagnostic, and their Python
twins), a Diagnostic drops sourceChain on every surface, JS loses all eleven
content guards and the foreign-handle prototype
patches, a MarkOp's link / anchor payload is spelled where the decoder has
always read it, and Python's CardWriter / CardReader cursors become a
card= keyword on Writer / Reader.
A third group tightens refusals, each in its own section below: the §8 count
caps report a count under parse::too_many_fields / parse::too_many_cards
rather than a byte size, a field write past the §8 field count is refused at the
write rather than at the save, MAX_YAML_DEPTH goes and the write surfaces bound a
value at MAX_JSON_DEPTH (128) where they bounded it at 100, a main: block
parses under the strict card-schema shape a card kind already drew,
Quill::validate refuses every value the render floor refuses, a link or image
url carrying a line ending is refused where it is authored,
RenderOptions::pages means one thing on every backend under backend::*
codes, a raster nobody can allocate is refused rather than attempted, canvas
geometry measures from the page's canvas box, flatten's own parse failure is
acroform::flatten_parse, a card-yaml parse failure locates on Location and
drops args.line, and a Python negative index is an out-of-range index rather
than an OverflowError. Not one of these is a type change: a compile
and a typecheck pass clean over every one, and what moves is a code, a message,
or a verdict — which is the class the ! marker exists for.
If your host never persists a Document, never calls quill.resolve, never
reads a composite content-bearing field or a present-null, never reads a
container's instance off a read, and never routes on
a Python diagnostic's path, there is nothing to do in the first five changes.
From the sweep, the two to check are the implicit ui.group, which is a
load error your quill may hit today, and the CLI flags if you script them; the
Rust removals reach no binding surface. From the third group, the two that
change a verdict on input you already hold are main: and
Quill::validate, and the one that moves a number your overlay reads is the
canvas box, on an acroform background whose page box does not start at
(0, 0).
The three forms¶
A document has three forms, one per question:
| Form | Definition | Read | Write |
|---|---|---|---|
| stored | the at-rest value, verbatim, quill-free | doc.getStored(addr), doc.toStored() |
doc.storeField(addr, v), Document.fromStored() |
| values | stored, with every content leaf decoded to its codec's text | reader.get(name) |
writer.set(name, v), writer.setAll(fields) |
| resolved | values, blank-filled and render-coerced, each cell tagged with its rung | reader.resolve() |
— |
A read never coerces a scalar: qty: "3" is "3" in get and 3 only in
resolve(), because canonicalizing is what a write does.
Why the storage verbs move¶
Both surfaces carry more than one JSON view of a document: the storage DTO,
lossless and frozen per schema version, and the interpreted reads, which hand
content fields back as markdown for a consumer to edit. Both are JSON, so the
encoding is the one thing that cannot tell them apart. toJson named the
encoding.
"The verb carries the lane" already governs the write surface, and getStored
already applies it to the read surface — the verbatim read is the read echo of
store, distinct from the interpreted reader.get. toStored / fromStored
extends that family to the document level: "stored" is the at-rest form
throughout Quillmark, and it is exactly what the DTO carries.
The adjective, not the bare verb. store is one of the three field-write
lanes (store verbatim / set typed / overwrite·revise·apply content), so
a doc.store() / Document.load() pair would collide with a rule that is
already load-bearing. toStored completes the family; store() would break it.
The old names are removed rather than aliased. An alias that keeps working while the neighbouring view means something else is the one failure neither TypeScript nor Python reports.
Migrate: rename the storage call sites¶
Signatures, arguments, return types, and thrown errors are unchanged.
| 0.112 | 0.113 |
|---|---|
doc.toJson() |
doc.toStored() |
Document.fromJson(blob) |
Document.fromStored(blob) |
doc.loadJson(blob) |
doc.loadStored(blob) |
doc.to_json() (Python) |
doc.to_stored() |
Document.from_json(blob) (Python) |
Document.from_stored(blob) |
// 0.112
const blob = doc.toJson();
const restored = Document.fromJson(blob);
// 0.113
const blob = doc.toStored();
const restored = Document.fromStored(blob);
tryFromJson / try_from_json have no 0.113 spelling: see
the owner-call section.
Migrate: quill.resolve(doc) → reader.resolve()¶
The shape is unchanged. A verb that needs a schema lives on the cursor.
Breaking: pdfform is acroform — crate, backend id, feature, codes¶
quillmark-pdf and quillmark-pdfform differed by four characters and read as
prefix-and-specialization, which invited the reading that the form backend was
the spine's only consumer; the Typst backend consumes it too. The backend
takes a distinct name at every layer where the old one was spelled, and the
spine keeps quillmark-pdf and the pdf::* namespace untouched.
| 0.112 | 0.113 | |
|---|---|---|
| crate | quillmark-pdfform |
quillmark-acroform |
Quill.yaml |
backend: pdfform |
backend: acroform |
| backend section key | pdfform: |
acroform: |
quillmark cargo feature |
pdfform |
acroform |
| backend type | PdfformBackend |
AcroformBackend |
registered_backends() |
["typst", "pdfform"] |
["typst", "acroform"] |
| diagnostic namespace | pdfform::* |
acroform::* |
WASM EngineOptions.backends key |
pdfform |
acroform |
| docs page | quills/pdfform-backend.md |
quills/acroform-backend.md |
There is no alias: an unmigrated backend: pdfform loads clean and fails at
render with engine::backend_not_found, whose hint lists the registered
backends; a half-migrated quill — backend: acroform over a leftover
pdfform: section — fails at load with quill::unknown_section.
Migrate: in every form quill's Quill.yaml, backend: pdfform becomes
backend: acroform, and a pdfform: section becomes acroform:. A consumer
routing on a pdfform::* code renames the prefix; the eight code suffixes
(missing_form_pdf, missing_form_json, invalid_form_json,
flatten_parse, flat_parse_failed, unbindable_field, dangling_binding,
field_page_out_of_range) are unchanged. A Rust consumer depending on the
crate by name or enabling the feature renames both. pdf::* codes, form.pdf
and form.json are untouched.
Breaking: the content vocabularies close¶
Every discriminator inside a Content was an open set: a line kind, a
container name, a mark type, an island type or loss this build did not
recognize round-tripped opaque and projected as its nearest safe neighbour. All
five are closed. A name outside the built-ins is refused wherever content is
decoded, and a stored row holding one stops opening.
| Axis | Members |
|---|---|
line kind |
para, heading, code, island, rule |
| container | list_item, quote |
mark type |
strong, emph, underline, strike, code, link, anchor |
island type |
table, image |
island loss |
lossless, degraded, unrepresentable |
No first-party writer ever produced another name: fromMarkdown, revise and
the storage writer mint built-ins only. A row is affected only if a host
authored a name of its own through overwrite, applyChange or a
CardInput.body and stored the result.
The mark axis is closed inside a table island's cells too, not only over
marks. A cell's marks are read leniently everywhere else — a malformed one is
skipped, and still is — but a name outside the vocabulary is refused at the
decoder, because the lenient read is what would make it silent: normalization
re-mints each cell from what it read, so a skipped name would leave the stored
bytes on a mere open. 0.112 carried such a mark; 0.113 refuses the row.
0.112's isUnknownMark reads a cell's marks as well as a line's.
| Door | 0.112 | 0.113 |
|---|---|---|
fromStored / loadStored (from_stored) on such a row |
opens | throws, naming the axis and the name: unknown line kind "callout" |
reader.get / getContent (get_content) on such a field |
the value | edit::field_decode, the message naming the axis and name |
getStored (get_stored) / payloadItems on such a field |
the stored value | unchanged: these echo bytes and never decode |
writer.set, an insertCard body |
stored opaque | edit::field_decode |
overwrite, applyChange |
stored opaque | throws, naming the axis and name |
quill.conform |
silent | a conform::field_decode warning, value left as authored |
Migrate: before upgrading, find such rows and respell or drop the construct
while a 0.112 build can still open them. 0.112's isUnknownLine /
isUnknownContainer / isUnknownMark / isUnknownIsland answer which values
are affected, and they go in 0.113 with the sets they classified.
One pair of surfaces answers quietly, so check them if you rely on either:
getStored and payloadItems echo the stored bytes and decode nothing, so an
authored-but-unconformed value carrying such a name reads back verbatim there
while every decoding door refuses it. storageVersionOf still returns the tag,
so a fromStored throw on a row tagged quillmark/document@0.112.0 is a
content refusal rather than a version mismatch — which is what the README's
storageVersionOf sniff turns on before it reaches fromMarkdown.
The storage tag is unchanged, quillmark/document@0.112.0: every byte the
writer emits is the same, and only the reader's accepted domain narrowed. From
here on, adding a construct is a storage-version event rather than a free pass
through an older reader.
WASM types. ContentLineKind, ContentContainer, ContentMark,
ContentIsland, ContentLossClass and MarkOp lose their open arm, so a bare
discriminant check narrows: line.kind === 'heading' reaches
line.attrs.level with no guard, and a literal like { kind: 'callout' } in a
setKind or an overwrite is a type error. The four isUnknown* guards are
deleted — a read never returns one and a write of one throws — which retires
the last of the content guards, the seven pinned-arm ones having gone with the
owner-call sweep below. MarkOp's link and anchor arms also move their
payload under attrs,
below.
If you carried an unknown, delete the carrier. A host that reads, edits and
writes back needed one per open set. A write restates what the host holds — a
setKind or setContainers carries a line's kind or path whole, an overwrite
the field — so a construct its model could not hold was gone on write-back
unless an inert carrier held it. No decoded content reaches one now, and what it
carried for — a name from a newer build or another host — is refused at
fromStored instead. The deletion reaches past the guards: the carrier's
declaration in your model, both directions of your codec, and any DOM or
clipboard form it crossed in.
A carrier that crossed the DOM is on the clipboard as well. A body copied from a page still running the carrier, pasted into one that has deleted it, meets the rules that remain: the construct is not rebuilt, and nothing refuses it either — the write never holds a name the table above refuses, and the row search above finds rows, not clipboards. What survives is what a paste rule keeps of markup it does not know, which is the text.
Rust. LineKind::Unknown, Container::Unknown, MarkKind::Unknown,
Content::RESERVED_{LINE_KINDS,CONTAINERS,MARK_TYPES},
Invariant::ReservedUnknown{Tag,LineKind,Container},
LineKind::projects_as_para, Fidelity and Loss::fidelity are deleted.
Island::island_type is an IslandType (the enum KnownIslandType was,
renamed) rather than a String, and Loss is the enum Fidelity was, so
Loss::LOSSLESS is Loss::Lossless. ParseError gains UnknownName { axis,
name }, and LineKind::tag / Container::tag / MarkKind::tag return
&'static str. An exhaustive match on any of these is a compile error, which
the #[non_exhaustive] withdrawal below makes loud.
Breaking: --- front matter no longer opens the root block¶
A --- at document start, paired with a later ---, was accepted as a root
block. It is not read as a fence any more: --- is CommonMark's everywhere in
a document, a thematic break or a setext-heading underline. A document fenced
that way now fails with MissingQuill.
This is a fact about documents already written, which no type checker reaches.
The alias was never emitted (toMarkdown always wrote ~~~) and never
documented, so it can only appear in hand- or LLM-authored markdown that has
not been through a round-trip.
| A document opening with | 0.112 | 0.113 |
|---|---|---|
~~~ … ~~~ |
root block | root block |
--- … ---, declaring $quill |
root block | MissingQuill, naming the fence edit |
--- … ---, no $quill |
MissingQuill |
MissingQuill |
a --- below the root block |
composable-card error, or a break | always a break |
Migrate: replace the opening and closing --- of the root block with a
line containing exactly ~~~. The error message says so when the block
declares $quill:
Missing required root card-yaml block. Your document opens with `---` YAML
front matter; card-yaml blocks are fenced with `~~~`. Replace the opening
`---` and its closing `---` with a line containing exactly `~~~` (three
tildes).
Grep a corpus for ^---$ on line 1 to find them. Re-emitting each document
through toMarkdown before upgrading also does it, since that already wrote
the canonical ~~~.
One document gains a parse: a body holding two thematic breaks with a
paragraph starting Word: between them — ---, Note: …, --- — was
refused as a misplaced composable card, and is prose again.
Breaking: every column-zero ~~~ block is a card, whatever its info string¶
A ~~~ opener's info string is no longer read. ~~~card-yaml and ~~~yaml
parsed as cards already; ~~~rust and every other info string opened an
ordinary code block, and now opens a card too. The canonical opener is still a
bare ~~~, and toMarkdown still emits that, so an info string is dropped on
round-trip as the two aliases always were.
This is a fact about documents already written, which no type checker reaches.
The rule the spec states — every column-zero ~~~ block with a blank line
above it is card metadata — is now true without exception, and the tilde escape
hatch it half-promised is gone. A backtick fence is the escape hatch.
| A body containing | 0.112 | 0.113 |
|---|---|---|
```rust … ``` |
code block | code block |
~~~rust … ~~~ |
code block | a card, payload rust's content |
~~~ … ~~~ |
a card | a card |
an indented ~~~lang |
code block | code block |
Migrate: a document whose body holds a column-zero ~~~ code block with a
blank line above it needs its fence changed to backticks. The failure is loud
in almost every case — the code inside reaches the YAML parser and fails
expected a mapping, or fails the field-name rule
/^[A-Za-z_][A-Za-z0-9_]*$/. It is silent only where the block's content is a
well-formed card payload, which is to say a YAML or JSON mapping whose keys are
all bare identifiers. Grep your corpus for ^~~~[^ ] to find them.
Blueprints are checked for you: a body.example containing any column-zero
~~~ line is rejected at Quill.yaml parse time under
quill::body_example_contains_fence, which now catches the language-tagged
form it used to pass.
Breaking: a Content read omits a zero instance, and the TypeScript field is optional¶
Canonical content had two byte forms, differing only in whether a zero
Container.instance was written. Storage omitted it, because absent is what zero
means and omitting it keeps a row written before the field existed byte-identical.
The bindings spelled it on every container, so the published ContentContainer
type could require the field. There is one form now, and it is storage's.
The break no type checker reports. A host that reads a container and hands it
straight back to a write now sends an object with no instance key. That decodes
to 0, exactly as the spelled 0 did, so nothing about the document changes —
but code that read the key off a read finds undefined where it found 0.
| Read | 0.112 | 0.113 |
|---|---|---|
getContent / getContentAt, getStored on a body, importMarkdown, rebase, doc.main.body / cards / card(i) |
{ container: 'quote', instance: 0 } |
{ container: 'quote' } |
| the same, on a run an adjacent sibling would weld with | instance: 1 |
unchanged |
getStored on a field, payloadItems |
already omitted | unchanged |
| a stored blob | already omitted | unchanged |
Writing is unchanged. Both spellings decode, a spelled 0 included, so an
overwrite, a CardInput.body or a setContainers op carrying one is taken
exactly as before — and assignInstances still stamps the field on everything it
returns, zeros included. A writer still owes a distinct value per adjacent
sibling run; that is the rule the helper carries, and it never was the type's.
TypeScript. ContentContainer.instance is instance?: number on both arms.
A literal spelling it compiles, one omitting it compiles, and the as
ContentContainer cast that content parsed from a stored document needed can go —
that shape is now the only shape. This reverses the requirement
0.109 → 0.110 introduced.
Migrate: read the key as c.instance ?? 0 (c.get("instance", 0) in
Python). A host comparing two reads compares them as it always did, both sides
having moved together; one comparing a read against a value taken out of storage
finds they now agree, where the key alone used to part them.
One emit behavior moves with it. toMarkdown projects a content-valued field to
a markdown scalar only when the stored value is byte-identical to the canonical
form, and that guard now has one form to match rather than two. A value carrying
a spelled zero — a 0.112 read, written back verbatim through storeField and
never re-conformed — stays a structural mapping in the emitted card-yaml instead
of becoming markdown. The document round-trips either way, and quill.conform
settles the value.
Rust. serial::to_seam_value is deleted. serial::to_canonical_value is the
one encoder, and serial::container_to_value its container half.
Breaking: the §8 count caps report a count, not a byte size¶
The card-count and per-block field-count caps raised parse::input_too_large,
whose one message shape is Input too large: {size} bytes (max: {max} bytes),
so a block carrying 1,001 fields reported 1,001 bytes. Each cap has its own
variant and code.
| 0.112 | 0.113 | |
|---|---|---|
| 1,001 fields in one card-yaml block | parse::input_too_large, Input too large: 1001 bytes (max: 1000 bytes) |
parse::too_many_fields, Too many fields in one card-yaml block: 1001 (max: 1000) |
| 1,001 cards in a document | parse::input_too_large, the same shape |
parse::too_many_cards, Too many cards: 1001 (max: 1000) |
| a document past a byte cap | parse::input_too_large |
unchanged |
The two new codes carry count and max in args, where input_too_large
carries size and max. parse::input_too_large keeps the byte caps and
nothing else. The caps themselves are unmoved: MAX_FIELD_COUNT and
MAX_CARD_COUNT are 1000.
Migrate: a consumer routing on parse::input_too_large to mean "too big"
gains two codes to route beside it, and one composing its own sentence from
args.size reads args.count on those two. A consumer that never distinguished
them sees only a truer message.
Migrate (Rust): ParseError::TooManyFields { count, max } and
ParseError::TooManyCards { count, max } are new variants. With
#[non_exhaustive] withdrawn in this same release (§ "register_backend
becomes private"), a match over ParseError is exhaustive again and gains two
arms rather than falling through a wildcard.
Breaking: a field write past the §8 field count is refused at the write¶
The card-level field cap (MAX_FIELD_COUNT, 1000) was enforced by the parser
and by both storage doors, but not by the mutators. A program could write the
1,001st field through storeField / storeFill / storeFields (Rust
store_field / store_fill / store_fields), through the typed set /
set_all / addCard, or through revise / overwrite on an absent field, and
the card accepted it — until the document was saved as Markdown, stored, or sent
across the card wire, each of which refused it. Every field write now runs
through one insert that holds the count, so the refusal arrives at the call that
caused it.
| 0.112 | 0.113 | |
|---|---|---|
storeField of a 1,001st field |
stored | edit::invalid_payload, card has 1001 user fields, exceeding the maximum of 1000 |
| overwriting a field a full card already carries | stored | unchanged: a replace is not a growth |
storeFields naming three fields past the cap |
stored | one diagnostic per name in the overflowing tail, nothing applied |
toMarkdown / toStored on a card built past the cap |
refused | unreachable |
The code is the one the card wire already raised for this violation, so a
consumer routing on edit::invalid_payload gains a reachable path to a code it
already handles; the diagnostic anchors at the card that is full (main,
cards.note[2]) rather than at a field, since it is the list and not the value
that is over. The cap itself is unmoved.
Migrate: a program driving a schema with fewer than 1,000 fields per card
cannot reach this. One that fans external data into a card — a database row with
an open column set, a form with user-added keys — checks
len(doc.main["payload_items"]) before the write, or catches the refusal where
it already catches edit::invalid_field_name. A batch is still all-or-nothing:
read the returned names to see which ones did not fit.
Breaking: a quill declaring more fields than a card carries is refused at load¶
MAX_FIELD_COUNT (1000) bounds one card-yaml block, and a card schema is what
seeding and the blueprint build a block from. A Quill.yaml declaring 1,001
fields under main: or under one card_kinds.<name>: loaded clean and rendered
any document staying within the cap. It failed only where the whole card got
built: seedDocument emitted markdown the parser refuses and storage rejects,
and blueprint emitted the same shape. The load refuses it now, in the artifact
that declares it.
| 0.112 | 0.113 | |
|---|---|---|
| loading a quill with 1,001 fields under one card | loads | quill::too_many_fields, 'card_kinds.line_item' declares 1001 fields; a card-yaml block carries at most 1000 |
| loading one with exactly 1,000 | loads | unchanged |
seedDocument / blueprint on the over-cap quill |
markdown the parser refuses | unreachable |
The count is per card and over declared fields alone: a nested properties map,
an array's items, and a variants: cell set each ride inside the one field
declaring them, and main: and the card kinds are never summed. The cap itself
is unmoved.
Migrate: a quill under 1,000 fields per card cannot reach this. One over it
splits the fields across card kinds, or groups related ones under an object
field — nested properties are one field. The diagnostic arrives from
Quill::from_tree / quillmark::quill_from_path (Quill.fromTree in WASM,
Quill.from_path in Python) beside every other quill::* load error, so a host
already rendering diagnostics shows it with no change.
Breaking: YAML nesting depth is the parser's to bound, and MAX_YAML_DEPTH is gone¶
One constant set serde_saphyr's depth budget and bounded host values
crossing into the document, so one number spoke for two unrelated limits. YAML
parsing runs on serde_saphyr's own budget, and §8 Limits fixes no YAML
nesting number. The write surfaces — store_field, store_ext, the wire and
storage DTOs, both bindings' converters — bound a value at MAX_JSON_DEPTH
(128), the depth storage already accepted.
| 0.112 | 0.113 | |
|---|---|---|
quillmark_core::MAX_YAML_DEPTH |
100 |
gone |
a host value 110 containers deep through storeField / store_ext |
refused | stored |
| a host value past 128 deep | refused | unchanged |
Migrate: delete the import; quillmark_core::error::MAX_JSON_DEPTH is the
bound that remains, re-exported from quillmark_content. This direction only widens —
nothing that parsed or stored in 0.112 is refused in 0.113 — so a host that
mirrored the constant to pre-check its own values raises its bound to 128 or
drops the pre-check.
Breaking: a card-yaml parse failure locates on Location, and args drops line¶
parse::yaml_error_with_location carried the failing line twice: once in
args.line, once inside a block-relative at line N (block K) prefix on the
message. Both were the YAML engine's own coordinates, which count lines in the
cleaned block rather than in the document.
The diagnostic now carries a real Location (input.md, line and column) —
translated through the comment lines prescan drops and the leading whitespace
trim removes, so it points at the line the author wrote — and args is
{blockIndex} alone. The message names the block in words (YAML error in the
root card-yaml block: …) in place of the coordinate prefix.
| 0.112 | 0.113 | |
|---|---|---|
args keys |
blockIndex, line |
blockIndex |
line's coordinate space |
the cleaned block | — |
location |
absent | input.md, the document's own line and column |
message prefix |
at line N (block K) |
YAML error in the <name> card-yaml block |
Migrate: read the line off location, not args — and it is a better line
than the one it replaces, being the document's rather than the block's. A
formatter re-wording this code from args alone finds one key where it expected
two; a UI that jumped to args.line jumps to location.line.
Breaking: reader.get answers in the values form¶
reader.get dispatched on the field's declared type, so a field whose
content sat one level in returned the stored content objects while a bare
richtext field returned markdown. It now projects at every content leaf, and
core's ReadValue enum is gone: get returns the plain value.
| Field | 0.112 | 0.113 |
|---|---|---|
array<richtext> |
[{text, lines, marks, islands}, …] |
["Para **one**", …] |
object with a richtext property |
{motto: {text, …}, code: "9"} |
{motto: "Fly **fight**", code: "9"} |
| variant cells | the stored content objects | each cell at its own codec |
a present-null content field (subject:) |
"" |
null (None in Python) |
richtext / plaintext whole-field |
markdown / literal text | unchanged |
| a type tree with no content leaf | verbatim | unchanged |
A leaf that does not decode still throws edit::field_decode, now anchored at
the element (main.paragraphs[1]). reader.getContent and
reader.getContentAt are unchanged — each answers with the Content, where
get projects.
The text carries less than the object did. An anchor mark has no markdown
projection, and an island's id is minted positionally by every importer, so
the values form drops the one and re-mints the other. A host that read an
array<richtext> element off get, edited it and wrote the array back through
writer.set kept both in 0.112; the same loop in 0.113 loses every anchor in
the element and re-identifies every island, and nothing reports it.
Migrate: a host reading a nested content object off reader.get to display
or search takes the text it now gets. One that edits it and writes it back
reads the leaf through reader.getContentAt(addr, path)
(reader.get_content_at(name, path, card=…) in Python) — the 0.112 object, a
zero instance now omitted — and writes the whole field back through
writer.set, an untouched element re-committing byte for byte. A host that
treated a present-null content field as "" reads null now.
New: a content image draws nothing, under a new warning family¶
 in a richtext field reaches no page. What a content
image's url names is undecided — a document is quill-free but for $quill,
which selects a range of versions (memo@1 admits 1.0.0 and 1.1.0 alike), and
declares everything else it references — so the Typst backend lowers an image
island to nothing rather than binding one reading of the string. A plate's own
#image("assets/logo.svg") is unchanged: assets are the plate's to draw.
The drop is legible. Every content field holding images draws one
backend::declined_construct warning on the compile, args
{backend, construct, count} and the field's DocPath in path:
{ "severity": "warning", "code": "backend::declined_construct",
"path": "main.body", "args": { "backend": "typst", "construct": "image", "count": 2 } }
It rides the session's compile warnings — session.warnings() and
RenderResult.warnings — because the compile is what dropped the construct.
It is the observed twin of quill-declared plate::unsupported_construct, sharing
its construct / count vocabulary, and differs in the two ways that matter: it
is per field rather than per body, and a backend mints it from what it saw
rather than a quill from what it declared.
Migrate: a consumer routing on diagnostic codes gains a sixth warning family
and needs an arm for it; one already wording plate::unsupported_construct can
reuse that sentence, adding backend. An editor that offered image insertion for
a Typst quill should stop, or say the image will not print.
Unaffected: storage. An image island still parses, stores, round-trips to
markdown and reaches an editor with its {url, alt} props — ImageProps and the
islandOps insert of a type: 'image' island are as they were. Only the page
declines it, and only until content images have a design.
Breaking: a card the wire refuses raises a coded error¶
insertCard (insert_card) builds a card from a wire, and a violation of the
card's contents used to escape without a code:
err.diagnostics[0].code was undefined in JS, and Python raised a bare
ValueError. Each now carries the code its addressed mutator has always
minted, so the two doors onto one violation route alike.
| Input | 0.113 code |
|---|---|
a field name off [A-Za-z_][A-Za-z0-9_]* |
edit::invalid_field_name |
| a value past the depth bound | edit::value_too_deep |
a !must_fill targeting a mapping |
edit::fill_on_mapping |
a quill that is not name@version |
parse::invalid_quill_reference |
| a body in neither accepted encoding | edit::field_decode |
a duplicate key, a $ entry twice, too many fields, a comment spanning lines |
edit::invalid_payload |
Migrate (Python): a try: … except ValueError around insert_card
catches only the shape failures now — a dict that is not a card
dict, a value with no JSON form. A violation of the card's contents raises
QuillmarkError, like every other engine refusal; route on
exc.diagnostics[0].code. Migrate (JS): nothing to change; a code appears
where undefined used to be. Migrate (Rust): WireError folds its
InvalidField / InvalidPayload variants into one Edit(EditError), and
WireError::code() / to_diagnostic() are what a binding stamps.
Breaking (Python): an edit::* diagnostic anchors at its DocPath¶
Diagnostic.path carried two spellings for one refusal, so nothing could
route on it: writer.set minted none, writer.set_all the bare field name.
Every writer and reader verb now anchors
where WASM already did — the rooted DocPath of
ERROR.md
§ "Document-model paths".
| Call | 0.112 | 0.113 |
|---|---|---|
writer.set("stray", "x") |
None |
"main.stray" |
writer.set_all({"stray": "x"}) |
"stray" |
"main.stray" |
writer.card(0).set("stray", "x") |
None |
"cards.quotes[0].stray" |
doc.set_card_kind(9, "quotes") |
None |
"cards[9]" |
Migrate: a host matching d.path against a bare field name matches
main.<field> now, or reads the name off d.args["field"], which carries it
unescaped. writer.add_card keeps its keys: the card is committed before it
joins the document, so a rejected kind, body or field is $kind / $body /
the bare name, as in WASM.
Breaking: five retired Quill.yaml keys lose their tailored message¶
must_fill, enum, ui.order, the richtext(inline) type token and
markdown were retired across 0.94, 0.104 and 0.108. Each still carried a
hand-written sentence naming its replacement. All five still fail to load,
under the same quill::field_parse_error code, but the message is now serde's
own and there is no hint.
| You wrote | Write instead |
|---|---|
must_fill: true |
a default: or example:, which carries the obligation |
enum: [a, b] |
type: enum with values: [a, b] |
ui: { order: N } |
field declaration order |
type: richtext(inline) |
type: richtext with inline: true |
type: markdown |
type: richtext, or richtext with inline: true |
Migrate: a quill already on the current spelling is unaffected. One on an older spelling failed to load before this release and fails now — only the text changed, so a host routing on the code sees nothing new.
Breaking: main: parses under the same strict card-schema shape as a card kind¶
A main: that is not a mapping, an unknown key under it (feilds:, title:),
and a main.fields that is not a mapping all loaded as a main card with zero
fields and no diagnostic. Each is quill::invalid_card_schema.
main and card_kinds.<name> accept description, fields, ui and body
only, and a malformed ui or body block under either reports
quill::invalid_ui or quill::invalid_body with the hint naming that block's
keys, where a card kind drew the whole-card refusal.
Quill.yaml |
0.112 | 0.113 |
|---|---|---|
main: "x" |
loads with zero fields | quill::invalid_card_schema |
main: carrying feilds: |
loads with zero fields, the typo silent | quill::invalid_card_schema naming the key |
main: { fields: [..] } |
loads with zero fields | quill::invalid_card_schema |
a malformed ui: under card_kinds.note |
quill::invalid_card_schema for the whole card |
quill::invalid_ui, hinting the ui keys |
Migrate: a main: block with a typo in it stops loading rather than
rendering every field blank, which is the failure the silence hid — the
diagnostic names the offending key, so the fix is the key. A Quill.yaml whose
main: is a well-formed mapping of the four accepted keys is unaffected.
Breaking: an implicit group is a load error¶
A ui.group on a card with no ui.groups registry has warned since 0.94 that
it "becomes an error in a future release". It is that release:
quill::implicit_group is now an error, and the registry is a group's one
declaration site.
Read this if your quill loads through WASM or Python. The warning never
reached you. Quill::from_tree dropped the config warnings the loader collects,
and only the CLI's validate took the door that kept them — so on those
surfaces the error is the first notice this deprecation has given. That
transport is fixed in this same release (below): from 0.113 the channel works,
but it works too late to have warned you about this one.
main:
ui:
groups: [addressing, letterhead] # add this
fields:
subject: { type: string, ui: { group: addressing } }
Migrate: declare every group id a card's fields reference. Ids are
snake_case (quill::invalid_group_id); a display label goes in the group's
title:. Field order within a group is unchanged, and group order now follows
the registry rather than first appearance — identical for a quill whose
registry lists groups in the order its fields first mention them.
Breaking: a quill carries the load's warnings, and two loader doors go¶
Quill::warnings() is new — quill.warnings in WASM and Python — and answers
the advisory diagnostics the load collected. They ride the quill, so every
construction door keeps them and a host reads them whenever it likes.
That makes the two doors that existed only to return them redundant, and both are deleted:
| Gone | Reach it by |
|---|---|
Quill::from_tree_with_warnings |
Quill::from_tree, then quill.warnings() |
quillmark::quill_from_path_with_warnings |
quillmark::quill_from_path, then quill.warnings() |
Migrate: a caller of either takes the plain door and reads the getter; the
tuple destructuring becomes one binding. A host that never wanted the warnings
changes nothing. The channel's output is quill::implicit_group (an error from
this release, so no longer seen here) and quill::body_example_unused, and any
advisory added later arrives the same way.
Breaking: Quill::validate refuses every value the render floor refuses¶
Validation judged a floor refusal by the authored value's own shape, and two
shapes read well-typed there. Both audited clean while compile_data and
dry_run refused them, so the validate / dry_run pairing an editor runs on
gave two verdicts for one value.
| value | 0.112 validate |
0.112 render | 0.113 validate |
|---|---|---|---|
a content object that is not canonical content ({prose: …}) on a richtext or plaintext field |
clean | refused | validation::type_mismatch at the field's path |
an integer past i64 on an integer field |
clean | refused | validation::type_mismatch, actual: number |
A leaf the floor cannot conform is a validation::type_mismatch at the field's
path, unless a shape check already names the refusal
(validation::not_inline, validation::not_plain,
validation::format_violation). A container's refusal stays the element's or
the property's, at its own path. A numeric literal past i64 reports
actual: number — the type that does carry it — and such a literal in a
default: or example: is a load error rather than a validation error.
Migrate: an editor that validates and then renders gets one verdict where it
got two, and the new errors are exactly the values a render already refused —
there is no shape validate now rejects that dry_run accepted. A host feeding
validate values it never renders should expect the render floor's verdict,
which is the point of the pairing.
Breaking: .quillignore is not read, and the ignore set is fixed¶
A disk load no longer looks for a .quillignore at the bundle root. The file is
an ordinary file now: it is walked into the tree like any other, and the rules
it spells do nothing.
What a walk skips is the built-in set, which is all that is left of
QuillIgnore: .git/, target/ and node_modules/ with their subtrees,
anchored at the bundle root, and .gitignore wherever it sits. .quillignore
itself leaves that set — the loader has no reason to hide a file it no longer
reads.
QuillIgnore::new and QuillIgnore::from_content go with the format;
QuillIgnore::default() and is_ignored stay, and the type is now a unit
struct.
Migrate: a bundle that relied on the file to keep something out ships that
something now, so move it out of the bundle directory instead — the loader has
no per-bundle exclusion. A bundle whose file only restated the built-in set
(the common case) loads the same tree, plus the .quillignore file itself;
delete it. Rust callers constructing a rule list of their own filter the walked
tree instead.
Breaking: a vendored Typst package needs its typst.toml¶
A packages/<dir>/ inside a quill with no typst.toml was loaded under a
synthesized @local/<dir>:0.1.0. It is skipped now, with a
typst::package_manifest warning naming the directory — the same code the
malformed-manifest and bad-version cases already raise.
The synthesized spelling was never documented, so a quill relying on it was
relying on read source. It also behaved differently from an identical package
with a manifest: passing no entrypoint, it skipped the
typst::package_entrypoint_missing check.
Migrate: add the file the fallback stood in for. The manifest parser
defaults namespace to local, version to 0.1.0 and entrypoint to
lib.typ, so this reproduces the old spec byte for byte:
Spell out version and entrypoint too if the package ships something other
than a lib.typ at 0.1.0. Without the file, the plate's
#import "@local/<dir>:0.1.0" fails as an unresolved file, and the load
warning above says why.
Breaking: pdfform::form_schema_version retires¶
A form.json tagged quillmark/form@0.1.x was rejected with a pointer to the
0.93→0.94 guide. That format was retired four minors ago; the tag is now simply
unrecognised.
| 0.112 | 0.113 | |
|---|---|---|
| code | pdfform::form_schema_version |
acroform::invalid_form_json (the namespace renames in this step too) |
| message | names the retired format and its migration guide | the schema tag is not the expected quillmark/form@0.2.0 |
Migrate: a consumer routing on pdfform::form_schema_version loses that
arm — the file still fails to load, under the code every other malformed
form.json already used. Migrating the file itself is unchanged, and the
0.93→0.94 guide still describes it.
Breaking (CLI): three flags go¶
render --verboseis deleted, with its nine progress lines.--quietno longer means "and not--verboseeither": it suppresses the warning block on stderr and theOutput written to:line on stdout. Errors are unaffected.schema -oandblueprint -oare deleted; both commands write to stdout.quillmark schema ./quill > schema.yamlis the replacement.render -ois unchanged.validatereadsplate_filefrom the loaded quill instead of the filesystem. A plate the load excludes — an ignored path, a symlink — now fails validation, which is what rendering it already did.cli::plate_file_escapes_quillandcli::plate_file_missingstay distinct and keep their messages.
Breaking: a block-only island takes a line of its own in the model¶
A block-only island (a table) whose slot shared its line with prose used to be
repaired on the way out: to_markdown broke the line, and the model kept the
paragraph. So the model could hold a shape markdown cannot spell, and the rule
lived only on the write.
Content::normalize performs the break now, and the export writes the lines it
is given.
No stored document fails to load. A blob carrying the shape loads already
split — exactly the content to_markdown would have written from it — with its
marks rebased through the inserted newlines. What changes is when you see it: a
read after load returns the split content rather than the paragraph.
Migrate: a host that reads content positions back after loading such a blob
sees the split line count and rebased marks immediately rather than after the
first export. An accepted LineOp::Join that runs a slot back into its prose is
taken apart again by the mint. The lanes that author an island still refuse the
placement up front with ApplyError::BlockIslandNotAlone.
Breaking: a line op the text or block contradicts lands and the mint settles it¶
setKind and setContinues refused what Content::normalize repairs anyway at
the end of the same bundle. Both land now, and the mint answers:
| Op | Was | Is |
|---|---|---|
setKind: island / rule over prose |
throws | Ok, the line is para |
setKind: code on a slot-bearing line |
throws | Ok, the line settles on what the slot spells |
setContinues: true on line 0 |
throws | Ok, the flag is cleared |
setContinues: true across a container change |
throws | Ok, the flag is cleared |
setContinues: true after a heading, island or rule |
throws | Ok, the flag is cleared |
Two things a type checker cannot report. Ok no longer means the op landed as
written — it means the bundle was accepted and the mint settled it, which for
every row above is a value other than the one asked for. And one row costs text:
a heading retagged island or rule is a paragraph afterward, its # gone
from the projection, where the throw used to leave the heading standing.
Migrate: an editor that mirrored an op into its own model on Ok must read
the content back after applyChange — body.lines[i].kind and
.continues — or pre-check before sending: a kind against the line's text (an
island line is exactly one , a rule line is empty, a code line
holds no slot), and continues against the line above's containers and
kind. A cleared flag is absent from the wire, not spelled false. An editor
that greyed a control out on the throw needs the same pre-check to keep doing
so. setKind still refuses a heading level outside 1..=6
(ApplyError::BadHeadingLevel), and the island ops still refuse a block
island's slot mid-line (ApplyError::BlockIslandNotAlone).
Rust: ApplyError loses LineKindMismatch, ContinuesAcrossContainers,
ContinuesSingleLineBlock and FirstLineContinues; Invariant loses those
last three plus ZeroWidthFormatting, MarkEdgeOnNewline, LineKindMismatch,
the four Table* shapes and BlockIslandNotAlone; LineKindMismatch and
IslandType::shape_error are deleted, and model::line_kind_mismatch becomes
the crate-internal predicate the mint asks. Content::validate reports what
normalization cannot repair, and nothing else. The diagnostic code is
unchanged — every ApplyError reaches a host as edit::content_apply — so only
an exhaustive match on these enums stops compiling.
No stored document fails to load, and one that failed now loads: a blob
spelling continues: true on line 0 opens with the flag cleared. Nothing
first-party wrote one — import mints line 0 false and every door validated
after the mint — so the rows this reaches are rows that never opened. No
loadable row's bytes move.
Breaking: a link or image url carrying a line ending is refused where it is authored¶
CommonMark admits no line ending in a destination, bare or angle-wrapped, so a
url of "a\nb" exported as [t](<a\nb>), which pulldown reads as an inline
HTML tag: the re-import came back [t]() with the mark gone, and an image the
same way with its island gone. The model could hold a link the projection
dissolved.
An authored lane that stores a url refuses one, the way an unwritable
code-fence lang is refused:
| Door | 0.112 | 0.113 |
|---|---|---|
MarkOp::Add of a link |
accepted, the mark lost on the next round trip | refused at the op |
IslandOp::Insert / IslandOp::Set of an image |
accepted, the island lost | refused at the op |
overwrite, CardInput.body |
accepted | refused |
MarkOp::Remove of a link |
accepted | unchanged — it matches on kind equality against a mark the field already holds, so a legacy link stays removable |
| a stored row already holding one | loads | unchanged; storage stays lenient |
to_markdown of such a row |
markup that re-imports without the mark | the line ending written as %0A / %0D |
Migrate: strip the line ending, or percent-encode it yourself, before the op — a url is a destination, and a destination has no line in it. Nothing on the read side changes: a row authored under 0.112 with such a url still loads, and now exports to markdown that survives its own re-import.
Breaking (Rust): fifteen items with no caller leave core and content¶
A pruning sweep over quillmark-core and quillmark-content. Every item below
had no caller outside its own tests, and none of them is reachable from the
WASM, Python or CLI surface — a host on a binding has nothing to do here.
| Gone | Reach it by |
|---|---|
Document::to_plate_json |
nothing public. The plate JSON is what a render hands a backend; to_plate_json_gated is crate-internal, and the export was never a storage or interchange format |
Document::card_kinds |
iterate doc.cards() and read Card::kind |
impl IntoIterator for &Payload |
payload.iter() |
MetaKey::ALL, MetaKey::is_root_only |
name the key: card.seed().is_some(), or match MetaKey::Seed |
PathStepWire, CommentPathSegment |
PathSegment, which carries the untagged serde form itself |
YamlError::line, column, hint |
err.to_diagnostic(code, file), whose location and hint carry all three |
RenderedRegion::contains |
region.distance(page, x, y) == Some(0.0) |
quillmark_core::error::print_errors |
the loop it was: for d in err.diagnostics() { eprintln!("{}", d.fmt_pretty()) } |
Delta::apply |
try_apply, which returns the base-length mismatch apply panicked on |
ChangeBundle::from_delta |
ChangeBundle { delta, ..Default::default() } |
quillmark_content::normalize_markdown |
nothing public. import::from_markdown runs it on its input, so an importer needs no pass of its own |
Two signatures move with them: normalize::normalize_document returns a
Document rather than a Result it never filled, and
PayloadItemWire::Field's nested_fills is Vec<Vec<PathSegment>>.
The wire is unchanged. A nested-fill path serializes as it always has, a JSON string per key and a JSON number per index, so no stored blob and no binding payload moves a byte.
Two additions come out of the same sweep: RenderError::coded_hint, the
coded-plus-hint shape four backend.rs refusals built by hand, and
region::nearest_region, the tolerant nearest-region search SessionHandle::field_at
and the Typst backend each carried a copy of.
Migrate: the table's right column is the whole of it. A consumer matching
exhaustively on PathStepWire matches PathSegment instead — the same two
variants, with the same payloads.
Breaking (Rust): a quillmark-content item is named at the module that defines it¶
quillmark-content declared its ten modules pub and re-exported 34 of their
items at the crate root, so quillmark_content::Delta and
quillmark_content::delta::Delta both resolved. The root re-exports go and the
modules stay public, which leaves Rust's own rule as the whole of it: an item is
reachable where it is defined. MAX_NESTING_DEPTH and MAX_JSON_DEPTH keep
their root spelling, the crate root being where they are defined.
| Gone from the root | Reach it by |
|---|---|
Assoc, Delta, Op, diff_import |
delta:: |
to_markdown, to_plaintext |
export:: |
from_markdown, from_plaintext |
import:: |
IslandType |
island:: |
Container, Content, Invariant, Island, Line, LineKind, Loss, Mark, MarkKind, Normalized, Usv |
model:: |
ApplyError, ChangeBundle, IslandOp, LineOp, MarkOp, change_bundle_from_value, island_op_from_value, line_op_from_value, mark_op_from_value |
ops:: |
ParseError |
serial:: |
Span, items, runs, segment |
traverse:: |
Migrate: prefix the module. Every removed path is an unresolved import, so
the compiler names each site. quillmark-core re-exports these names itself, so
a consumer on core reaches them there — quillmark_core::Content, the
session:: delta and op types, error::MAX_NESTING_DEPTH and
error::MAX_JSON_DEPTH — under the core spellings the next section settles. No
binding surface moves.
Breaking (Rust): a quillmark-core item is named at the module that defines it¶
The same shape, one crate up. quillmark-core declared its thirteen modules
pub and re-exported 77 of their items at the crate root, so
quillmark_core::Document and quillmark_core::document::Document both
resolved. The root re-exports go and the modules stay public, which leaves
Rust's own rule as the whole of it: an item is reachable where it is defined.
Content and Normalized keep their root spelling: they are
quillmark-content types, and the root is the only path core offers them.
| Gone from the root | Reach it by |
|---|---|
Backend, DECLINED_CONSTRUCT, MAX_RASTER_PIXELS, check_raster, declined_construct, page_selection_not_supported, raster_scale, selected_pages, unsupported_format |
backend:: |
Card, CardMut, CardWire, Document, EditError, ImportError, Parsed, Payload, PayloadItem, PayloadItemWire, SeedOverlay, WireError |
document:: |
Diagnostic, Location, ParseError, RenderError, RenderResult, Severity, YamlError |
error:: |
DocPath, DocSeg |
path:: |
BoundParseError, CardSchema, FieldSchema, FieldSource, FieldType, FileTreeNode, Quill, QuillConfig, QuillIgnore, Resolved, ResolvedCard, ResolvedField, ResolvedMain, ValidationError, blank |
quill:: |
CardReader, TypedReader |
reader:: |
ContentHit, HitGranularity, RenderedRegion, doc_path_to_plate_addr, field_boxes, nearest_region, plate_addr_to_doc_path, regions_to_doc_path |
region:: |
ApplyError, Assoc, ChangeBundle, ChangeSet, Delta, IslandOp, LineOp, LiveSession, MarkOp, Op |
session:: |
Artifact, OutputFormat, RenderOptions |
types:: |
PathSegment, QuillValue, json_depth_exceeds |
value:: |
QuillReference, Version, VersionSelector, quill_ref_hint |
version:: |
CardWriter, TypedWriter |
writer:: |
quillmark::orchestration goes with them. Quillmark is named at the facade
root, which is the one path the facade offers.
Migrate: prefix the module. Every removed path is an unresolved import, so
the compiler names each site — 328 of them across this workspace. A consumer on
the quillmark facade has nothing to do: it re-exports every name it did, now
from the module-qualified core paths, and tests/facade_surface.rs holds that
list. No binding surface moves.
Breaking: a comment inside a multi-line plain scalar ends it¶
Prescan used to delete comment lines from the YAML it hands the parser. It now leaves them in place — they are comments to the parser too — which makes the parsed string line-for-line with the fence content. One document shape changes verdict:
This folded to key: "aaa bbb", a value no YAML parser reads out of that
document (a comment ends a plain scalar, so bbb is a mapping line with no
key). It is now a located parse::yaml_error anchored at bbb.
Every other comment position is unchanged, block scalars included: a comment
line inside a literal or folded block is the scalar's own text and always
was, and a comment line after one ends it without adding to it — including
under keep chomping (|+ / >+), where a blank line in that position would
be content.
Migrate: put the comment on its own line at the mapping's indentation, or
quote the scalar (key: "aaa bbb") if the fold was what you meant.
Breaking (Rust): an enum's domain rides the type token¶
FieldType::Enum carries its domain, and FieldSchema::enum_values is gone:
// Before: the token said "enum", a sibling field carried the members.
FieldSchema { r#type: FieldType::Enum, enum_values: Some(vec!["CUI".into()]), .. }
if let Some(values) = &field.enum_values { /* … */ }
// After: one carrier.
FieldSchema { r#type: FieldType::Enum { values: vec!["CUI".into()] }, .. }
if let FieldType::Enum { values } = &field.r#type { /* … */ }
Migrate: match the token where you have it, and call the new
FieldSchema::domain() -> &[String] where you do not — a variant-bearing
branch, reached through variants:, never matched the token. domain() answers
empty for every non-enum type, so read it only where the field is an enum;
whether a field is one is matches!(field.r#type, FieldType::Enum { .. }). A
FieldType::Enum pattern needs { .. }, and FieldType::from_str("enum")
yields an empty domain that the sibling values: key fills in at
FieldSchema::from_quill_value, exactly as inline: fills a prose type's
payload.
Two equality notes: two FieldType::Enums with different domains are now
unequal (FieldSchema equality already compared domains, so nothing changes
there), and FieldType is no longer a payload-free token for any variant except
the scalars.
No quill loads differently, and no stored or wire byte moves. values: is
still the one spelling of a domain, still required non-empty on type: enum
(quill::field_parse_error), and still a load error on any other type; the
schema projection, blueprint annotation, pdfform widgets and validation all
behave as before. What changes is only reachable from Rust: a hand-built
FieldType::Enum { values: vec![] } is a real domain of no members, and an
empty domain admits only the blank — so a non-blank value against one is an
enum_violation, and pdfform binds it as a dropdown offering the blank alone.
Previously such a field could not be expressed without also setting
enum_values, and the three consumers that met it disagreed about what it
meant.
Breaking (Rust): nested comments hang off the payload, not each item¶
PayloadItem::Field and PayloadItem::Meta lose their nested_comments
field. The comments inside a structured value now live in one list on the
Payload, at paths whose head segment names the owning entry — a field's
key, or the literal $ext / $seed:
// Before: relative to the item's own value, one Vec per item.
for item in card.payload().items() {
if let PayloadItem::Field { key, nested_comments, .. } = item {
for nc in nested_comments { /* nc.container_path is relative to `key` */ }
}
}
// After: one list, each path rooted at the entry that owns it.
for nc in card.payload().nested_comments() {
// nc.container_path[0] is PathSegment::Key(<field key or "$ext"/"$seed">)
}
This is the shape both ends already spoke — prescan produces it and the storage
DTO stores it — so the two converters between them are gone. Payload gains
nested_comments() for reading, and rename_field, which carries a field's
comments with its key; items_mut is withdrawn, having existed only for the
rename that now has a verb.
The wire is unchanged. PayloadV0_92_0.nested_comments was already a flat
payload-level sidecar with absolute paths, so no stored blob moves a byte, and
nothing on the WASM, Python or CLI surface reads these at all.
Migrate: read from payload.nested_comments() and match the head segment
instead of iterating items; rename a field with payload.rename_field(from, to).
Breaking (Rust): a placed card is reached as a CardMut, not a &mut Card¶
Document::main_mut returns CardMut<'_> and Document::card_mut returns
Option<CardMut<'_>>; Document::cards_mut is gone. CardMut forwards every
&mut self verb Card carries and Derefs to Card for the reads, so a
chained call compiles unchanged:
doc.main_mut().store_field("title", "Q3")?; // unchanged
doc.card_mut(0).unwrap().revise_body("…")?; // unchanged
What it withholds is the one write no gate polices: assigning a whole card over
a placed one. *doc.card_mut(0).unwrap() = doc.main().clone() put $quill and
$seed on a composable card past push_card's placement check, and
*doc.main_mut() = Card::new("note")? took $quill off the root, which
Document::quill_reference reads without re-checking — a panic on the next
bound call. Neither compiles now. No binding surface exposed a &mut Card,
so a host on WASM or Python has nothing to do here.
Migrate: a binding that named the type (let c: &mut Card = doc.main_mut())
becomes let mut c = doc.main_mut(), mut because a CardMut is a value
rather than a reference. Replacing a card wholesale is remove_card +
insert_card, which runs the placement check. A slice operation over
cards_mut() is move_card, remove_card and insert_card; reading every
card is still cards(), and card(i) reads one.
Breaking (Rust): register_backend becomes private, and the Backend seal and #[non_exhaustive] go¶
Three mechanisms held the Rust crate API stable for an out-of-workspace consumer. All three go. A host on a binding has nothing to do here.
One thing stops compiling: Quillmark::register_backend is private and
quillmark_core::backend::sealed is deleted, so a crate that registered its own
backend cannot. That path was documented as unsupported and was already
half-shut — Backend::open returns a LiveSession, which only a
#[doc(hidden)] SessionHandle builds. Quillmark::new registers one backend
per enabled cargo feature and is now the whole registry;
Quillmark::registered_backends() still reports what it holds.
Everything else widens. No type in the workspace carries #[non_exhaustive]:
| 0.112 | 0.113 | |
|---|---|---|
a struct literal of a pub-field type |
refused | compiles |
RenderOptions { .., ..Default::default() } |
refused | compiles |
| exhaustive destructuring | refused | compiles |
an exhaustive match over a public enum |
needs a _ arm |
compiles, and a variant added later is a compile error |
Migrate: a _ arm keeps compiling wherever the arms above it leave a
variant to reach. Where they leave none, rustc reports an unreachable pattern
and deleting the arm is the fix — worth doing, because that arm is what a missed
variant hides behind. new plus the with_* setters are unchanged and stay the
shape for building a value you do not own every field of.
prose/canon/COMPATIBILITY.md goes with the attribute it governed: a SemVer
promise to crates.io consumers that no CI job checked. The pub seam that
exists for the workspace rather than for a consumer — Backend +
SessionHandle — is described in
ARCHITECTURE.md
§ "Backend Implementation".
Breaking (Rust): quillmark-core declares no cargo features¶
internal-test-seam is gone, and with it the crate's whole [features] table.
It gated one method, LiveSession::update_data, which is now compiled into
every build under #[doc(hidden)] — the spelling this workspace already uses
for the seams that exist for a backend rather than for a consumer
(SessionHandle, LiveSession::new). A host on a binding has nothing to do
here.
A feature was the wrong shape for the intent. A cargo feature is public surface
itself, advertised by crates.io and docs.rs, so the gate moved the opt-in from a
source read to a Cargo.toml line rather than removing it. What it bought — an
accidental call failing to compile — the crate already gives away next door:
LiveSession::new and SessionHandle are #[doc(hidden)] pub in every build,
and a session assembled through them reaches the same unchecked update.
Migrate: drop features = ["internal-test-seam"] from the
quillmark-core dependency line; a dependency naming no other feature becomes
quillmark-core = "0.113". update_data is unchanged — plate data straight to
the backend, no $quill check and no compile — and reaching it now needs
nothing.
Breaking: canvas capability goes, at every layer¶
Canvas is no longer something a backend has or lacks. SessionHandle's
page_size_pt and render_rgba are required, so every session paints and
there is nothing left to answer about — both the pre-session probe, which
answered for the backend rather than for the compile, and the session's own
derived answer are deleted:
| Gone | Reach it by |
|---|---|
engine.supportsCanvas(quill) (WASM) |
engine.open(quill, doc), then paint and handle the throw |
session.supportsCanvas (WASM) |
session.pageSize(0) / session.paint(…), which throw when there is nothing to paint |
canvas in a BackendDescriptor |
drop the key; formats is the whole manifest now |
Quillmark::supports_canvas(&quill) (Rust) |
session.page_count() > 0, or paint and handle the Err |
quillmark_core::formats_support_canvas (Rust) |
session.page_count() > 0 |
LiveSession::supports_canvas() (Rust) |
session.page_count() > 0, which is what it computed |
The probe keyed on output formats — true iff the backend emitted PNG or SVG —
and canvas paint is a SessionHandle seam a backend overrides independently of
the formats it emits, so the two could disagree. Every backend the workspace
ships paints, so the probe answered true in every build.
Migrate (JS): delete the probe call and mount the canvas UI on the session
you opened. paint and pageSize already throw for a compile with nothing to
paint, naming the page index and the pageCount that excludes it, and that
throw is now the whole contract — a consumer that probed still needed it,
because a backend can compile a zero-page document. A registry entry keeps working with
canvas left on it (an unknown key is ignored), but canvas is no longer
required and no longer validated.
// 0.112
if (await engine.supportsCanvas(quill)) mountCanvas(await engine.open(quill, doc));
// 0.113
const session = await engine.open(quill, doc);
try { session.pageSize(0); mountCanvas(session); }
catch { showNoPreview(); }
Migrate (Rust): Quillmark::supports_canvas(&quill),
quillmark_core::formats_support_canvas and LiveSession::supports_canvas()
are gone. The last of the three was page_count() > 0 && page_size_pt(0).is_some(),
whose second half is now always true of a counted page, so write
session.page_count() > 0 where you called it — or skip the question and let
page_size_pt / render_rgba answer None for the page you asked about.
Breaking: the form backend emits PDF alone¶
quillmark_acroform's (quillmark_pdfform before this step's rename)
supported_formats reports [Pdf]. SVG and PNG were
views of the flattened form, consumed by nothing but their own test, and each
now fails with backend::format_not_supported — the refusal core already gave
every other format outside a backend's list.
| 0.112 | 0.113 | |
|---|---|---|
supported_formats(&pdfform_quill) |
[Pdf, Svg, Png] |
[Pdf] |
render(.., format: Svg \| Png) |
one artifact per page | backend::format_not_supported |
RenderOptions::pages on a pdfform quill |
narrows SVG/PNG output | backend::page_selection_not_supported — see the next section |
quillmark render --format png on a pdfform quill |
a PNG per page | the same refusal |
Canvas paint is unchanged. render_rgba stays, the hayro dependency with
it, and a WASM consumer paints pdfform pages exactly as before: paint is a
SessionHandle seam, not an output format. The hayro-svg dependency goes,
since the SVG artifact path was its only caller.
Migrate: a consumer rasterizing a filled form takes the canvas seam
(session.paint) or renders the PDF and rasterizes it itself. A Typst quill's
SVG and PNG are unchanged.
Breaking: RenderOptions::pages means one thing on every backend, under backend::* codes¶
The form backend ignored the option: it rendered every page whatever was asked
for, and an out-of-range index passed silently. It refuses a selection now, PDF
being its one format. Both backends mint the two refusals from the shared
constructors in quillmark_core::backend, so the codes are
backend::page_index_out_of_bounds and backend::page_selection_not_supported
in place of the Typst-private typst::* pair.
| 0.112 | 0.113 | |
|---|---|---|
pages: [99], Typst quill, SVG or PNG |
typst::page_index_out_of_bounds |
backend::page_index_out_of_bounds |
pages: [0], Typst quill, PDF |
typst::page_selection_not_supported |
backend::page_selection_not_supported |
pages: [0], acroform quill |
ignored; every page rendered | backend::page_selection_not_supported |
pages: [99], acroform quill |
ignored | the same refusal |
pages: None |
every page | unchanged |
Both messages carry the offending indices or the format, and the selection comes back as given: order and repeats stay the caller's.
Migrate: a consumer routing on the typst::* pair reads the backend::*
pair, which is now the code for the same refusal from any backend. A host that
passed pages to a form quill expecting a subset was getting the whole document
and can drop the argument — the refusal says so instead of rendering something
other than what was asked for.
Breaking: a raster nobody can allocate is refused, not attempted¶
RenderOptions.ppi and the render_rgba canvas scale reached tiny-skia and
hayro unchecked. Both size their buffer from the value and unwrap it, so
ppi: Infinity or 1e9 panicked the process — in WASM a trap that takes the
engine instance with it — while NaN, zero and a negative quietly rasterized a
1×1 image.
Every raster path refuses, under backend::invalid_raster_scale, a scale that
is not finite and positive or that would put a page past MAX_RASTER_PIXELS
(16384² px: a 1 GiB RGBA buffer, and the area of the WASM painter's per-side
clamp, so nothing that clamp admits is refused). US Letter at the default 144
ppi is 138× under it.
| 0.112 | 0.113 | |
|---|---|---|
ppi: Infinity, ppi: 1e9 |
a process panic; in WASM the engine instance traps | backend::invalid_raster_scale |
ppi: NaN, 0, a negative |
a 1×1 image | the same refusal |
render(pages=[-1]) (Python) |
OverflowError |
see § "a negative index is an out-of-range index" |
ppi: 144 on US Letter |
a raster | unchanged |
Migrate (Rust): SessionHandle::render_rgba and LiveSession::render_rgba
return Result<Option<(u32, u32, Vec<u8>)>, RenderError> to carry the refusal;
Ok(None) is still the out-of-range page, so a caller that matched the Option
keeps that arm and gains the Result.
Migrate (hosts): a UI handing a user-typed zoom or DPI straight to ppi
gets a diagnostic where it got a blank image or, at the top of the range, a dead
engine. Nothing that rendered a real page in 0.112 is refused.
Breaking: canvas geometry measures from the page's canvas box¶
hayro rasterizes /CropBox ∩ /MediaBox and draws that box's lower-left corner
at the raster's origin, while page_size_pt reported the /MediaBox extent and
regions() reported widget /Rects in raw user space. An overlay over a
pdfcropped background (/MediaBox [96 133 500 700]) therefore sat 96 × 133 pt
off its ink, and a /CropBox inside the MediaBox reported a page bigger than its
own raster.
page_size_pt is the canvas box's extent, regions() subtracts its lower-left
corner, and form.json's top-left rects flip against it, so pageSize,
regions, the point queries and the raster share one origin.
| background | 0.112 | 0.113 |
|---|---|---|
a page box starting at (0, 0) |
correct | unchanged — most quills are here |
/MediaBox [96 133 500 700] |
pageSize 500 × 700; regions 96 × 133 pt off the ink |
pageSize 404 × 567; regions on the ink |
a /CropBox inside the /MediaBox |
a page bigger than its raster | the raster's own extent |
| a canvas box under a point per side | a degenerate raster | pdf::degenerate_page_box |
The stamped PDF's widget /Rects stay in user space. Canvas geometry is
box-relative; the deliverable is not.
Migrate: a host that corrected for the offset itself — subtracting a
background's /MediaBox origin before painting an overlay — deletes that
correction, or it now double-counts. A host on backgrounds that start at
(0, 0) sees no change at all. quillmark_pdf::page_media_boxes is
page_canvas_boxes, and it refuses a page box that is not a direct array of
numbers.
Breaking: a stamped widget carries its own /AP, and the flatten path is gone¶
A stamped widget carried /NeedAppearances and no appearance stream, so a
viewer that synthesizes appearances showed the value and everything else — a
raster pipeline, Ghostscript, a thumbnailer — drew an empty box. The acroform
backend worked around that for its canvas by building a second PDF with each
value baked into the page content streams, and previewing that instead.
Every widget now carries a baked /AP /N Form XObject drawing its own value,
so one document serves both: the stamped PDF renders its values wherever it is
opened, and the second one goes with the code that built it.
| 0.112 | 0.113 | |
|---|---|---|
a stamped widget's /AP |
absent | one Form XObject per drawn value |
a /DR text face's /Encoding |
absent | /WinAnsiEncoding |
| the acroform canvas raster | a flattened second PDF | the stamped PDF itself |
a page dict or /Contents flatten could not read |
acroform::flatten_parse |
gone with the path |
| the raster's document failing to parse | acroform::flat_parse_failed |
acroform::stamped_parse_failed |
a widget /Rect that is not four finite numbers |
pdf::bad_rect |
unchanged |
Read this if you rasterize a Quillmark-stamped PDF. Pages that came back
with blank fields now come back filled — a change in rendered pixels that no
type checker reports. A pipeline that composited values in itself will draw them
twice; drop that step. Both backends are affected: a Typst form-field carrying
a value: bakes one too.
The baked stream is an approximation and /V remains the source of truth: it
draws WinAnsi (a code point outside CP1252 draws as ?), from the box's left
edge whatever /Q says, clipped to the box. /NeedAppearances still rides the
form, so a synthesizing viewer rebuilds every appearance from /V and /DA and
none of the three limits reaches it.
Migrate: nothing to change to keep the interactive PDF working — /V,
/DA, /Q and the widget geometry are untouched. A consumer routing on
acroform::flatten_parse deletes that arm and one on acroform::flat_parse_failed
reads acroform::stamped_parse_failed. A Rust caller reaching into
quillmark_pdf::reader or quillmark_pdf::writer, or naming ObjectIndex or
PdfUpdate, has no replacement: both modules were #[doc(hidden)] and are now
private, and the crate's surface is stamp, regions_of, page_canvas_boxes,
FieldSpec and the checkbox constants.
Breaking (Typst): form-field keeps text and signature¶
The checkbox and choice kinds go from the Typst helper. Interactive
non-text widgets are the form backend's: a plate wanting a fillable checkbox or
dropdown is an acroform quill. text keeps multiline, and signature-field
is unchanged.
| 0.112 | 0.113 | |
|---|---|---|
form-field(.., type: "checkbox") |
a /Btn widget |
a Typst assert naming the two types |
form-field(.., type: "choice", options: (..)) |
a /Ch widget |
the same assert; options: is not a parameter |
font / size / align on a "choice" field |
applied | — |
Migrate: a plate placing a checkbox or dropdown moves that form to an
acroform quill, or draws the box in Typst and drops the widget. A plate using
only text and signature is unaffected.
Breaking: the $ext namespace verbs collapse into the whole-map three¶
$ext is a map the engine never inspects, keyed by a namespace its consumer
owns. Six verbs addressed it on WASM — whole-map and namespace variants of
store, remove and read — where three do, because a namespace write is a spread
on the client and the map's read shape is its write shape.
| Gone | Reach it by |
|---|---|
doc.storeExtNamespace(addr, ns, v) |
doc.storeExt(addr, {...doc.getExt(addr), [ns]: v}) |
doc.removeExtNamespace(addr, ns) |
spread without ns, then storeExt — or removeExt when nothing else is left |
doc.getExtNamespace(addr, ns) |
doc.getExt(addr)?.[ns] |
doc.store_ext_namespace(ns, v, card=i) (Python) |
doc.store_ext({**(doc.cards[i]["ext"] or {}), ns: v}, card=i) |
doc.remove_ext_namespace(ns, card=i) (Python) |
the same merge without ns, or remove_ext(card=i) |
Card::store_ext_namespace / remove_ext_namespace (Rust) |
card.ext() cloned, edited, card.store_ext(map) |
getExt / storeExt / removeExt and Python's store_ext / remove_ext are
unchanged, card selector included, and so is what $ext holds: stored bytes,
the Markdown round trip and the plate strip are untouched.
One wrinkle the spread does not carry. removeExtNamespace dropped the
$ext entry entirely once its last namespace went, where storeExt({}) records
an explicit $ext: {}. A consumer emptying the map calls removeExt instead:
// 0.113 — remove one namespace, and the map with it when it was the last
const { [ns]: gone, ...rest } = doc.getExt(addr) ?? {}
Object.keys(rest).length ? doc.storeExt(addr, rest) : doc.removeExt(addr)
Migrate (Rust): the two Card verbs had no caller outside their own tests
once the bindings stopped delegating to them. Card::ext reads the map and
Card::store_ext writes it, both bounded by the same depth check the merge
used, so a namespaced write is a clone, an insert and a store.
Breaking: the producer render option goes, the stamp stays¶
RenderOptions.producer overrode the PDF /Info /Producer string on every
surface it was plumbed through. It goes from all of them. What a rendered PDF
carries is unchanged: Quillmark <version>, stamped on every PDF render as
before.
| 0.112 | 0.113 | |
|---|---|---|
render(.., { producer: 'ACME' }) (WASM) |
stamps ACME |
the key is undeclared; typecheck reports it, and the value is ignored |
render(.., producer="ACME") (Python) |
stamps ACME |
TypeError: unexpected keyword |
RenderOptions::with_producer(..) (Rust) |
stamps the string | gone |
a rendered PDF's /Info /Producer |
Quillmark <version> |
unchanged |
The default moves down to the spine that writes it. StampOptions::producer is
a String whose Default is Quillmark <version>, in place of an
Option<String> each backend filled from its own default_producer() — two
copies of one format! over the same version.workspace = true. So
quillmark_pdf::stamp always appends its /Info revision, where a None
producer over an empty field list returned the base bytes untouched; both
backends always passed a producer, so no render changes.
StampOptions::with_producer goes with the Option, and a caller wanting
another string writes the field — StampOptions { producer: "ACME".into() } —
which the #[non_exhaustive] withdrawal above makes legal again.
Migrate: delete the argument. A host that must name itself in the PDF owns
the /Info rewrite on the returned bytes, which is where it always belonged: no
first-party caller set this, and the engine carried the string through four
crates and two bindings to reach a writer the host can reach directly.
Breaking: Quill.metadata loses the <backend>_<key> mirror¶
A loaded quill carried a flat map holding four Quill.yaml identity fields
verbatim plus every key under the backend section flattened as
<backend>_<key>, so typst: { plate_file: plate.typ } surfaced as a
typst_plate_file entry. Nothing read a mirrored key: the Typst backend and the
CLI read QuillConfig::backend_config, and both bindings built their identity
keys from the config and used the map only for the leftovers.
quill.metadata (JS and Python) now carries exactly its five identity keys —
name, version, backend, author, description, in that order — and
nothing else. In Rust, Quill::metadata and quillmark_core::STANDARD_METADATA_KEYS
are deleted.
Migrate: read a backend setting from the config section it was authored in.
// 0.112
const plate = quill.metadata.typst_plate_file;
// 0.113 — the key is under the backend section, where the backend reads it
// 0.113 (Rust)
let plate = quill.config().backend_config.get("plate_file").and_then(|v| v.as_str());
A host that needs a backend setting on the JS or Python side reads Quill.yaml
out of quill.toTree() / quill.to_tree(): no binding surfaces
backend_config, and none did before except through this mirror.
Breaking (CLI): quillmark info loses --json¶
The flag's one distinctive output was that metadata mirror. Its text output is
unchanged but for the Metadata: section going with it, and validate -v
prints the same name/backend/field/card/defaults counts if you were scripting
info --json for them.
Breaking: five owner calls leave both bindings¶
Each is a call the host can make itself, in one or two lines, from surface that stays. They go from both WASM and Python, so WASM remains the reference surface Python mirrors.
| 0.112 (JS / Python) | 0.113 |
|---|---|
Document.tryFromJson(b) / try_from_json |
Document.storageVersionOf(b) ? Document.fromStored(b) : null |
Document.makeCard(k, f, b) / make_card |
a CardInput object literal (below) |
doc.setCardKind(i, k) / set_card_kind |
removeCard + insertCard at the same index with the new kind |
result.renderTimeMs / render_time_ms |
clock the render call yourself |
Document.formatDiagnostic(d) |
the CLI and Python's str(diagnostic) still render it |
makeCard built a string-bodied wire and round-tripped it because CardInput
refuses a flat field map. The literal is that wire:
// 0.112
doc.insertCard(Document.makeCard("note", { x: 1 }, "Body."));
// 0.113
doc.insertCard({
kind: "note",
payloadItems: [{ type: "field", key: "x", value: 1 }],
body: "Body.",
});
# 0.113
doc.insert_card({
"kind": "note",
"payload_items": [{"type": "field", "key": "x", "value": 1}],
"body": "Body.",
})
Validation is unchanged: it always ran at insertCard, which is the kind gate
and refuses a bad field name or an over-deep value under the same edit::*
codes.
Timing: performance.now() in JS, time.perf_counter() in Python, around
the render call. Rust never carried it.
Breaking: a Diagnostic drops sourceChain / source_chain¶
The field leaves all three surfaces — Rust Diagnostic::source_chain, JS
Diagnostic.sourceChain, Python Diagnostic.source_chain — together with the
Rust builder that filled it, Diagnostic::with_source.
One code in the engine ever filled it: typst::world_creation, raised when the
Typst compilation environment cannot be built. Its cause is a message with no
cause of its own, so the chain was a one-element array holding exactly the text
message already ends with. Every other diagnostic serialized without the
field, which was omitted when empty.
| 0.112 | 0.113 |
|---|---|
diag.sourceChain[0] / diag.source_chain[0] |
diag.message, which interpolates the same cause |
Diagnostic::with_source(&e) (Rust) |
interpolate the cause: RenderError::coded(code, format!("...: {e}")) |
Nothing printed it. fmt_pretty covers severity, message, code, location
and hint, so the CLI's output and Python's str(diagnostic) are byte-identical.
Breaking (JS): a MarkOp spells its payload in attrs¶
MarkOp's link and anchor arms declared their payload as a named sibling,
{ op: 'add', start, end, type: 'link', url }. That is the spelling the
authored lane refuses as the retired @0.93.0 encoding — the decoder reads a
built-in's payload out of attrs, and applyChange answers the sibling with
invalid markOps: content json shape: legacy mark payload. So the type named
the shape the runtime rejects.
| 0.112 | 0.113 |
|---|---|
{ op: 'add', start, end, type: 'link', url } |
{ op: 'add', start, end, type: 'link', attrs: { url } } |
{ op: 'add', start, end, type: 'anchor', id } |
{ op: 'add', start, end, type: 'anchor', attrs: { id } } |
{ op: 'removeAnchor', id } is unchanged: that arm is an op with an id, not a
mark payload.
Nothing on the wire moves. The runtime accepted attrs and only attrs
throughout 0.112, so no working call changes and no stored blob does. What
changes is which calls compile. A consumer whose op type-checked did so through
0.112's open arm, { type: string; attrs: unknown }, which took the attrs
shape the runtime wants; the arm goes with the vocabularies (above), so without
this correction there would be no spelling that both compiles and runs.
The payload has a name. ContentMarkKind is the three arms, exported from
the package root beside ContentLineKind; ContentMark is a range over it and
a MarkOp's add / remove is a ContentMark under an op. Both resolve to
the shapes above, so nothing you wrote changes, and an arm added to the mark
vocabulary reaches the op with no second declaration to lag behind it. A held
mark spreads into an op whole — { op: 'remove', ...mark } type-checks for any
ContentMark — and a payload built without one types as ContentMarkKind,
where 0.112 had no name for it: Omit<ContentMark, 'start' | 'end'> collapses
the attrs arms, since Omit does not distribute.
Migrate: a MarkOp written against the declared arms was already throwing —
move its payload into attrs. One written against the open arm compiles now
without its cast: as MarkOp on a correct op can go.
Breaking (JS): the eleven content guards go¶
isTableIsland, isImageIsland, isLinkMark, isAnchorMark, isHeadingLine,
isCodeLine and isListItemContainer are deleted. Each was one
x.type === 'k'. The four isUnknown* guards go with the vocabularies they
classified (see § "the content vocabularies close"), which also makes the
discriminant check narrow on its own: no cast needed.
Migrate: switch on the discriminant.
// 0.112
if (isTableIsland(island)) render(island.props);
// 0.113
if (island.type === 'table') render(island.props);
Breaking (JS): a foreign handle on a by-reference method is a bare Error¶
The prototype patches on Document.equals, Quill.validate and Quill.conform
are gone, with the Symbol.for('@quillmark/wasm:handle-checked') marker they
needed. wasm-bindgen's own _assertClass already refuses a foreign class
wherever a method declares a reference parameter; it throws a bare Error, so
isQuillmarkError reads false there now.
The seams that cross as data keep their check, because nothing else would
catch them: the four writer/reader binds, every Engine verb, and
LiveSession.update. Those throw in contract as before, except that
runtime::foreign_handle retires: a value that is not one of this copy's
handles — the wrong type, or the right class from a second copy — is now
runtime::not_a_quill / runtime::not_a_document, whose hint names both cures
including npm ls @quillmark/wasm.
Migrate: a host routing on runtime::foreign_handle routes on the two
not_a_* codes instead. A duplicate install still fails loudly at every seam
that could hide one.
Breaking (Python): CardWriter / CardReader become a card= keyword¶
writer.card(i) and reader.card(i) returned a cursor holding the index. In
core the writer holds a &mut Document; in pyo3 it held two Py<> and
re-borrowed per call, so the cursor was a namespace, not a guarantee — and it
carried a hazard the docs warned about twice: a remove_card between binding
and writing silently retargeted it.
Every Writer and Reader verb now takes card, the selector Document
already used for remove_field and store_ext: None (the default) is the
main card, an int the composable card at that index, read at the call.
| 0.112 | 0.113 |
|---|---|
writer.card(i).set(n, v) |
writer.set(n, v, card=i) |
writer.card(i).set_all(f) |
writer.set_all(f, card=i) |
writer.card(i).revise_body(md) |
writer.revise_body(md, card=i) |
writer.card(i).revise_field(n, t) |
writer.revise_field(n, t, card=i) |
reader.card(i).get(n) |
reader.get(n, card=i) |
reader.card(i).get_content(n) |
reader.get_content(n, card=i) |
reader.card(i).get_content_at(n, p) |
reader.get_content_at(n, p, card=i) |
reader.card(i).body_markdown() |
reader.body_markdown(card=i) |
reader.card(i).kind / writer.card(i).kind |
doc.card(i)["kind"] |
reader.card(i).index / writer.card(i).index |
the i you passed |
Diagnostics are unchanged: a refusal still anchors at cards.<kind>[<i>], and
an index addressing no card still raises edit::index_out_of_range. The JS
cursors are untouched — writer.card(i) / reader.card(i) stay there, where
the writer holds the document by reference and the cursor is a real binding.
Breaking (Python): a negative index is an out-of-range index, not an OverflowError¶
Every index parameter was a usize, so doc.card(-1), doc.move_card(-1, 0)
and render(pages=[-1]) died in pyo3's boundary conversion with an exception
type the binding does not document, carrying no diagnostics to route on.
Indices are signed at the boundary now. A negative one addresses nothing — it is
not the last card, and the binding does not index from the end — so it takes
the answer its own site already gives an index past the end.
| call | 0.112 | 0.113 |
|---|---|---|
doc.card(-1), doc.move_card(-1, 0), writer.set_all(.., card=-1) |
OverflowError |
edit::index_out_of_range, as doc.card(99) gives |
render(quill, doc, pages=[-1]) |
OverflowError |
backend::page_index_out_of_bounds |
doc.remove_card(-1) |
OverflowError |
None, as remove_card(99) gives |
| any non-negative index | unchanged | unchanged |
Migrate: an except OverflowError around a card or page call catches
nothing now — the refusal arrives as QuillmarkError carrying its diagnostic,
the way every other refusal on these calls already arrived. Code reaching for
-1 to mean the last card never worked (it raised), so read len(doc.cards)
and pass len - 1.
Unaffected¶
- Stored blobs. The wire format, the
schematag (quillmark/document@0.112.0), and byte-determinism within a version are untouched. A row the 0.112 writer produced loads in 0.113 and back. A row a host authored a vocabulary name of its own into does not — see § "the content vocabularies close". storageVersionOf/currentStorageVersion(and their Python twins) keep their names: they already say storage.reader.getContentAt/get_content_at, theContentread of a leaf nested inside a composite field: the one read that carries the leaf's anchors and island ids, wheregetnow projects to text — see § "reader.getanswers in the values form". Python's spelling takes thecard=selector like every otherReaderverb.- Core (Rust).
Documentserializes through#[serde(into / try_from)]and callers useserde_jsondirectly, so there is no verb to rename. The collision was a WASM/pyo3 artifact of fusing the DTO onto the oneDocumenthandle, asgetStored's was.Quill::resolvestays as the core producer besideTypedReader::resolve. toMarkdown/fromMarkdown,equals,clone, and every mutator.fromMarkdownreads one shape differently — see § "every column-zero~~~block is a card".quill.validate/quill.conform/quill.parse, which stay on the quill as its ingestion and verdict verbs.validate's verdict moves on two shapes — see § "Quill::validaterefuses every value the render floor refuses".