@quillmark/wasm 0.112.0 → 0.114.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1617 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.114.0 - 2026-09-18
4
+
5
+ ### Schema, validation and the resolved view
6
+
7
+ - feat(core): **`type: matrix`: a closed vocabulary someone ticks.** A card kind
8
+ gave add and remove, the wrong verbs for a roster the page prints in full, and
9
+ an `array<{member: enum, detail}>` hid every unpicked option, let the same
10
+ member in twice, and left the plate owning a second copy of the vocabulary to
11
+ print the unheld boxes. A `matrix` declares `members:` — ordered blocks of
12
+ `id: Title`, groups optional — and `properties:` as the columns, and the
13
+ loader expands them into an `object` whose properties are the member ids, each
14
+ carrying a synthesized `held: {type: boolean, default: false}` beside the
15
+ columns. Coercion, validation, blank-fill and addressing are the typed
16
+ dictionary's, reached through the new `FieldSchema::namespace_props`, so
17
+ `qualifications.flight_cc.held` regions on Typst and binds a checkbox on
18
+ acroform without a new address grammar. Four behaviors are the type's own.
19
+ **Key presence is the tick**: `cyber_200: true` normalizes to the member
20
+ object as the bare `classification: CUI` normalizes to `{value: CUI}`, and a
21
+ mapping naming no `held` is held, so a document stays sparse. **The roster
22
+ reaches the wire**: every member present in declaration order carrying its own
23
+ `title` and `group`, so a plate prints the vocabulary without holding a second
24
+ copy of it. **The wire carries the live world only**: an unheld member's
25
+ columns render at their blanks whatever the document retains, the closed shape
26
+ variants already hold, so tick, type, untick, retick keeps the answer in the
27
+ file and stops rendering it. **Obligation is gated on the tick**: a column with
28
+ no `default:` reads "required when held", the variant rule one level down,
29
+ while the matrix itself obliges nothing, takes no `default:` or `example:`
30
+ (`quill::{default,example}_on_namespace`, the existing namespace rule) and
31
+ seeds empty. `held`, `title` and `group` are reserved as column names
32
+ (`quill::matrix_reserved_column`); a member id is a snake_case identifier
33
+ (`quill::invalid_matrix_member`), unique across the roster
34
+ (`quill::duplicate_matrix_member`), and one outside it is refused as an
35
+ out-of-domain enum member is. The blueprint spells the roster in the existing
36
+ format slot, `# matrix<flight_cc | dodin_ops | …>`, over a `{}` cell, so no
37
+ third annotation form is minted and a model sees the whole vocabulary without
38
+ twenty-seven lines to delete. Closes #1831.
39
+ - feat(core): **the transform schema carries a declared key order, and the Typst
40
+ codegen honours it.** `Codegen::emit_value` sorts dict keys so a reorder-only
41
+ `update` rebuilds byte-identical `lib.typ` and comemo reuses the whole compile.
42
+ A matrix is the one node that cannot pay it: printing the vocabulary as the
43
+ schema groups it is the type's purpose, and sorted keys hand the plate
44
+ `cyber_200` before `sq_cc_candidate`. A container node whose *schema* fixes an
45
+ order now says so with `quillmark:order`, and the emitter reads it. The order
46
+ is a property of the schema rather than of the data, so equal data still
47
+ produces byte-equal source and nothing about the reuse changes; a node
48
+ carrying no such key sorts as before.
49
+ - feat(core): **`max:` on an array, and `validation::cardinality`.** A form with
50
+ thirty-seven ruled lines silently swallowed a thirty-eighth row, and the limit
51
+ lived in `description:` prose that no consumer reads. `max:` is a non-negative
52
+ integer on an `array`, refused elsewhere, and a `default:`/`example:` longer
53
+ than it fails the load (`quill::{default,example}_over_max`) rather than
54
+ seeding a document that warns on arrival. `Quill::validate` reports
55
+ `validation::cardinality` at the field's own path with `{max, actual}`, at
56
+ every depth — an array in a typed dictionary, a matrix member, a live variant
57
+ world or another array's elements is capped by its own declaration — and never
58
+ gates render: fatal ≡ won't-render is an invariant of the diagnostic model, and
59
+ the plate keeps its own rule for the surplus. The count is the render floor's,
60
+ so a bare scalar coerced to a one-element array is counted as one. `min:` is
61
+ not taken: obligation is `default:`'s absence, so `min: 1` is `required:` under
62
+ another name. Closes #1826.
63
+ - feat(core): **the blueprint carries a cap as `# up to <N>`.** The author most
64
+ likely to exceed a limit is the MCP flow that reads the spec once and then
65
+ writes, and it reads the blueprint: `schema()` is consulted before that author
66
+ writes, and the `validate` warning arrives after the overflowing document
67
+ exists. The line rides the own-line leading slot directly under the field's
68
+ description, the form `# composable (0..N)` already takes for a card kind's
69
+ cardinality, so the `<type>[<format>]` inline grammar is untouched. It reaches
70
+ a field and an `object` property, the two positions that slot exists at.
71
+ - feat(core): **`ui.layout: table` asks an editor for the grid a typed table
72
+ wants.** A row of four short cells drew as a stack of collapsed rows that open
73
+ one at a time — the right control for a row of long prose, the wrong one here —
74
+ and `UiFieldSchema` is `deny_unknown_fields`, so the shape had no way to ask.
75
+ `layout: table` is valid on an `array` whose `items` is an `object` and refused
76
+ with `quill::invalid_ui` anywhere else, and `schema()` echoes it verbatim. A
77
+ request, not a contract: the plate, `validate` and the blueprint are all
78
+ deliberately inert on it, and a consumer that cannot draw the control falls
79
+ back to its own choice for the type. `items.ui.title` is documented beside it
80
+ as the collapsed row's summary template, which needed no loader change.
81
+ Closes #1825.
82
+ - feat(core): **a card kind declaring `body.enabled: false` warns.** A card is a
83
+ part someone writes; a repeated record someone fills in is a row, an
84
+ `array<object>` on the card that owns it. Four card kinds across three airmark
85
+ quills were rows in card costume, each bodiless with a plate reassembling them
86
+ from the stream by position. `quill::bodiless_card_kind` says so at
87
+ `Severity::Warning`, hinting at the row shape. A warning and not a load error,
88
+ because the loader sees the proxy and not the fact: whether a kind interleaves
89
+ among kinds of other sorts is a property of documents and the plate, nothing in
90
+ `Quill.yaml` states it, and a bodiless positional kind — a page break, a rule,
91
+ an inserted signature block — is a card. `main` keeps the key unwarned: a form
92
+ has no root prose. Closes #1824.
93
+
94
+ ### The binding surface
95
+
96
+ - feat(wasm): **`formatDiagnostic` renders a diagnostic as the CLI does.**
97
+ `Document.formatDiagnostic` left the binding in 0.113 among the five owner
98
+ calls a host can make itself, and it was the one of the five that left a
99
+ surface behind: Python keeps the rendering on `PyDiagnostic.__str__`, which is
100
+ `fmt_pretty`, and the CLI prints through the same call, so WASM alone lost the
101
+ engine's English. A consumer wording its own still selects by `code` + `args`.
102
+
103
+ ### Docs and tests
104
+
105
+ - docs(core): **`ERROR.md` § "Error Presentation" names the two lanes apart.** A
106
+ consumer surfacing the engine's wording calls the one printer; a consumer
107
+ wording its own selects by `code` + `args` and owns the layout with the
108
+ sentence.
109
+ - docs(acroform): **the worked example gains the richtext field the fixture
110
+ does.** `sample_form` is what the backend guide points at for a hand-authored
111
+ `form.pdf` + `form.json`, so its excerpts state what that fixture declares.
112
+ - test(core): **one fixture walk, one scalar-fidelity home, one card-order
113
+ test.** Closes #1676.
114
+ - test(core): **one loader, one coercion table, one quill header.** Closes #1677.
115
+ - test(pdf,typst): **one base-PDF builder, one home per invariant.** Closes
116
+ #1681, closes #1680.
117
+ - ci: **the release dispatches the docs deploy it no longer triggers.**
118
+ `docs.yml` deploys Pages on `release: published`, and moving `gh release
119
+ create` onto `GITHUB_TOKEN` stopped that event starting a run, so the site sat
120
+ eleven releases behind at v0.102.0. `workflow_dispatch` is the one event exempt
121
+ from that rule, which `release-prepare.yml` already leans on for `ci.yml`, so
122
+ the release dispatches `docs.yml` at the tag it just pushed — the site is built
123
+ from the released bytes, and a prerelease is skipped as the event path skipped
124
+ it.
125
+ - build(deps): **lopdf 0.44.0 → 0.45.0**, and **taiki-e/install-action 2.87.8 →
126
+ 2.87.12** in the actions group.
127
+
128
+ ## v0.113.0 - 2026-09-16
129
+
130
+ Upgrade path: [0.112 → 0.113](docs/migrations/0.112-to-0.113.md).
131
+
132
+ ### The content model
133
+
134
+ - fix(content): **a marker run that spells a thematic break breaks its line.**
135
+ Three nested empty bullet items emitted `- - - `, which CommonMark reads as a
136
+ thematic break rather than as three items, so the nesting was gone after one
137
+ pass; `> + + +` and any deeper run went the same way. Changing a bullet char is
138
+ the obvious way out and is not available — a different char starts a new list,
139
+ resetting `ordinal` on that item and every one after it, and the empty item can
140
+ share its list with a non-empty sibling. The item's content moves to the next
141
+ line instead (`-\n - - `), which no marker char and no list identity depends
142
+ on. The check runs per level, so a run of any depth breaks into pieces of at
143
+ most two, and a line that never spelled a break emits the same bytes as before.
144
+ `delimiter_run()` takes `+` back, the token it was denied to keep this defect
145
+ from making that property flap. Closes #1809.
146
+ - fix(content): **the projection's safety net verifies the marks a rendering
147
+ carries, not only its text.** `to_markdown` took the first spelling whose
148
+ emission re-imported with the text intact, and an ambiguous `***` run costs the
149
+ text in one shape and only the marks in another: `**a±**_b_**c**` lowered to
150
+ `**a±***b***c**`, which CommonMark re-segments into one `Strong` over all three
151
+ spans. Every character came back, so the net passed it — and bold moved onto
152
+ the `±b` the source did not bold, two spans became one, and the second emission
153
+ differed from the first. That last part is
154
+ `the_markdown_loop_settles_on_an_arbitrary_body` going red on main. The probe
155
+ now compares the re-imported marks against the ones the sweep was asked to
156
+ carry, and the whole search is keyed on that one predicate — the four spellings
157
+ first, then the drop-by-halves — so a rendering is taken only where it
158
+ re-imports as the content it rendered. Text is still the floor the search
159
+ cannot go below, `**`/`*` is still swept first, and a line that already
160
+ round-tripped emits the same bytes. Over 20M generated delimiter runs the
161
+ emissions needing a second pass to settle fall from 17 to 0, and the marks
162
+ that come back over text they did not cover fall with them, 17 to 0. Closes
163
+ #1807.
164
+ - fix(content): **a mark whose delimiters collide with its neighbour's is
165
+ re-spelled, not dropped.** `to_markdown` spelled `Strong` `**` and `Emph` `*`
166
+ and nothing else, so a `Strong` ending in a literal `*` against an `Emph`
167
+ starting where it ends emitted a `***` run CommonMark re-segments rather than
168
+ pairs; the verify-and-drop net read the text drift that caused and dropped the
169
+ `Strong` to recover the text. `__a**__*b*` imported as `a**b` under
170
+ `Strong(0..3) Emph(3..4)` and exported as `a\*\**b*`, losing bold the round
171
+ trip was meant to carry, and `~~*a*~~` lost its `Emph` whenever the sweep put
172
+ `*` outside `~~`, where it has no non-punctuation to pair against. Markdown
173
+ spells both kinds a second way: the net now tries `__`/`_` before it drops
174
+ anything, and the asterisk family sorts innermost among marks over one span.
175
+ Every candidate is verified exactly as before, and `**`/`*` still leads, so a
176
+ line that already round-tripped emits the same bytes. Over 200k generated
177
+ delimiter runs the marks lost across export∘import fall from 104 to 14, the
178
+ remainder being the crossing tails `clip_asterisk_overlap` truncates by
179
+ design. Text was never at risk and does not move. Found reviewing #1801.
180
+ - fix(content): **`Delta::map_pos` saturates its base cursor.** `mapPos` is the
181
+ one `Delta` door no `try_apply` bounds, and it summed a wire delta's
182
+ `retain`/`delete` counts with a plain `+`: on wasm32, where `usize` is
183
+ 32-bit, `{ops:[{retain:3000000000},{retain:3000000000}]}` wrapped to a wrong
184
+ caret position in the published build and aborted the instance in the
185
+ checked one. `map_pos`, `is_deleted` and `inserted_spans` now walk the ops
186
+ with `saturating_add`, as `expected_base_len` already did: a run past
187
+ `usize::MAX` describes a base longer than any content, and a position lands
188
+ inside it as it would in a bounded one. Closes #1782.
189
+ - refactor(content,wasm,python)!: **one canonical content form, a zero
190
+ `Container.instance` omitted.** Canonical JSON had two byte forms differing
191
+ only in whether a zero `instance` was written: storage omitted it, so a row
192
+ written before the field existed re-encodes byte for byte, and a second
193
+ encoder spelled it on every container so the published `ContentContainer`
194
+ type could require the field. Required bought no correctness — a checker
195
+ reports an omitted field, never the `0` stamped on two runs that welds them,
196
+ which is what `assignInstances` is for — and it cost a spelling a host had to
197
+ tell apart from the one storage holds. `serial::to_seam_value` goes;
198
+ `to_canonical_value` is the one encoder, and every `Content`-typed read
199
+ answers in it: `getContent{,At}`, `getStored` on a body, `importMarkdown`,
200
+ `rebase`, and the `Card` wire. TypeScript spells `instance?: number` on both
201
+ arms, and an absent key decodes to `0` as the spelled one did, so every write
202
+ takes either spelling and no stored byte moves. A host reading the key off a
203
+ read finds `undefined` where it found `0`: the break no type checker reports.
204
+ `emit`'s markdown projection of a content-valued field now matches one form
205
+ rather than two, so a value carrying a spelled zero stays a structural
206
+ mapping until it is conformed. Closes #1648.
207
+ - feat(content,wasm)!: **the content vocabularies close.** A line `kind`,
208
+ container, mark `type`, island `type` or `loss` outside the built-ins was an
209
+ open set: it round-tripped opaque and projected as its nearest safe
210
+ neighbour. All five are closed, so a name outside them is
211
+ `ParseError::UnknownName { axis, name }` at every decoder and a stored row
212
+ holding one stops opening. Projecting an unknown `kind` as `para` would
213
+ re-encode the row and move canonical bytes on a read with no edit, which is
214
+ why refusing is the only honest reading. No first-party writer ever produced
215
+ another name, so the affected population is exactly the rows a host authored
216
+ one into through `overwrite`, `applyChange` or a card body. The mark axis
217
+ closes inside a table island's cells too, where the read is otherwise lenient
218
+ — `canon_cell` re-mints from what `parse_cell` returned, so a skipped name
219
+ would leave stored bytes behind on a read with no edit. A *malformed* cell
220
+ mark is still skipped; that split is about shape, not vocabulary. The three
221
+ `Unknown` carriers, `RESERVED_*` and the three `ReservedUnknown*` invariants
222
+ go; `Island::island_type` becomes the `IslandType` enum and `Loss` becomes
223
+ the enum `Fidelity` was. On the WASM surface the six unions lose their open
224
+ arm, so `line.kind === 'heading'` narrows `line.attrs` with no guard, and the
225
+ eleven content guards (`isTableIsland`, `isImageIsland`, `isLinkMark`,
226
+ `isAnchorMark`, `isHeadingLine`, `isCodeLine`, `isListItemContainer` and the
227
+ four `isUnknown*`) are deleted — each was one discriminant check the
228
+ narrowing now performs. The storage tag is unchanged: every byte the writer
229
+ emits is the same, and only the reader's accepted domain narrowed. Adding a
230
+ construct is a storage-version event from here on. Closes #1693.
231
+ - refactor(content,wasm)!: **`Content::normalize` settles the lenient/strict
232
+ split alone.** Eleven `Invariant` arms named shapes the mint repairs — a
233
+ zero-width or newline-edged formatting mark, a `continues` flag on the first
234
+ line, across a container boundary or after a one-line block, a line kind its
235
+ text contradicts, the four table shapes, and a block island's slot sharing
236
+ its line — and every door mints before it validates, so each fired only on a
237
+ hand-built content. All of it goes: `validate` reports what normalization
238
+ cannot repair (a forbidden character with no substitute, two counts with no
239
+ rule saying which is right, a range or depth past a bound, a colliding id),
240
+ and `setKind` / `setContinues` land with the terminal normalize settling them
241
+ — a contradicted kind becomes `para`, an impossible `continues` clears, line
242
+ 0 included. So `applyChange` resolves where it threw, and `Ok` stops meaning
243
+ the op landed as written: an editor mirroring ops into its own model reads
244
+ the content back. The one content-changing case is a heading retagged
245
+ `island` or `rule`, which is a paragraph afterward. `BadHeadingLevel` and the
246
+ block-island placement stay refused — the first has no principled rewrite,
247
+ the second is the authored lane's policy rather than a second reading of a
248
+ repair. `LineKindMismatch` leaves the crate, and so do
249
+ `ApplyError::LineKindMismatch`, `ContinuesAcrossContainers`,
250
+ `ContinuesSingleLineBlock` and `FirstLineContinues`;
251
+ `LineKind::takes_continuations` names the kinds a continuation is legal after
252
+ (`Para`, `Code`, `Unknown`), since export renders only the first line of a
253
+ heading, island or rule. Stored bytes are untouched, and the one reader
254
+ change is a loosening: a blob spelling `continues: true` on line 0 loads
255
+ cleared where it failed. Closes #1699.
256
+ - feat(content)!: **a block-only island takes a line of its own in the model,
257
+ not only on the way out.** A `table`'s markdown is a block, so a slot spliced
258
+ into a paragraph exported as pipes mid-line — `a| h |\n| --- |\n| c |b` — and
259
+ re-imported as prose with the island gone. `Content::normalize` performs the
260
+ break the export used to perform at write time, so the model can no longer
261
+ hold a shape markdown cannot spell. `IslandOp::Insert` refuses an `at` that
262
+ is not an empty line, `IslandOp::Set` refuses retyping an inline island into
263
+ a block-only one (`ApplyError::BlockIslandNotAlone`), and `overwrite` refuses
264
+ the same placement through `serial::from_authored_value`. Storage stays
265
+ lenient: a stored blob in that shape loads already split with its marks
266
+ rebased, and `to_markdown` writes a paragraph, the table, and a paragraph, so
267
+ the island survives re-import. An accepted `LineOp::Join` that runs a slot
268
+ back into its prose is taken apart again by the mint. `normalize_markdown`
269
+ narrows to `pub(crate)`.
270
+ - fix(content)!: **a link or image `url` carrying a line ending is refused
271
+ where it is authored, and percent-encoded where it is written.** CommonMark
272
+ admits no line ending in a destination, bare or angle-wrapped, so `"a\nb"`
273
+ exported as `[t](<a\nb>)`, which pulldown reads as an inline HTML tag: the
274
+ re-import came back `[t]()` with the mark gone, and an image the same way
275
+ with its island gone. An authored lane that *stores* a url now refuses one —
276
+ `MarkOp::Add` of a `link`, `IslandOp::Insert` and `IslandOp::Set` of an
277
+ `image`, and the whole-content doors (`overwrite`, `CardInput.body`) — the
278
+ way an unwritable code-fence `lang` is refused. `MarkOp::Remove` still takes
279
+ it, matching on kind equality against a mark the field already holds. Storage
280
+ stays lenient, and `to_markdown` writes the line ending as `%0A`/`%0D`.
281
+ - fix(content): **an island alone on a line takes the line kind its type
282
+ projects.** An `image` slot alone on a `LineKind::Island` line and the same
283
+ slot on a `Para` line both wrote `![alt](url)`, which re-imports as `Para`,
284
+ and a `table` alone on a `Para` line wrote a pipe table, which re-imports as
285
+ `Island`: two normalized contents per document and one markdown, so any write
286
+ and read back flipped the kind. `IslandType::block_only` names which markup
287
+ is a block, and `normalize` writes the kind the round trip yields.
288
+ - fix(content): **VT, FF, NEL, U+2028 and U+2029 in document text become a
289
+ space.** Typst's lexer reads all five as line breaks, so one mid-paragraph
290
+ reopens `at_start` and the characters behind it are read as a block marker —
291
+ `"intro\u{c}- item"` rendered a bullet — and two in a row split the
292
+ paragraph. No escape reaches them, a `\` before whitespace being Typst's own
293
+ linebreak. The spaced set is Typst's whole newline set less `\n`, named by
294
+ `normalize::is_line_separator`, refused by `validate`, and replaced at every
295
+ text ingress: `from_plaintext`, markdown import, an `Op::Insert` through
296
+ `apply_text_delta`, and a table cell's text. A space rather than a drop, both
297
+ being Unicode whitespace, so the words either side stay parted. Markdown-spec
298
+ §7 states it.
299
+ - fix(content): **a paragraph emptied by HTML stripping leaves no line.**
300
+ `<span></span>` on its own imported as an empty `Para` line, which markdown
301
+ has no syntax to write, so `to_markdown` emitted a stray blank line and
302
+ re-import collapsed it. An empty heading, code block and container keep their
303
+ line, markdown being able to write those back.
304
+ - fix(content): **a code span whose content touches its fence exports with the
305
+ CommonMark space pad.** Export emitted `fence + content + fence`, so an edge
306
+ backtick joined the fence run (text `` `a `` came back as ` ```a`` `) and a
307
+ span that begins and ends with a space lost one off each side on re-import. A
308
+ pad space now flanks the content in exactly those two cases, which import
309
+ strips back off; a span of nothing but spaces is exempt from the strip.
310
+ - fix(content): **a mark flanking an unknown island's placeholder survives
311
+ export.** The verify-and-drop net re-imports the rendered line and expects
312
+ the line's own text back, island slot included, but a type this build has no
313
+ projection for renders as a comment placeholder that re-imports as nothing —
314
+ so the probe could never match and every `**` / `*` / `~~` on such a line was
315
+ dropped as unrepresentable. The expected text now omits the slots of islands
316
+ with no markdown projection, so the probe measures delimiter leakage alone.
317
+ - fix(content): **`diff_import` carries unknown marks forward beside anchors.**
318
+ The rebase loop matched `MarkKind::Anchor` alone, so a full-document rewrite
319
+ through the stale-text writer lane dropped every open-set mark, even one over
320
+ text the rewrite left untouched. It rebases every non-formatting mark —
321
+ formatting is what the fresh import re-derives, and the rest lives in the
322
+ content but not in markdown.
323
+ - fix(content): **a change bundle whose `retain`/`delete` counts sum past
324
+ `usize` is a base mismatch, not a panic.** `Delta::expected_base_len` summed
325
+ the counts unchecked, so `{"retain": 18446744073709551615}` aborted in debug
326
+ and wrapped in release, where the wrapped total let `apply` slice past the
327
+ base. The sum saturates past any real base, so `try_apply` and
328
+ `apply_field_change` return the `DeltaBaseMismatch` the contract already
329
+ names.
330
+ - fix(content): **`change_bundle_from_value` reads camelCase keys only.** The
331
+ snake_case fallback served a Python content lane that does not exist.
332
+
333
+ ### Parsing and the card block
334
+
335
+ - feat(core)!: **`---` front matter is CommonMark's, and the message names the
336
+ fence to write instead.** A `---` at document start paired with a later `---`
337
+ opened the root block. It is a thematic break and a setext underline again,
338
+ so a document fenced that way has no root block and fails `MissingQuill`. The
339
+ alias was accept-don't-emit-don't-advertise: no authoring page taught it,
340
+ `FORMAT_RULES` says the fence is exactly `~~~`, the blueprint emits `~~~`,
341
+ and `toMarkdown` rewrote a `---`-authored root to `~~~` on first re-emit.
342
+ What it cost was a rule that only half held — composable cards have no `---`
343
+ form — and the containment that took: a document-start rule, a matched-fences
344
+ rule, and a lookahead rejecting a `---` below the root block when a later
345
+ `---` paired with it over YAML-key-looking content. That lookahead read prose
346
+ it had no claim on. A thematic break, a paragraph opening `Note:`, and a
347
+ second break is ordinary markdown, and it was refused outright with a
348
+ composable-card error. The scanner now reads no `---` at all, so it claims
349
+ nothing a CommonMark renderer draws, and `missing_block_message` carries what
350
+ the tolerance was for: a document opening with `---` and declaring `$quill`
351
+ is told to replace the opening and closing `---` with `~~~`, which is the
352
+ edit. A `---` document *without* `$quill` keeps the generic message, which
353
+ names the fence and the key together rather than sending the author back for
354
+ a second turn. Refs #1698.
355
+ - feat(core)!: **every column-zero `~~~` block is a card, whatever its info
356
+ string.** The opener's info string is no longer read. `~~~card-yaml` and
357
+ `~~~yaml` were accepted aliases and `~~~rust` opened an ordinary code block;
358
+ all of them open a card now. The spec already stated the widened rule in §3.2
359
+ and then exempted language info strings in the next sentence, so a tilde
360
+ escape hatch was half-promised and half-withheld — and already unavailable
361
+ for the two shapes anyone would reach for: a YAML block, claimed by the
362
+ `yaml` alias, and a nested backtick block, written with a bare `~~~`. Nothing
363
+ first-party emits a language-tagged tilde fence. A backtick fence is the
364
+ escape hatch, and the whole of it. The widening deletes what the exemption
365
+ needed: the accepted-alias list, the `RootFault::InfoString` diagnostic that
366
+ explained why `~~~metadata` opened no block, and `code_fence_info`, whose
367
+ last caller it was; `RootFault` collapses to the `UnclosedRoot` struct it now
368
+ is. CommonMark conformance is unchanged — `fence_conformance_tests` holds
369
+ every card block to a pulldown-cmark fenced span at the same offsets — so the
370
+ widening moves toward equality rather than away. The `body.example` blueprint
371
+ guard tightens with the parser it delegates to. `docs/cli/reference.md` and
372
+ `prose/canon/CLI.md` stop naming an alternative opener. Refs #1698.
373
+ - fix(core)!: **`main:` parses under the same strict card-schema shape as a
374
+ card kind.** A `main:` that is not a mapping, an unknown key under it
375
+ (`feilds:`, `title:`), and a `main.fields` that is not a mapping all loaded
376
+ as a main card with zero fields and no diagnostic; each is now
377
+ `quill::invalid_card_schema`. `main` and `card_kinds.<name>` accept
378
+ `description`, `fields`, `ui` and `body` only, and a malformed `ui` or `body`
379
+ block under either reports `quill::invalid_ui` or `quill::invalid_body` with
380
+ the hint naming that block's keys, where a card kind drew the whole-card
381
+ refusal.
382
+ - fix(core)!: **the §8 count caps report a count, not a byte size.** The
383
+ card-count and per-block field-count caps raised `parse::input_too_large`,
384
+ whose one message shape is `Input too large: {size} bytes (max: {max}
385
+ bytes)`, so 1001 fields read as 1001 bytes. Each cap has its own variant and
386
+ code: `ParseError::TooManyFields` / `parse::too_many_fields` and
387
+ `ParseError::TooManyCards` / `parse::too_many_cards`, both carrying `count`
388
+ and `max`. `parse::input_too_large` keeps the two byte caps.
389
+ - change(core)!: **YAML nesting depth is the parser's to bound, and
390
+ `MAX_YAML_DEPTH` is gone.** The constant set `serde_saphyr`'s depth budget
391
+ and doubled as the bound on host values crossing into the document, so one
392
+ number spoke for two unrelated limits. YAML parsing runs on `serde_saphyr`'s
393
+ own budget, and the write surfaces (`store_field`, `store_ext`, the wire and
394
+ storage DTOs, the bindings' converters) bound values at `MAX_JSON_DEPTH`
395
+ (128), the depth storage already accepts — raising the accepted depth on
396
+ those paths from 100. `§8 Limits` no longer fixes a YAML nesting number.
397
+ - refactor(core)!: **prescan's cleaned YAML is line-for-line with its source.**
398
+ A comment line was dropped from the string handed to the parser, so the two
399
+ numberings diverged and a `PreScan::source_lines` table existed to map a
400
+ reported position back. The line passes through — it is a comment to the
401
+ parser too — and the table, the `Cleaned` pair it rode in, and the
402
+ fall-back-to-the-last-line lookup go with it. Blanking the line instead is
403
+ what this does *not* do: a blank line is content under keep chomping, so
404
+ `bio: |+` followed by a comment would have gained a newline. One break: a
405
+ comment indented inside a multi-line plain scalar now ends it, as it does in
406
+ YAML, so `key: aaa` / ` # c` / ` bbb` raises a located `parse::yaml_error`
407
+ where it used to fold to `"aaa bbb"` — a value no YAML parser reads out of
408
+ that document.
409
+ - refactor(core)!: **nested comments hang off the payload, not each item.**
410
+ `PayloadItem::Field` / `Meta` lose `nested_comments`; one list on `Payload`
411
+ carries them, at paths whose head segment names the owning entry. That is the
412
+ form prescan already produced and the storage DTO already stored, so the flat
413
+ → per-item → flat conversion at both ends is gone. `Payload` gains the public
414
+ `nested_comments()` and `rename_field`, which carries a field's comments with
415
+ its key; `items_mut` is withdrawn, having existed only for the rename that
416
+ now has a verb. The wire is untouched: `PayloadV0_92_0.nested_comments` was
417
+ already the flat sidecar.
418
+ - fix(core): **an unclosed root `~~~` block is reported as unclosed.** A
419
+ document that opens with `~~~` and `$quill` but never closes the fence drew
420
+ the generic `parse::missing_quill` text, telling the author to open a block
421
+ they had already opened while the scanner's unclosed-fence signal was
422
+ dropped. The message names the opener's line, the field to close after, and —
423
+ for a `~~` run or an indented `~~~` — the line that failed to close it.
424
+ - fix(core)!: **a card-yaml parse failure carries a document `Location`.**
425
+ `YamlErrorWithLocation` keeps the engine's line and column, translated
426
+ through the comment lines prescan drops and the leading whitespace `trim`
427
+ removes onto the document's own coordinates, and `to_diagnostic()` sets them
428
+ as the `Location` (`input.md`). The message names the block (`YAML error in
429
+ the root card-yaml block: …`) in place of the block-relative `at line N
430
+ (block K)` prefix, and `args` no longer carries `line`.
431
+ - fix(core): **a card fence with CRLF line endings parses as its LF twin
432
+ does.** The prescan splits the fence body on `\n`, so every CRLF line reached
433
+ the matchers with a trailing `\r`: a bare `x: !must_fill` matched neither
434
+ spelling the fill-tag stripper accepts, so the marker was read as an unknown
435
+ tag — dropped with two warnings, a `null` value and an `x: null` emit — and a
436
+ lone `-` opening a sequence item was not one. The scan strips one trailing
437
+ `\r` per line up front.
438
+ - fix(core): **`key: !must_fill` written inside a block scalar or a quoted
439
+ scalar is that scalar's text, and warns about nothing.** The
440
+ `parse::fill_marker_unsupported_position` check re-read every cleaned line
441
+ after the prescan, block-scalar bodies among them, and accepted any `:` plus
442
+ whitespace as the tag's left boundary. The prescan decides the warning per
443
+ line as it reads one, where a block-scalar body and a quoted value are known
444
+ for what they are; the four positions the marker genuinely cannot survive
445
+ still warn.
446
+ - fix(core): **a `!must_fill` marker nested inside `$seed` or `$ext` warns
447
+ instead of vanishing.** A `$` metadata value is a plain tree with no fill
448
+ carrier, so a marker under one reached neither storage nor emit and the cell
449
+ it marked read back as `null`. Parse emits a
450
+ `parse::fill_marker_unsupported_position` warning naming the cell
451
+ (`$seed.note.from`). The value under the marker is kept; a marker on the `$`
452
+ key itself remains a parse error.
453
+ - fix(core): **a field name is ASCII as written, not as it normalises.**
454
+ `is_valid_field_name` ran NFC before matching `[A-Za-z_][A-Za-z0-9_]*`, so
455
+ `store_field("\u{212A}elvin", …)` was accepted, emitted verbatim, and re-read
456
+ as a nested key: the document did not survive `parse(to_markdown())`. The
457
+ check reads the name's own characters, matching the raw bytes the parser's
458
+ key grammar accepts.
459
+ - fix(core): **a new `$` entry lands after the preceding `$` line's inline
460
+ comment, not between the line and its comment.** `Payload::upsert_meta`
461
+ inserted one past the last lower-ranked `$` item, which is the index the
462
+ trailing comment occupies, so `$quill: q@1.0 # note` with no explicit `$kind`
463
+ emitted `$kind: main # note`. The four callers now leave the trailer on its
464
+ own key and `parse(to_markdown())` holds.
465
+ - fix(core): **a comment between a bare `-` and the item's first key stays
466
+ inside the item.** The prescan records such a comment against the item, but
467
+ emit wrote it above the `- ` line, where it re-parses as a comment on the
468
+ sequence — a parse-emit-parse inequality settling only on the second emit. A
469
+ sequence item whose mapping carries an own-line comment before its first key
470
+ emits in the bare-dash form, so the first emit is the fixed point. A
471
+ blueprint's typed-table row takes that form too.
472
+ - fix(core): **a field write clears a root `!must_fill` bit riding on the value
473
+ it is handed.** The payload item's own `fill` flag is the one carrier of a
474
+ root marker — emit, the wire and the storage DTO all read it there — while
475
+ `QuillValue::set_fill_at(&[])` marks the value tree's root, and a value
476
+ stored after that call kept both bits. `Payload` clears the tree's root bit
477
+ on insert, so a document compares equal to itself across the markdown and
478
+ storage round trips. Parse skips a nested-fill path naming only its own key,
479
+ the route reaching the same split from source.
480
+ - fix(core): **`Card::store_fields` refuses a `!must_fill` marker targeting a
481
+ mapping, as `Card::store_field` does.** The batch checked the field-name
482
+ grammar and value depth only, so a marker on a nested object node was stored,
483
+ emitted with the marker dropped, and refused by the `@0.92.0` storage DTO on
484
+ reload. `edit::validate_fill_targets` runs per field in the same
485
+ all-or-nothing pass, so the offending name rides the batch's error vector as
486
+ `edit::fill_on_mapping`. The WASM `storeFields` inherits it.
487
+ - fix(core): **a `ParseError` spells its English once.** `to_diagnostic()`
488
+ renders the variant's `Display` instead of a second copy of the same
489
+ sentence, and the copies had drifted: `InvalidStructure` displayed under an
490
+ `Invalid YAML structure: ` prefix the diagnostic dropped. A Rust caller
491
+ formatting `{err}` from `Document::parse` reads that variant without the
492
+ prefix, matching what every binding and the CLI have always shown.
493
+ - fix(core): **body prose left inside a card block is told to close the block,
494
+ not to wrap itself in a block scalar.** A closing `~~~` placed after the
495
+ prose body fails YAML on the first prose line, and `simple key expected`
496
+ answered every such line with the wrapped-scalar advice: rewriting the memo
497
+ as `body: |` keeps the body inside the block and fails again. The hint reads
498
+ the flagged line the parser names and names the real fix — close the block
499
+ before the prose. A genuine plain scalar wrapped onto a second line keeps the
500
+ block-scalar hint.
501
+ - fix(core): **a leading space before a top-level key gets its own hint.** One
502
+ stray space folds the line into the preceding plain scalar, and YAML raises
503
+ the same `mapping values are not allowed` an unquoted `:` inside a value
504
+ raises — so the hint sent the reader hunting for a colon that is not in the
505
+ block, and four models quoted the `subject:` above it instead. Where the
506
+ flagged line starts with a space, reads as `key:` or `key: value`, and
507
+ follows a column-zero key line, the hint names the space.
508
+
509
+ ### The binding surface
510
+
511
+ - refactor(core,typst,wasm,python)!: **a diagnostic carries its cause in the
512
+ message.** `Diagnostic::source_chain` and the `with_source` builder that
513
+ filled it are gone, and with them JS `Diagnostic.sourceChain` and Python
514
+ `Diagnostic.source_chain`. One code ever filled the field:
515
+ `typst::world_creation`, whose boxed cause is a `String` whose `source()` is
516
+ `None`, so the chain was a one-element array holding the text its own message
517
+ already ends with — and `skip_serializing_if` omitted the field from every
518
+ other diagnostic. No formatter read it: `fmt_pretty` covers severity, message,
519
+ code, location and hint, so the CLI's output and Python's `str(diagnostic)`
520
+ are byte-identical. A Rust caller attaching a cause interpolates it into the
521
+ message, which is what `RenderError::coded` already does at the one call site.
522
+ Refs #1748.
523
+ - build(wasm): **three artifacts to two: the `render` build carries both
524
+ backends.** The core split (no engine, ~0.5 MB) is real for a web editor and
525
+ stays guarded. A pdfform-only binary bought "a pdfform-only page skips 8 MB
526
+ of Typst", and no such page exists; the render artifact grows by the
527
+ `quillmark-pdfform` crate and a brotli decoder, `hayro` already shipping in
528
+ it under `typst-render`. The wasm `typst` and `pdfform` features collapse
529
+ into one `render` feature — a feature named for one backend that selects two
530
+ would be a fresh inaccuracy in place of the one #1749 records — and the
531
+ forty-three `#[cfg(any(feature = "typst", feature = "pdfform"))]` sites
532
+ become `#[cfg(feature = "render")]`. `build-wasm.sh` emits `pkg/core/` and
533
+ `pkg/render/`; `runtime.js` keeps one `DEFAULT_BACKENDS` entry per backend
534
+ id, each with its own `formats` manifest, sharing one memoized load, and the
535
+ `Engine` keys its module, engine and clone caches on the descriptor's `load`
536
+ thunk rather than the backend id, so two ids over one build hold one entry
537
+ each. Both drift guards stay, the pdfform one retargeted at the merged
538
+ build. Closes #1640.
539
+ - feat(core)!: **`reader.get` answers in the values form, and `ReadValue` is
540
+ gone.** `get` returns the plain value: every content leaf in the field's type
541
+ tree as its codec's text (`richtext` markdown, `plaintext` literal),
542
+ descending `items` / `properties` / `variants`, so `reader.get("paragraphs")`
543
+ is `["Para **one**", …]` where an `array<richtext>` used to return the stored
544
+ content objects. A present-null reads `null` rather than `""`. A leaf that
545
+ does not decode raises `edit::field_decode` anchored at the element
546
+ (`main.paragraphs[1]`). A read never coerces: `qty: "3"` reads `"3"` here and
547
+ `3` only in `resolve`. `reader.getContent` and `reader.getContentAt` are
548
+ unchanged, and are where a leaf's anchors and island ids read back: the text
549
+ form carries neither.
550
+ - feat(bindings)!: **the storage DTO verbs name their lane, not their
551
+ encoding.** `Document.toJson` / `fromJson` / `loadJson` become `toStored` /
552
+ `fromStored` / `loadStored`, and Python's `to_json` / `from_json` become
553
+ `to_stored` / `from_stored`. `storageVersionOf` and `currentStorageVersion`
554
+ are unchanged, already naming storage. "Stored" is the at-rest form
555
+ throughout, so the pair completes the family `getStored` started; the bare
556
+ `store` stays the field-write lane's verb. The old names are removed rather
557
+ than aliased. Stored blobs, the `schema` tag, and every byte these verbs
558
+ write are untouched.
559
+ - feat(bindings)!: **`resolve` lives on the reader.** `quill.resolve(doc)`
560
+ becomes `quill.reader(doc).resolve()`: a verb that needs a schema lives on
561
+ the cursor. WASM-only, as before.
562
+ - refactor(core,wasm,python,cli)!: **`Quill.metadata` loses the
563
+ `<backend>_<key>` mirror, and `quillmark info` loses `--json`.** A loaded
564
+ quill carried four identity fields verbatim plus every key under the backend
565
+ section flattened as `<backend>_<key>`, so `typst: { plate_file: plate.typ }`
566
+ surfaced as a `typst_plate_file` entry. Nothing read a mirrored key: the
567
+ Typst backend and the CLI read `QuillConfig::backend_config`, and both
568
+ bindings built their identity keys from the config and used the map only for
569
+ the leftovers. `quill.metadata` (JS and Python) carries exactly `name`,
570
+ `version`, `backend`, `author`, `description`, in that order — so a metadata
571
+ snapshot serializes stably across processes, where the extras rode core's
572
+ `HashMap` iteration order. In Rust, `Quill::metadata` and
573
+ `quillmark_core::STANDARD_METADATA_KEYS` are deleted. The `--json` flag's one
574
+ distinctive output was that mirror.
575
+ - refactor(wasm,python)!: **five owner calls leave both bindings.** Each is a
576
+ call the host makes itself in a line or two from surface that stays:
577
+ `Document.tryFromJson` / `try_from_json` — never renamed with its lane, and
578
+ deleted rather than aliased, so `storageVersionOf(b) ? fromStored(b) : null`
579
+ is the read — `Document.makeCard` / `make_card` (a `CardInput`
580
+ object literal), `doc.setCardKind` / `set_card_kind` (`removeCard` +
581
+ `insertCard` at the same index),
582
+ `result.renderTimeMs` / `render_time_ms` (clock the call), and
583
+ `Document.formatDiagnostic` (the CLI and Python's `str(diagnostic)` still
584
+ render it). They go from **both** surfaces, so WASM remains the reference
585
+ surface Python mirrors.
586
+ - refactor(wasm)!: **a foreign handle on a by-reference method is a bare
587
+ `Error`, and `runtime::foreign_handle` retires.** The prototype patches on
588
+ `Document.equals`, `Quill.validate` and `Quill.conform` are gone with the
589
+ `Symbol.for('@quillmark/wasm:handle-checked')` marker they needed:
590
+ wasm-bindgen's own `_assertClass` already refuses a foreign class wherever a
591
+ method declares a reference parameter, and it throws a bare `Error`, so
592
+ `isQuillmarkError` reads `false` there. The seams that cross as **data** keep
593
+ their check, nothing else catching them: the four writer/reader binds, every
594
+ `Engine` verb, and `LiveSession.update`. A value that is not one of this
595
+ copy's handles is `runtime::not_a_quill` / `runtime::not_a_document`, whose
596
+ hint names both cures including `npm ls @quillmark/wasm`. A consumer routing
597
+ on `runtime::foreign_handle` routes on the two `not_a_*` codes instead.
598
+ - refactor(python)!: **`Writer` / `Reader` take a `card=` selector.**
599
+ `writer.card(i)` and `reader.card(i)` returned a cursor holding the index;
600
+ the index is a keyword on the verb, `None` selecting the main card.
601
+ `CardWriter` and `CardReader` go with the cursors.
602
+ - refactor(core,wasm,python)!: **the `$ext` namespace verbs collapse into the
603
+ whole-map three.** `storeExtNamespace` / `removeExtNamespace` /
604
+ `getExtNamespace`, the Python pair, and the `Card::*` methods they delegated
605
+ to are deleted. `getExt` / `storeExt` / `removeExt` keep the whole surface,
606
+ `card` selector included: `$ext` is a map the engine never inspects, so a
607
+ namespace write is `{...getExt(addr), [ns]: v}` on the client and the read
608
+ shape is the write shape. The one behavior the spread does not carry is the
609
+ drop-when-empty — `removeExtNamespace` dropped `$ext` with its last namespace
610
+ where `storeExt({})` records an explicit `$ext: {}` — for which `removeExt`
611
+ is the call. Stored bytes, the Markdown round trip and the plate strip are
612
+ untouched.
613
+ - fix(core,wasm,python)!: **a card built from a wire refuses under the code its
614
+ addressed mutator mints.** `insertCard` folded a malformed field name, a bad
615
+ `$quill` reference, a `!must_fill` on a mapping and a malformed item list
616
+ into one code-less refusal, so `diagnostics[0].code` was `undefined` in JS
617
+ and Python raised a bare `ValueError` — while `storeField` / `setQuillRef`
618
+ carried `edit::invalid_field_name`, `parse::invalid_quill_reference` and
619
+ `edit::fill_on_mapping` for the same violations. `WireError` carries the
620
+ `EditError` its addressed twin raises and both bindings stamp
621
+ `WireError::code()` on the diagnostic. A card dict Python cannot read as a
622
+ card at all is still a `ValueError`; one that deserializes and then violates
623
+ an invariant is a `QuillmarkError` under its code. The list-level violations
624
+ (a duplicate key, a field count past the §8 bound, a `$` entry twice, a
625
+ comment spanning lines) carry the new `edit::invalid_payload`.
626
+ - fix(python)!: **every `edit::*` diagnostic anchors at the `DocPath` its verb
627
+ ran against.** `Diagnostic.path` had two spellings for one refusal:
628
+ `writer.set` minted none and `writer.set_all` the bare field name. The
629
+ converters thread the base anchor the WASM binding does, so an undeclared
630
+ name is `main.stray` from every main-card verb, `cards.<kind>[<i>].stray`
631
+ from a card cursor, and a structural out-of-range op is `cards[<i>]`. A
632
+ consumer comparing against a bare field name now matches nothing.
633
+ - fix(python)!: **a negative index is an out-of-range index, not an
634
+ `OverflowError`.** Every index parameter was a `usize`, so `doc.card(-1)`,
635
+ `doc.move_card(-1, 0)` and `render(pages=[-1])` died in the boundary
636
+ conversion with a third exception type, outside the two the binding
637
+ documents. Indices are signed at the boundary. A negative one addresses
638
+ nothing — it is not the last card, and the binding does not index from the
639
+ end — so it takes the answer its site already gives an index past the end:
640
+ `edit::index_out_of_range`, `backend::page_index_out_of_bounds`, or `None`
641
+ where `remove_card` answers absence.
642
+ - fix(python): **`OutputFormat` and `Severity` members are hashable.** The two
643
+ pyclass mirrors declared `eq` alone, which fills `tp_richcompare` and leaves
644
+ no `tp_hash`, so CPython stamped `__hash__ = None` and every variant was
645
+ rejected as a set member or a dict key. Both are frozen and hash by variant.
646
+ - fix(python): **a card dict is `CardWire`'s serde projection rather than a
647
+ hand copy of it.** `card_to_pydict` serializes the wire and adapts the two
648
+ keys Python's surface owns: snake_case `payload_items`, and an explicit
649
+ `None` where an absent `$quill` / `$ext` / `$seed` leaves the wire key out.
650
+ Every key and value is what it was; the dict iterates in the wire's own
651
+ order.
652
+ - perf(python): **reading `RenderResult.artifacts` copies no bytes.** The
653
+ getter rebuilt its list on every read, cloning each artifact's buffer, and
654
+ `Artifact.bytes` cloned that again, so a `save` followed by one `bytes` read
655
+ moved a multi-MB PDF three times. The `Artifact` and `Diagnostic` objects are
656
+ built once, with the result; `bytes` makes the one Rust→Python copy and
657
+ `save` writes without one.
658
+ - fix(wasm): **a failed conversion at the typed boundary throws instead of
659
+ stranding the JS handle.** `RenderOptions`, `RenderResult`, `Diagnostic`,
660
+ `ChangeSet`, `ContentHit` and `FieldRegion` cross as `tsify::Ts<T>`, whose
661
+ handle the wasm-bindgen shim owns and frees; the deprecated `into_wasm_abi` /
662
+ `from_wasm_abi` impls they carried leaked it on the way out (tsify#65), and
663
+ took the module down with a trap rather than a catchable error when
664
+ serialization failed. The TypeScript surface is byte-identical, and the crate
665
+ compiles warning-free.
666
+ - fix(wasm): **an unregistered backend rejects with `engine::backend_not_found`
667
+ like every other failure.** The four `Engine` verbs threw a bare `Error` for
668
+ a quill whose declared `backend:` has no loader, so `isQuillmarkError`
669
+ answered `false` and the README's own `catch` example re-threw it as a
670
+ foreign failure. The rejection carries one diagnostic under the code core and
671
+ the Python binding already raise, hinting the registered backend ids.
672
+ - fix(wasm): **`Engine.render` returns the document's load warnings ahead of
673
+ the compile's own.** The engine renders a backend-memory clone built by
674
+ `Document.fromStored`, which carries no warnings, so a parse, `conform::*` or
675
+ `plate::unsupported_construct` diagnostic reached `RenderResult.warnings`
676
+ from no public surface. `Engine.render` snapshots `doc.warnings` beside the
677
+ storage DTO and fronts the result with it, the pipeline order ERROR.md
678
+ states. `LiveSession.render` carries the compile half alone.
679
+ - fix(wasm)!: **a `MarkOp` spells its payload where the decoder reads it, off
680
+ one `ContentMarkKind`.** The `link` and `anchor` arms declared it as a named
681
+ sibling — `{ type: "link"; url }` — which is the spelling the authored lane
682
+ refuses as the retired `@0.93.0` encoding (`content json shape: legacy mark
683
+ payload`). The decoder reads a built-in's payload out of `attrs` and the
684
+ canonical encoder writes it there, so the type named the one shape
685
+ `applyChange` rejects and rejected the one it takes. It was survivable while
686
+ the union carried its open arm, `{ type: string; attrs: unknown }`, whose
687
+ shape happened to match the decoder: a correct op type-checked through the
688
+ wrong arm. Closing the vocabularies deleted that arm and left no spelling
689
+ that both compiles and runs. Nothing on the wire moves — the runtime accepted
690
+ `attrs` and only `attrs` throughout — so this reaches a consumer as a type
691
+ that stops refusing correct code. The drift was possible because the op
692
+ restated the payload: `ContentMarkKind` names the three arms once, exported
693
+ from the package root beside `ContentLineKind`; `ContentMark` is a range over
694
+ it and `MarkOp`'s `add` / `remove` are a `ContentMark` under an op, so an arm
695
+ added upstream reaches both by construction and `{ op: 'remove', ...mark }`
696
+ type-checks for a held mark. The type guard lifts a mark's payload by name as
697
+ it lifts a line's, beside an `@ts-expect-error` pair refusing the sibling, and
698
+ the Rust drift guard reads the mark vocabulary off `ContentMarkKind`.
699
+ - fix(wasm,python): **`reader.getContentAt` / `get_content_at` stays.** The
700
+ owner-call sweep took it as a call the host makes in a line or two, but
701
+ neither read that remains is one: `reader.get` answers in the values form,
702
+ which is text, and an anchor has no markdown projection while an island's
703
+ `id` is re-minted by every importer, so the round-trip a nested content
704
+ editor performs loses both; `getStored` echoes bytes under `unknown` and
705
+ hands the codec dispatch back to the caller, which is the judgement the read
706
+ exists to make. The verb returns in its 0.112 shape — `path` a `PathStep[]`
707
+ walked through the field schema's `items` / `properties` / `variants` to the
708
+ leaf, absent for a path naming nothing stored — with Python's twin taking the
709
+ `card=` selector that replaced the `CardReader` cursor.
710
+
711
+ ### The engine seam and the backends
712
+
713
+ - refactor(pdfform)!: **`quillmark-pdfform` is `quillmark-acroform`, backend id
714
+ included.** `quillmark-pdf` and `quillmark-pdfform` differed by four
715
+ characters and read as prefix-and-specialization, the reading #1749 records:
716
+ that the form crate was the spine's only consumer. The spine keeps its name,
717
+ a bare generic name reading as leaf infrastructure with many consumers, and
718
+ the backend takes a distinct one at every layer the old one was spelled:
719
+ crate, directory, `AcroformBackend` with `id()` of `"acroform"`, the
720
+ `quillmark` cargo feature, the `pdfform::*` diagnostic namespace (now
721
+ `acroform::*`, the suffixes unchanged), the WASM `DEFAULT_BACKENDS` key, the
722
+ docs page and the two fixture quills' `backend:`. No alias: an unmigrated
723
+ `backend: pdfform` fails at render with `engine::backend_not_found`, whose
724
+ hint lists the registered backends, and a half-migrated quill with a leftover
725
+ `pdfform:` section fails at load with `quill::unknown_section`. `pdf::*` is
726
+ untouched. Closes #1775.
727
+ - refactor(typst)!: **`form-field` keeps `text` and `signature`.** `usaf_memo`
728
+ uses those two. Checkbox and choice were the only kinds needing a value
729
+ coercion, and both coercions were copies of the pdfform resolver's,
730
+ duplicated because the Typst backend must not depend on it: deleting the two
731
+ kinds deletes the duplicate, and `FieldKind`, the mirror of the spine's
732
+ `FieldType` it existed for, retires with them — the extractor reads
733
+ `(FieldType, Option<String>)` directly. A plate wanting an interactive
734
+ checkbox or dropdown is a form-backend quill: pdfform keeps both kinds on
735
+ the spine. `multiline` stays, one bool on `FieldType::Text` the spine keeps
736
+ either way. A plate passing `type: "checkbox"` or `type: "choice"` fails the
737
+ helper's type assert, and `options:` is no longer a parameter. Closes #1644.
738
+ - refactor(core)!: **`.quillignore` is not read; the ignore set is the built-in
739
+ one.** A three-rule parser with a raw-string fallback — `dir/`, a literal
740
+ name, and a glob matched against both the whole path and the basename that
741
+ also matches the line it was written as — served a format one bundle in the
742
+ tree used. That bundle's file was a copy-pasted demo ("This demonstrates the
743
+ .quillignore functionality") restating the built-ins it would have inherited
744
+ by writing nothing. The bracket-as-literal reading in the glob
745
+ existed for `Cinzel[wght].ttf`, a name no `.quillignore` in this repo's
746
+ history spells. `QuillIgnore` is the fixed set alone now: `.git/`, `target/`
747
+ and `node_modules/` with their subtrees, anchored at the bundle root, and
748
+ `.gitignore` wherever it sits. `.quillignore` leaves that set with the format
749
+ — the loader has no reason to hide a file it no longer reads — so a bundle
750
+ carrying one walks it into the tree as an ordinary file, and a bundle that
751
+ relied on the file to exclude something ships that something. `QuillIgnore::new`
752
+ and `QuillIgnore::from_content` go; `default()` and `is_ignored` stay, on what
753
+ is now a unit struct. Refs #1641.
754
+ - fix(typst)!: **a vendored package without `typst.toml` is skipped, with the
755
+ warning its siblings already get.** A `packages/<dir>/` carrying no manifest
756
+ had one synthesized — `@local/<dir>:0.1.0` — and loaded under it. That spelling
757
+ was undocumented, unfixtured and untested, and it split one package tree into
758
+ two behaviors: the synthesized path passed no entrypoint, so it alone skipped
759
+ the `typst::package_entrypoint_missing` check that the same files with a
760
+ two-line manifest get. A manifest-less directory now warns under
761
+ `typst::package_manifest`, the code its malformed-manifest and bad-version
762
+ siblings already carry, and is skipped. The migration is the file the fallback
763
+ was standing in for: `parse_package_toml` defaults `namespace` to `local`,
764
+ `version` to `0.1.0` and `entrypoint` to `lib.typ`, so `[package]` plus
765
+ `name = "<dir>"` reproduces the old spec exactly. `package_spec` and
766
+ `entrypoint` stop being `Option` on `load_package_files_from_quill`, whose two
767
+ call sites always passed one, and the infallible `"0.1.0".parse()` dressed as
768
+ fallible goes with them. Refs #1698.
769
+ - refactor(core,wasm)!: **canvas preview is part of the backend contract, and
770
+ the pre-session probe goes.** `SessionHandle::page_size_pt` and `render_rgba`
771
+ lose their absent-reading defaults and become required, so a session paints
772
+ by construction rather than by opting in. Each return value carried two
773
+ meanings and now carries one: `None` and `Ok(None)` say the page is past
774
+ `page_count()`, where they also used to say the backend had no painter.
775
+ `update`, `regions` and `field_at` keep their defaults — this closes the
776
+ canvas door alone. With the door shut, the probe that guessed at it goes at
777
+ every layer: `Quillmark::supports_canvas`,
778
+ `quillmark_core::formats_support_canvas`, the WASM
779
+ `Engine.supportsCanvas(quill)` and `LiveSession.supportsCanvas` getters, and
780
+ the `canvas` key of a runtime `BackendDescriptor`, leaving a descriptor's
781
+ manifest as `formats` alone. It keyed on output formats — true iff the
782
+ backend emitted PNG or SVG — while paint is a `SessionHandle` seam a backend
783
+ overrides independently, so the two could disagree; every backend the
784
+ workspace ships paints, so it answered `true` in every build.
785
+ `LiveSession::supports_canvas()` reduced to `page_count() > 0`, which is what
786
+ a Rust caller writes instead. Both shipped backends already implement the
787
+ pair, so no rendered pixel moves. A compile with nothing to paint meets the
788
+ out-of-range refusal, `"paint: page index 0 out of range (pageCount=0)"`, in
789
+ place of a message naming a painter the backend has. Closes #1706.
790
+ - refactor(pdfform)!: **the SVG and PNG output formats go; canvas paint
791
+ stays.** `supported_formats` reports `[Pdf]`, so a `render` asking for either
792
+ fails under `backend::format_not_supported`, and `quillmark render --format
793
+ png` on a pdfform quill does too. The two formats were views of the flattened
794
+ form consumed by nothing but their own test. `render_rgba` and the `hayro`
795
+ dependency stay, so a WASM consumer paints pdfform pages exactly as before:
796
+ paint is a `SessionHandle` seam, not an output format. `hayro-svg` goes with
797
+ the SVG artifact path, its only caller.
798
+ - refactor(core,wasm,python)!: **the `producer` render option goes; the
799
+ `/Producer` stamp it overrode stays.** `RenderOptions::producer` and
800
+ `with_producer`, the WASM key and Python's `producer=` keyword are deleted.
801
+ No first-party caller set it, and the engine carried the string through four
802
+ crates and two bindings to reach a writer a host can reach directly. Every
803
+ rendered PDF still carries `Quillmark <version>`: the default sits in
804
+ `quillmark-pdf`'s `StampOptions::default()`, one `format!` over
805
+ `version.workspace = true` in place of a `default_producer()` in each
806
+ backend. `StampOptions::producer` becomes a plain `String`, so `stamp` always
807
+ appends its `/Info` revision where a producer-less call over an empty field
808
+ list returned the base bytes; both backends always passed a producer, so no
809
+ render changes.
810
+ - refactor(all)!: **the crate-compatibility ceremony is withdrawn:
811
+ `#[non_exhaustive]`, the `Backend` seal, public `register_backend`, and the
812
+ SemVer promise `COMPATIBILITY.md` carried.** The attribute leaves the 86
813
+ items that held it, so a struct literal, functional update and exhaustive
814
+ destructuring compile out-of-crate again — `RenderOptions { ..,
815
+ ..Default::default() }` among them. Its one observable effect was the forced
816
+ `_` arm, which hides a variant a build has not learned instead of reporting
817
+ it: the WASM and Python `Severity` conversions folded an unmatched level into
818
+ `Error`, and both now match every variant. `Backend` loses its
819
+ `sealed::Sealed` supertrait; implementing the trait outside the workspace
820
+ stays impossible, because `Backend::open` returns a `LiveSession` only a
821
+ `#[doc(hidden)]` `SessionHandle` builds and `Quillmark::register_backend` is
822
+ private, leaving `Quillmark::new` as the whole registry.
823
+ `prose/canon/COMPATIBILITY.md` is deleted — a promise to crates.io consumers
824
+ that, per its own text, no CI job checked; its table of `pub` seams that
825
+ serve the workspace rather than a consumer moves into `ARCHITECTURE.md`'s
826
+ backend section.
827
+ - fix(typst,pdfform)!: **`RenderOptions::pages` means one thing on every
828
+ backend, under `backend::*` codes.** The PDF-form backend ignored the option:
829
+ it rendered every page whatever was asked for, and an out-of-range index
830
+ passed silently. It refuses a selection now, PDF being its one format, and
831
+ both backends mint the two refusals from the shared constructors in
832
+ `quillmark_core::backend`, so the codes are
833
+ `backend::page_index_out_of_bounds` and
834
+ `backend::page_selection_not_supported` in place of the Typst-private
835
+ `typst::*` pair.
836
+ - fix(typst,pdfform)!: **a raster nobody can allocate is refused, not
837
+ attempted.** `RenderOptions.ppi` and the `render_rgba` canvas scale reached
838
+ tiny-skia and hayro unchecked; both size their buffer from the value and
839
+ unwrap it, so `ppi: Infinity` or `1e9` panicked the process — in WASM a trap
840
+ that takes the engine instance with it — while NaN, zero and a negative
841
+ quietly rasterized a 1×1 image. Every raster path refuses under
842
+ `backend::invalid_raster_scale` a scale that is not finite and positive, or
843
+ that would put a page past `MAX_RASTER_PIXELS` (16384² px: a 1 GiB RGBA
844
+ buffer, and the area of the WASM painter's per-side clamp, so nothing that
845
+ clamp admits is refused). US Letter at the default 144 ppi is 138× under it.
846
+ `SessionHandle::render_rgba` and `LiveSession::render_rgba` return
847
+ `Result<Option<(u32, u32, Vec<u8>)>, RenderError>` to carry the refusal;
848
+ `Ok(None)` is still the out-of-range page.
849
+ - fix(pdfform)!: **canvas geometry measures from the page's canvas box.** hayro
850
+ rasterizes `/CropBox` ∩ `/MediaBox` and draws that box's lower-left corner at
851
+ the raster's origin, while `page_size_pt` reported the `/MediaBox` extent and
852
+ `regions()` reported widget `/Rect`s in raw user space. An overlay over a
853
+ `pdfcrop`ped background (`/MediaBox [96 133 500 700]`) therefore sat 96 × 133
854
+ pt off its ink, and a `/CropBox` inside the MediaBox reported a page bigger
855
+ than its own raster. `page_size_pt` is the canvas box's extent, `regions()`
856
+ subtracts its lower-left corner, and `form.json`'s top-left rects flip
857
+ against it, so `pageSize`, `regions`, the point queries and the raster share
858
+ one origin. Values move only on a page whose canvas box does not start at
859
+ `(0, 0)`. The stamped PDF's widget `/Rect`s stay in user space.
860
+ `quillmark_pdf::page_media_boxes` is `page_canvas_boxes`, and it refuses a
861
+ canvas box under a point per side (`pdf::degenerate_page_box`) and a page box
862
+ that is not a direct array of numbers.
863
+ - fix(pdfform)!: **flatten's own parse failure is `acroform::flatten_parse`.** A
864
+ page dict or `/Contents` the content-stream flattener cannot read raised
865
+ `pdf::flatten_parse`, naming the stamp spine for a failure of the backend's
866
+ own code. The backend's namespace renames later in this same cycle, so the
867
+ code ships as `acroform::flatten_parse`, not the `pdfform::` spelling the
868
+ commit landed. `pdf::bad_rect` stays the spine's and is minted in one place,
869
+ `FieldSpec::assert_finite_rect`, which the stamp and flatten paths both call.
870
+ - feat(typst,pdfform,cli)!: **`pdfform::form_schema_version` retires, and the
871
+ CLI loses three flags.** A `form@0.1.0` file still fails to load, now as an
872
+ unrecognised tag under `acroform::invalid_form_json` (the namespace renames
873
+ later in this cycle). `render --verbose` is
874
+ deleted, so `--quiet` states what it suppresses on its own; `schema -o` and
875
+ `blueprint -o` are deleted, both commands writing to stdout where `>` does
876
+ the rest. `render -o` is unchanged. `validate` reads `plate_file` from the
877
+ loaded quill rather than the filesystem, so a plate the load excludes fails
878
+ validation, which is what rendering it already did.
879
+ - change(typst): **an image in a content field draws nothing and warns.** What
880
+ a content image's `url` names is undecided — a document is quill-free but for
881
+ `$quill`, which selects a *range* of versions, and declares everything else
882
+ it references — so the Typst backend lowers an `image` island to nothing
883
+ rather than binding one reading of the string. A quill asset stays the
884
+ plate's to draw (`#image("assets/logo.svg")`), unchanged. The refusal is
885
+ legible where it used to be a Typst file-not-found error about a generated
886
+ file the author never wrote: one **`backend::declined_construct`** warning
887
+ per content field, `args` `{backend, construct, count}` and the field's
888
+ `DocPath` in `path`, minted by the new `quillmark_core::declined_construct`
889
+ so it cannot drift from quill-declared `plate::unsupported_construct` — the
890
+ sixth warning family, and the first a backend *observes* rather than a quill
891
+ declares. **A consumer routing on diagnostic codes gains a warning family**
892
+ and needs an arm for it. Storage is untouched: an `image` island still
893
+ parses, stores, round-trips to markdown and reaches an editor with its `{url,
894
+ alt}` props.
895
+ - fix(typst): **a compile diagnostic carries a code from a closed set.** The
896
+ code was the message up to its first `:`, so a missing asset minted
897
+ `typst::file not found (searched at assets/logo.png)` — the author's path
898
+ inside what consumers route on, one code per input — and a URL truncated
899
+ mid-value to `typst::file not found (searched at https`. Most Typst messages
900
+ hold no `:` at all, so the whole sentence became the code. The mapping
901
+ classifies: `typst::file_not_found`, `typst::unknown_variable`,
902
+ `typst::type_error`, and `typst::compile` for every message the set does not
903
+ name, warnings included. Typst's sentence, and the path it searched, stay in
904
+ `message`.
905
+ - fix(typst): **a `form-field` widget's rect is the box it prints in, whatever
906
+ the layout context.** The helper emitted its `<__qm_field__>` metadata beside
907
+ the box rather than inside it, and a tag's own position is the line's
908
+ baseline inline and the flow cursor's left edge in a block: an inline widget
909
+ reported a rect one box-height low, and one under `#align(center, ..)`
910
+ reported the left margin. The tag rides in the box body, whose origin is the
911
+ box's top-left in every layout context, so `session.regions()`, `fieldAt` and
912
+ the stamped AcroForm `/Rect` all land on the widget. A plate that compensated
913
+ for the offset shifts by that much.
914
+ - fix(typst): **`escape_markup` lowers every character Typst reads as a newline
915
+ to a space.** A content that reached the emitter without passing the ingress
916
+ — hand-built, or decoded from storage — could still carry `\r`, VT, FF, NEL,
917
+ U+2028 or U+2029, and the emitter wrote it through: the text behind it parsed
918
+ as a heading, list or term marker the document never wrote. One character to
919
+ one byte, so the per-character span scan stays exact.
920
+ - fix(typst): the font loader drops its `woff`/`woff2` extension arms, which
921
+ parsed nothing, and the data codegen drops its skip of a `__meta__` key
922
+ nothing produces.
923
+ - refactor(typst): **a compile's form fields cross to the PDF spine as one
924
+ derivation.** `Compiled` carries `field_specs: Vec<FieldSpec>` built with the
925
+ compile: `widget_regions` is `regions_of` over it and `render_document_pages`
926
+ stamps it directly, where each PDF render had rebuilt the specs from the
927
+ placements, page-height scan included. A placement naming a page outside the
928
+ document fails the compile alongside the extraction errors it sits with,
929
+ instead of emptying the session's regions and surfacing at render.
930
+ - fix(core): **the default `field_at` hands a tie to the later-painted
931
+ placement.** It ranked `regions()` with `min_by`, which keeps the first of
932
+ equal distances, so two placements on one rect resolved to the one underneath
933
+ — against the documented contract and against the Typst backend, which
934
+ overrides `field_at` with later-wins. The default walks `regions()` in
935
+ reverse, so a pdfform quill whose widgets overlap answers with the
936
+ last-stamped one.
937
+ - refactor(core,typst,pdfform): **the default PPI is stated once, as
938
+ `RenderOptions::DEFAULT_PPI`.** Each raster backend carried its own
939
+ `DEFAULT_PPI = 144.0` const, one of them documented as mirroring a core
940
+ constant that did not exist. `RenderOptions::ppi_or_default()` resolves the
941
+ option against that constant and both backends call it. Additive on the core
942
+ API; the resolved value is unchanged.
943
+
944
+ ### The PDF spine
945
+
946
+ - refactor(pdf,acroform)!: **a stamped widget draws its own value, so the second
947
+ PDF goes.** A widget carried `/NeedAppearances` and no appearance stream, so
948
+ the value reached only a viewer that synthesizes one and a flat rasterizer
949
+ drew an empty box. The form backend worked around that for its canvas by
950
+ building a whole second document — `flatten.rs` and `typography.rs`, 850 lines
951
+ — that baked each value into the page content streams, which meant resolving
952
+ inherited `/Resources`, inlining an indirect `/Font` dict, picking a resource
953
+ name free in the page's own, and splicing a `/Contents` that might name an
954
+ array object. The stamp now writes one `/AP` `/N` Form XObject per drawn
955
+ value, over a `/BBox` the size of the field box and its own one-entry
956
+ `/Resources`: the page is not touched, so none of that surgery has anything to
957
+ resolve. One document is the deliverable and the raster both, the preview is
958
+ the file, and a Typst `form-field` carrying a `value:` bakes one too. `/V`
959
+ stays the source of truth — the stream draws WinAnsi, from the left edge, and
960
+ the `/BBox` clips it — and `/NeedAppearances` still hands a synthesizing
961
+ viewer the whole value. With no second path to share them, `quillmark-pdf`'s
962
+ two `#[doc(hidden)] pub` modules are private and `PdfUpdate::begin` stops
963
+ taking a producer it was always given. Closes #1645.
964
+ - fix(pdf): **a stamped checkbox's `/DA` names ZapfDingbats.** `stamp` wrote a
965
+ checkbox's `/MK /CA (4)` caption and no `/DA`, so the widget inherited the
966
+ form-level `/Helv 0 Tf 0 g` and nothing registered the face the glyph lives
967
+ in: a viewer synthesizing the appearance under `/NeedAppearances` drew the
968
+ digit `4`, while the canvas raster drew the check mark through a real
969
+ ZapfDingbats resource. The widget now carries `/DA (/ZaDb 0 Tf 0 g)`, and
970
+ `/DR /Font` registers `/ZaDb` whenever a checkbox is present; `spec.font`
971
+ stays inert on a checkbox. The face, its resource name and the glyph are
972
+ `quillmark_pdf::{CHECK_FONT, CHECK_FONT_RESOURCE, CHECK_GLYPH}`, which the
973
+ acroform flatten path now reads rather than restating. Closes #1779.
974
+ - fix(pdf): **one predicate for the checkbox on-state.** `stamp` read any
975
+ `Some` value as checked and `flatten` only `Some("Yes")`, so a
976
+ `FieldSpec::value` the contract excludes — `value` is public — stamped
977
+ `/V /Yes` on a widget the raster drew blank. `FieldSpec::is_checked` is the
978
+ one reading, the strict one, and both paths call it. Closes #1780.
979
+ - fix(acroform): **a value carrying a newline binds to a multiline widget.**
980
+ `bind` decided `MULTILINE` from the schema type, so a richtext of two
981
+ paragraphs, a `String` block scalar, or any value whose projection keeps a
982
+ `\n` reached the stamped widget single-line unless `ui.multiline` said
983
+ otherwise, while the flattened raster stacked the lines: the author saw the
984
+ value in the preview and one line of it in the file. `resolve::field_spec`
985
+ now promotes a `Text` widget to multiline when the value it resolved holds a
986
+ `\n`, reading the same string the raster draws. An array's widget stays
987
+ multiline unconditionally, value or none, since a signer types its elements
988
+ one per line. Closes #1781.
989
+ - refactor(pdfform): **a checkbox reads a bool.** `is_truthy` accepted
990
+ `"yes"`, `"on"`, `"y"`, `"checked"`, `"1"` and any nonzero number beside
991
+ `true`. Nothing reaches it: a `checkbox` widget binds only to a `boolean`
992
+ schema field, and `resolve` runs against `compile_data`, where
993
+ `conform_value` has already turned a bool, a `"true"`/`"false"` spelling and
994
+ a number into a JSON bool — and refused everything else as
995
+ `CoercionError::uncoercible` before any widget resolves. So the vocabulary
996
+ was not a tolerance but a second, *wider* one behind a door the first never
997
+ opens: `"yes"` checks the box here and fails the compile upstream. The module
998
+ binds against `compile_data` precisely so coercion is inherited rather than
999
+ re-implemented, which is the line `is_truthy` crossed. `matches!(raw,
1000
+ Value::Bool(true))` is the whole rule now. No reachable behavior changes.
1001
+ - fix(pdf): **a stamped trailer carries one `/Info`, whatever shape the base's
1002
+ `/Info` had.** The producer stamp allocated a fresh information dictionary
1003
+ whenever the trailer's `/Info` did not parse as an indirect reference, while
1004
+ the trailer writer copied the old value forward regardless, so a direct-dict
1005
+ `/Info << /Title (x) >>` came out as two `/Info` keys in one dict — undefined
1006
+ per spec, parser-dependent in practice. The fresh reference supersedes the
1007
+ old value, and a direct dict's entries seed the object it names, so `/Title`
1008
+ and the rest survive the stamp.
1009
+ - fix(pdf): **the object index skips literal strings, `%`-comments and stream
1010
+ bodies, so `N G obj` bytes carried as content cannot shadow the real
1011
+ object.** The scan accepted any `<id> <gen> obj` at a token boundary and a
1012
+ later occurrence overwrites an earlier one, so a header spelled inside a
1013
+ string value (`/Subject (see 4 0 obj)`) or inside raw stream data displaced
1014
+ the real object's offset. `find_endobj_end` skips stream bodies too: `endobj`
1015
+ bytes in stream data truncated the object body.
1016
+ - fix(pdf): **a dict ending in a hex string parses to its real `>>`.** The
1017
+ scanner stepped over literal strings and `%`-comments but read a hex string
1018
+ as ordinary bytes, so the string's own `>` abutting the dict's `>>` closed
1019
+ the dict one byte early: `<< /T <41>>>` read as ` /T <41`, and a `/Producer`
1020
+ stamp rewrote the `/Info` with an unterminated `<…` — a title lost to any
1021
+ reader. `skip_string_or_comment` steps a `<` that no `<` follows to just past
1022
+ its `>`, which the dict, array and `endobj` scans all inherit. The trigger is
1023
+ real: pdf-writer's compact mode, which krilla and typst-pdf use, writes a
1024
+ non-ASCII `/Title` or `/Author` exactly this way.
1025
+ - fix(pdf): **an inheritable page attribute resolves along the page's own
1026
+ ancestor chain, not the root `/Pages` node alone.** `/Rotate` and `/MediaBox`
1027
+ were read from the page dict and then from the root, so a base whose
1028
+ intermediate `/Pages` node carries `/Rotate 90` passed the rotation guard and
1029
+ every stamped widget landed a quarter turn off, and a page inheriting its
1030
+ `/MediaBox` from an intermediate node was flipped against the root's page
1031
+ height. The `/Kids` walk carries each page's ancestor ids, nearest first, and
1032
+ both readers consult the page dict then that chain (ISO 32000-1 §7.7.3.4).
1033
+ - fix(pdf): **a `/Rotate` the reader cannot read as an integer refuses the
1034
+ stamp.** `assert_unrotated_pages` parsed the raw value and treated a failure
1035
+ as an absence, so `/Rotate 7 0 R` — legal for any dict value — climbed past
1036
+ the page and fell to the default zero, stamping every widget in unrotated
1037
+ user space onto a page the viewer turns. The first *present* value along the
1038
+ ancestor chain binds: a direct integer is checked as before
1039
+ (`pdf::rotated_page`), anything else is `pdf::parse`.
1040
+ - fix(pdf): **a base PDF whose trailer `/Size` sits above `i32::MAX` is refused
1041
+ rather than panicking mid-stamp.** `alloc_id` bounded only `u32` overflow, so
1042
+ a `/Size` in `2^31 ..= 2^32-2` handed out ids that cast to a negative `i32`
1043
+ and pdf-writer's `Ref::new` panicked — a crafted `form.pdf` opened cleanly
1044
+ and then took down the process, and with it a WASM module. `alloc_id` stops
1045
+ at `i32::MAX`, and every id that becomes a reference goes through a checked
1046
+ `to_ref`, which also covers the base page ids that never pass through
1047
+ `alloc_id`.
1048
+ - fix(pdfform): **flatten keeps the background's resources and its own
1049
+ `/Contents`.** `/Resources` is inheritable, so writing a fresh one onto a
1050
+ page that carried none shadowed the ancestor's dict and unbound every name
1051
+ the background stream selects; the effective dict is resolved up `/Parent`,
1052
+ inlined onto the page and extended there. The drawn fonts take names free in
1053
+ that dict (`Helv2` where `Helv` is taken), since a second binding for a name
1054
+ the background uses rebinds it under a last-wins parser. A `/Contents`
1055
+ reference naming an *array* object expands to its elements instead of being
1056
+ wrapped.
1057
+ - fix(pdfform): **a flattened value asserts black fill and the default text
1058
+ state before it draws.** The appended stream opened with `q` and set only
1059
+ `Tf`, and a page's `/Contents` array is one stream, so a background's
1060
+ unpaired `0.9 g` or `3 Tr` — a shaded field box, a scanned form's invisible
1061
+ OCR layer — carried into the drawn value and rastered it near-white or blank,
1062
+ with no diagnostic. Both writers open with `0 g 0 Tr 0 Tc 0 Tw 100 Tz 0 Ts`,
1063
+ the state the stamped `/DA` starts from. Canvas paint only: the AcroForm PDF
1064
+ deliverable is stamped, not flattened.
1065
+ - refactor(pdf): **one ancestor chain per page.** `PdfUpdate::resolve_pages`
1066
+ returns `Vec<Page>` rather than page ids: each `Page` carries its `/Pages`
1067
+ ancestors from the `/Kids` walk and resolves any inheritable attribute
1068
+ through `Page::inherited_attribute`. Flatten reads `/Resources` through it
1069
+ instead of climbing `/Parent` on its own, so rotation, media box and
1070
+ resources answer from the same chain under the same cycle and depth guards.
1071
+
1072
+ ### Schema, validation and the resolved view
1073
+
1074
+ - fix(core)!: **a quill declaring more fields than a card carries is refused at
1075
+ load.** `MAX_FIELD_COUNT` (1000) bounds one card-yaml block and every
1076
+ incremental field write charges it, but `Quill::seed_document` and
1077
+ `QuillConfig::blueprint` build a card from its schema wholesale: a `Quill.yaml`
1078
+ declaring 1001 fields under `main:` or under one `card_kinds.<name>:` loaded
1079
+ clean, and the failure landed at an exit: emitted markdown the parser refuses,
1080
+ and a card storage refuses. The load counts each card's declared fields and
1081
+ reports `quill::too_many_fields` naming the card, so the defect reaches the quill
1082
+ author in the artifact that holds it and both schema-driven constructions are
1083
+ total over every loaded quill. The count is per card and over declared fields
1084
+ alone: a nested `properties` map, an array's `items` and a `variants:` cell set
1085
+ ride inside the one field declaring them. Closes #1792.
1086
+ - fix(core)!: **a field write past the §8 field count is refused at the write.**
1087
+ `Card::store_field` validated a field's name and its value's depth and left the
1088
+ card's field count to the parser and the two storage doors, so a program could
1089
+ build a card past `MAX_FIELD_COUNT` (1000) through `storeField` / `storeFill` /
1090
+ `storeFields`, the typed `set` / `set_all` / `addCard`, or `revise` /
1091
+ `overwrite` on an absent field — and learn of it only at `toMarkdown`,
1092
+ `toStored`, or the card wire, each of which refused what the API had taken.
1093
+ Every field write funnels through one `Payload::insert` that holds the count,
1094
+ so an append past the cap is `edit::invalid_payload` carrying
1095
+ `PayloadViolation::TooManyFields` — the code the wire already mints for this
1096
+ violation — anchored at the card that is full. A replace is not a growth and
1097
+ still lands. The batches charge the count over the whole batch and report one
1098
+ diagnostic per name in the overflowing tail, applying none of themselves.
1099
+ Closes #1750.
1100
+ - refactor(core)!: **an enum's domain rides the type token.** `FieldType::Enum`
1101
+ carries `values`, and `FieldSchema::enum_values` is gone. The domain and the
1102
+ token had to agree, an agreement the loader enforced and the type could not,
1103
+ so every consumer keyed on the carrier and left a `FieldType::Enum` arm behind
1104
+ as unreachable residue — three of them, each answering "no domain" differently:
1105
+ the transform schema projected `{type: string}`, an open domain contradicting
1106
+ the token; validation skipped the membership check; pdfform refused to bind.
1107
+ One rule replaces them, special-cased nowhere: a domain admits its members and
1108
+ the blank, so an empty one admits only the blank. `FieldSchema::domain()`
1109
+ answers it for the three branches that enter through `variants:` holding no
1110
+ token. No quill loads differently and no stored or wire byte moves: `values:`
1111
+ is still the one spelling, still required non-empty on `type: enum`, still a
1112
+ load error elsewhere, and `Serialize` re-emits it from the payload in the slot
1113
+ it already occupied.
1114
+ - feat(core,wasm,python)!: **a quill carries the load's advisory diagnostics,
1115
+ so they reach a binding host at last.** `Quill::warnings()` is new, mirrored
1116
+ as `quill.warnings` in WASM and Python, and it answers whatever
1117
+ `QuillConfig::from_yaml_with_warnings` collected. Every construction door
1118
+ keeps them, which retires the two that existed only because the field was
1119
+ missing: `Quill::from_tree_with_warnings` and
1120
+ `quillmark::quill_from_path_with_warnings` are gone, and `from_tree` /
1121
+ `quill_from_path` are the whole surface. Closes #1625: `Quill::from_tree` was
1122
+ the door every binding took and the one that dropped them, so
1123
+ `quill::implicit_group` and `quill::body_example_unused` were visible only to
1124
+ the CLI's `validate`.
1125
+ - feat(core)!: **five retired `Quill.yaml` keys lose their tailored migration
1126
+ message, and an implicit group is a load error.** `must_fill`, `enum`,
1127
+ `ui.order`, the `richtext(inline)` type token and `markdown` were retired
1128
+ across 0.94, 0.104 and 0.108, each with a hand-written sentence naming its
1129
+ replacement. All five still fail to load, now under
1130
+ `quill::field_parse_error` with serde's unknown-key text and no hint.
1131
+ Separately, a `ui.group` on a card with no `ui.groups` registry is
1132
+ `quill::implicit_group` at **error** severity, the promotion that warning's
1133
+ own text scheduled. Note for a WASM or Python host: that warning never
1134
+ reached you (#1625), so the error is the first notice. Declare the registry.
1135
+ - fix(core)!: **`Quill::validate` refuses every value the render floor
1136
+ refuses.** Validation judged a floor refusal by the authored value's own
1137
+ shape, and two shapes read well-typed there: a content object that is not
1138
+ canonical content on a `richtext` or `plaintext` field (`{prose: older}`),
1139
+ and an integer past `i64` on an `integer` field. Both audited clean while
1140
+ `compile_data` and `dry_run` refused them, so the `validate`/`dry_run`
1141
+ pairing an editor runs on gave two verdicts. A leaf the floor cannot conform
1142
+ is a `validation::type_mismatch` at the field's path, unless a shape check
1143
+ already names the refusal (`validation::not_inline`, `validation::not_plain`,
1144
+ `validation::format_violation`); a container's refusal stays the element's or
1145
+ property's, at its own path. A numeric literal past `i64` reports `actual:
1146
+ number`, the type that does carry it, and such a literal in a `default:` or
1147
+ `example:` is a load error.
1148
+ - fix(core): **a type mismatch names the field's own declared type, so `date`,
1149
+ `datetime` and `enum` report themselves.** The validator collapsed the three
1150
+ onto `string`, so `due: 20260101` against `type: date` read "schema declares
1151
+ `string`" — a type the schema does not declare, with an exit that discards
1152
+ the field's format. The declared type has one source, `FieldType::as_str`,
1153
+ and `validation::type_mismatch` carries the name in `args.expected`.
1154
+ - fix(core): **a `$seed` overlay cell is validated as the document value it
1155
+ is.** `validate` judged each cell as a Quill.yaml schema literal, a context
1156
+ that refuses the container spelling of a variant-bearing enum and reads a
1157
+ present-null as a typed value, so `classification: { value: CUI, note: hello
1158
+ }` and `author: null` each drew a `validation::type_mismatch` warning while
1159
+ `seed_card` committed both. An overlay cell takes the same pass a card's own
1160
+ fields take. Overlays stay advisory and never gate render.
1161
+ - fix(core): **a seeded variant commits its cells without a discriminant to
1162
+ name their world.** `seed_card` needed the overlay or an `example:` to name a
1163
+ member and dropped the whole field otherwise, so a `$seed` entry was honored
1164
+ for a card's plain fields and silently discarded for its variant-bearing one.
1165
+ The world walked is the render floor's own selection — overlay › `example:` ›
1166
+ `default:` › blank — and the container commits whenever a cell has something
1167
+ to commit. `value` is written only where the overlay or an `example:` named
1168
+ the member, so a `default:` stays deferred to the floor.
1169
+ - fix(core): **`$seed` writes only on the card carrying `$kind: main`.**
1170
+ `store_seed_overlay` is inherent on `Card` and `card_mut` / `cards_mut` hand
1171
+ out `&mut Card`, so a root-only `$seed` reached a placed composable card
1172
+ after the positional gate on `push_card` / `insert_card` had run, producing a
1173
+ document that emits markdown the parser refuses and fails its own serde round
1174
+ trip. The card answers this about itself, so both mutable doors are covered
1175
+ at once.
1176
+ - fix(core): **`Quill::resolve` keeps a mis-shaped container value raw rather
1177
+ than blanking it under the document's own label.** A seed the render coercion
1178
+ cannot conform — `rows: abc` on an `array`, `addr: 5` on a typed dictionary —
1179
+ was rebuilt from the schema anyway, so the row showed an empty container
1180
+ still tagged `authored`. The container arms compose an absent or
1181
+ already-shaped seed only, and anything else falls through to the keep-raw
1182
+ path `conform_card_render` documents. The render gate refuses the shape, so
1183
+ the plate is unchanged.
1184
+ - fix(core): **a variant container the document wrote reads `authored`
1185
+ whichever rung filled its discriminant.** `resolve()` lifted a present
1186
+ container off the blank rung only, so `classification: {}` reported `default`
1187
+ where the schema declared one and `authored` where it did not — the reported
1188
+ rung turning on the schema rather than the document. A present container is
1189
+ `authored`; a present-null still reads as absent and keeps the discriminant's
1190
+ rung.
1191
+ - fix(core): **a blank `main.description` emits no description line in the
1192
+ blueprint.** The main card tested the raw string for emptiness and collapsed
1193
+ whitespace afterwards, so `main: { description: " " }` landed as a bare `#
1194
+ ` above the first field. The collapse runs first, and a description that
1195
+ collapses to nothing falls through to `quill.description`.
1196
+ - fix(core): **a nested richtext `example:` reaches the blueprint as its `#
1197
+ e.g.` hint.** A richtext cell never inlines its example, and the per-property
1198
+ builder for typed-dict properties and typed-table rows gated the hint on
1199
+ `default:` alone, so a defaultless richtext property's `example:` appeared in
1200
+ neither the cell nor a hint. Both gates are one helper, as BLUEPRINT.md §
1201
+ Typed dictionaries states.
1202
+
1203
+ ### The CLI
1204
+
1205
+ - fix(cli): **`render` parses a `MARKDOWN_FILE` through the bound door, so
1206
+ `conform::*` and `plate::unsupported_construct` warnings reach stderr.** The
1207
+ command called `Document::parse`, the transport door, which runs neither the
1208
+ conform walk nor the declined-construct walk: a `usaf_memo` body carrying a
1209
+ `***` rendered with an empty stderr where every other surface warns. It calls
1210
+ `Quill::parse` instead. Rendered bytes are the same — the coercion pass
1211
+ already ran in `compile_data` — and a `$quill` naming another quill refuses
1212
+ at parse rather than at compile, with the same diagnostics and the same exit
1213
+ 1.
1214
+ - fix(cli): **`validate` states each failure once.** A failing run printed its
1215
+ own summary and then a second copy through the error it returned, which
1216
+ `main` labelled `[ERROR] Invalid argument:` — including on a load failure,
1217
+ which is a quill config failure and not an argument the caller got wrong. The
1218
+ command returns `CliError::Reported` once it has written the per-diagnostic
1219
+ lines and the summary. Exit status stays 1 on every path.
1220
+ - fix(cli): **`-f PDF` writes `example.pdf`.** `--format` parses
1221
+ case-insensitively, but the derived output filename, the `example.<fmt>`
1222
+ fallback and the multi-page `--stdout` refusal interpolated the flag as
1223
+ typed. All three read the parsed format's lowercase id.
1224
+ - docs(cli): **the exit-code table separates a usage error from a refusal.**
1225
+ The CLI reference and the crate README both promised `1` on any error, while
1226
+ `clap` exits `2` on an invocation it cannot parse — an unknown flag, a
1227
+ missing argument, an unknown subcommand — before any command runs. `1` is the
1228
+ command running and refusing. `--help` and `--version` exit `0`. A smoke test
1229
+ pins the `2`.
1230
+
1231
+ ### The Rust surface
1232
+
1233
+ - refactor(core,quillmark)!: **every `quillmark-core` item is named at the module
1234
+ that defines it.** The crate declared its thirteen modules `pub` and
1235
+ re-exported 77 of their items at the root, so `quillmark_core::Document` and
1236
+ `quillmark_core::document::Document` both resolved and first-party callers used
1237
+ both; neither spelling was the one a reader could rely on seeing. The root
1238
+ re-exports go and the modules stay public, which leaves Rust's own rule as the
1239
+ whole of it: an item is reachable where it is defined. `Content` and
1240
+ `Normalized` keep their root spelling, the root being the only path core offers
1241
+ those two `quillmark-content` types. `quillmark::orchestration` goes the same
1242
+ way, leaving `Quillmark` named once at the facade root. Every removed path is
1243
+ an unresolved import, so the compiler names each of the 328 call sites; the
1244
+ `quillmark` facade re-exports the same list from the module-qualified core
1245
+ paths, so a consumer on the facade has nothing to do and no binding surface
1246
+ moves. `quillmark-pdf` already carried the one-path shape and needed no change.
1247
+ Closes #1791.
1248
+ - feat(core,quillmark): **`Normalized` and `ImportError` are nameable from core
1249
+ and the facade.** `quillmark_content::model::Normalized` is what `Card::body`
1250
+ returns, what `overwrite_body` / `overwrite_field` take on `Card` and
1251
+ `CardMut`, what all four `get_content` / `get_content_at` answer in, and what
1252
+ `dto::CanonicalContent` wraps; `quillmark_content::import::ImportError` is the
1253
+ payload of `EditError::Import`. No crate on that path re-exported either, so a
1254
+ consumer holding only `quillmark-core`, or only the facade, could bind what
1255
+ those verbs return but not write the type, and could not destructure the
1256
+ import refusal to read its depth. `Normalized` now sits beside the `Content`
1257
+ re-export it completes and `ImportError` beside `EditError`, both reach the
1258
+ facade, and `tests/facade_surface.rs` names them through the content lane.
1259
+ What `quillmark-content` carries *inside* the types core forwards — `Mark`,
1260
+ `Line`, `Island` and the enums they name — stays that crate's own surface.
1261
+ Closes #1790.
1262
+ - refactor(content)!: **every `quillmark-content` item is named at the module
1263
+ that defines it.** The crate declared its ten modules `pub` and re-exported 34
1264
+ of their items at the root, so `quillmark_content::Delta` and
1265
+ `quillmark_content::delta::Delta` both resolved and first-party callers used
1266
+ both; neither spelling was the one a reader could rely on seeing. The root
1267
+ re-exports go and the modules stay public, which leaves Rust's own rule as the
1268
+ whole of it: an item is reachable where it is defined. `MAX_NESTING_DEPTH` and
1269
+ `MAX_JSON_DEPTH` keep their root spelling, the crate root being where they are
1270
+ defined. Every removed path is an unresolved import, so the compiler names each
1271
+ site; the names `quillmark-core` re-exports keep their core spellings, so a
1272
+ consumer on core or on the `quillmark` facade has nothing to do, and no binding
1273
+ surface moves. Closes #1755.
1274
+ - refactor(core): **`ParseError::code` is the one variant-to-code match.**
1275
+ `code()` joins the three siblings that already carry the same
1276
+ `fn code(&self) -> &'static str` — `EditError`, `ValidationError`,
1277
+ `WireError` — and `to_diagnostic` reads it rather than spelling a second
1278
+ nine-arm table beside `args()`. What is left there is a two-arm decoration
1279
+ match over the only variants carrying a hint or a location. Refs #1748.
1280
+ - refactor(core)!: **the raw-plate test seam is `#[doc(hidden)]`, not a cargo
1281
+ feature.** `internal-test-seam` gated one method,
1282
+ `LiveSession::update_data`, and the crate's `[features]` table held nothing
1283
+ else; both go, and the method compiles into every build. A cargo feature is
1284
+ public surface itself — crates.io and docs.rs advertise it — so the gate moved
1285
+ the opt-in from a source read to a `Cargo.toml` line rather than removing it,
1286
+ and the callable-vs-not difference it bought is already given away next door:
1287
+ `LiveSession::new` and `SessionHandle` are `#[doc(hidden)] pub` in every
1288
+ build, and a session assembled through them reaches the same unchecked
1289
+ `update`. The typst backend's dev-dependency on core carried the feature and
1290
+ nothing else, so it goes too; `[dependencies]` already names core, which is
1291
+ what its acceptance tests link. Refs #1748.
1292
+ - fix(core)!: **a placed card is reached as a `CardMut`, not a `&mut Card`.**
1293
+ `main_mut` / `card_mut` / `cards_mut` handed out `&mut Card`, so a whole-card
1294
+ assignment wrote past every gate that polices placement:
1295
+ `*doc.card_mut(0).unwrap() = doc.main().clone()` put `$quill` and `$seed` on a
1296
+ composable card that `push_card` refuses, and `*doc.main_mut() =
1297
+ Card::new("note")?` took `$quill` off the root, which `quill_reference`
1298
+ `expect`s present — a release panic on the next bound door. `main_mut` and
1299
+ `card_mut` return `CardMut`, which forwards every `&mut self` verb `Card`
1300
+ carries and `Deref`s for the reads with no `DerefMut`, so a chained call
1301
+ compiles unchanged and the assignment does not compile at all. `cards_mut` is
1302
+ withdrawn: `move_card` / `remove_card` / `insert_card` are the slice ops and
1303
+ `cards` / `card` the reads. No binding surface exposed a `&mut Card`.
1304
+ Closes #1750.
1305
+ - refactor(core): **a quill reference parses its selector token directly.**
1306
+ `QuillReference::from_str` split `name@selector` and then re-prefixed the
1307
+ half it had just split off — `VersionSelector::from_str(&format!("@{}",
1308
+ part))` — so the selector parser carried a `written` flag to tell the typo
1309
+ `memo@` from the absent selector in `memo`, a distinction only the caller
1310
+ ever had in hand. `VersionSelector::from_token` parses the unprefixed token
1311
+ and refuses the empty one, `from_str` is the written spelling over it, and
1312
+ the allocation per parse goes. Parsed values, `Display` output and error
1313
+ strings are unchanged, which is what the untouched selector tests assert.
1314
+ - feat(core)!: **five `quillmark-core` document verbs with no caller are
1315
+ gone.** `Document::to_plate_json` was the schema-free spelling of the
1316
+ crate-internal `to_plate_json_gated` every render already takes, so the plate
1317
+ export leaves the public Rust surface; no binding ever carried it.
1318
+ `Document::card_kinds` had one caller, a test. `impl IntoIterator for
1319
+ &Payload` duplicated `Payload::iter`. `MetaKey::ALL` and
1320
+ `MetaKey::is_root_only` enumerated a two-member set to find `Seed`.
1321
+ `PathStepWire` and the `CommentPathSegment` alias were a second and third
1322
+ name for `PathSegment`. The bytes do not move.
1323
+ - feat(core,content)!: **unread accessors leave `quillmark-core` and
1324
+ `quillmark-content`.** `YamlError::line` / `column` / `hint` had no caller
1325
+ outside one test; a consumer reads the position off `to_diagnostic`.
1326
+ `RenderedRegion::contains` had none outside its own, and `field_at` never
1327
+ shared it — it ranks by `distance` under a tolerance. `print_errors` is gone;
1328
+ its one caller, the CLI, carries the two-line loop. `normalize_document`
1329
+ returns a `Document` rather than a `Result` it never filled. `Delta::apply`,
1330
+ which panicked on a delta built against a longer revision, folds into the
1331
+ checked `try_apply`; `ChangeBundle::from_delta` had two callers, both tests.
1332
+ Two additions come with them: `RenderError::coded_hint`, the coded-plus-hint
1333
+ shape four `backend.rs` refusals built by hand, and `region::nearest_region`,
1334
+ the tolerant search `SessionHandle::field_at` and the Typst backend each
1335
+ carried a copy of.
1336
+ - refactor(core): **`QuillValue` holds its JSON, not a mirror of it.** The
1337
+ value carried a private `Node`/`Kind` tree annotating every node with one
1338
+ `fill` bit, plus a seeded `serde_json::Value` cache of the same data — so
1339
+ `from_json` deep-cloned the whole document to record markers almost none of
1340
+ it carries, and `get` cloned a subtree twice to read one child. No consumer
1341
+ wanted the tree: emit, both wire formats, seeding, compose and conform all
1342
+ ask for a flat path list, which is what `Seeded` already wrote by hand and
1343
+ what the DTO's `nested_fills` already stores. `QuillValue` is that pair — the
1344
+ JSON beside a duplicate-free `Vec<Vec<PathSegment>>` — so the two node
1345
+ walkers collapse to one `json_at`, and `OnceLock` and the hand-written
1346
+ `Clone` / `PartialEq` go with them. The public surface is unchanged;
1347
+ `set_fill_at` still refuses a path that addresses nothing, and records a
1348
+ marker where a walk of the JSON meets it, so `fill_paths` reads in
1349
+ declaration order and the `nested_fills` a stored document carries are
1350
+ byte-for-byte what 0.112 wrote.
1351
+ - refactor(core): **every surface that refuses a non-content richtext value
1352
+ spells one sentence.** `Codec::decode_field` builds the shape-mismatch
1353
+ message and names the shape that arrived (`expected a richtext content object
1354
+ or a markdown string, got a number`); the wire `$body` reader and the
1355
+ richtext write coercion route through it. `Card::store_ext` bounds `$ext`
1356
+ depth through `value::depth_check_meta_map`, the check the wire and the
1357
+ storage DTO run. Every diagnostic code is unchanged.
1358
+ - refactor(core): **`FieldViolation` spells its own message, once.** The parse,
1359
+ wire and storage boundaries each re-spelled the three field-invariant
1360
+ reasons, and the wording had drifted apart. `Display` gives the reason alone
1361
+ and `FieldViolation::message(key)` names the key inline, which all three
1362
+ boundaries wrap. The parse path's dead `FillOnMapping` arm goes with the
1363
+ match it lived in.
1364
+ - refactor(core): **the prescan's frame stack carries no `kind`.** `Frame.kind`
1365
+ and `FrameKind` were written at every push and read nowhere but the write
1366
+ that filled them.
1367
+ - refactor(content): **code-block import filters its text through
1368
+ `Inline::push_text`.** `push_code_line` differed from it only in dropping a
1369
+ `\n` where `push_text` spaces one, and its segments come from a
1370
+ `split('\n')`, so the two agree on every input it receives.
1371
+ - refactor(quillmark): **`Quillmark::render` resolves the backend once.** It
1372
+ called `supported_formats` and `open`, each resolving the quill's backend
1373
+ separately. Same diagnostics in the same order.
1374
+ - refactor(core/quill): `is_valid_quill_name` drops its exemption for the
1375
+ literal `__default__`, a name no quill, fixture, test or doc in the tree
1376
+ carries.
1377
+
1378
+ ### Docs and tests
1379
+
1380
+ - docs(content): **`ISLAND_SLOT` says who removes a stray one.** The constant
1381
+ called a slot with no island an invariant violation and stopped there, which
1382
+ read as the codecs contradicting the model when they drop one on import. They
1383
+ are establishing the invariant, and they take `\r`, the bidi controls and the
1384
+ line separators out of the same input without a word either. Closes #1808.
1385
+ - test(content): **the literal codec's stray-slot drop is stated.**
1386
+ `from_plaintext` filters `ISLAND_SLOT` out of its input and nothing failed if
1387
+ the filter went: `into_normalized` normalizes but does not validate, and the
1388
+ mint does not repair a stray slot, so the codec would have handed out a
1389
+ `Normalized` that `validate` refuses. The markdown door's half of the contract
1390
+ landed with #1812; this is the other one. Closes #1810.
1391
+ - test(core,content,typst,pdf): **each property suite lives beside the seam it
1392
+ states, and `quillmark-fuzz` is gone.** Seven targets over boundaries owned by
1393
+ four crates sat in one `publish = false` member outside `default-members`, so
1394
+ a bare `cargo test` ran none of them, and `quillmark_typst::emit` stayed a
1395
+ `#[doc(hidden)] pub` module for a single out-of-crate import. Each is now a
1396
+ `properties` module beside its owner's unit tests: coercion under
1397
+ `quill/tests/`, the resting form under `quill/conform/tests/`, `Document`'s
1398
+ JSON and markdown doors under `document/tests/`, the content decode lanes in
1399
+ `content/tests/properties.rs`, the escapers inside `emit.rs`'s own test module
1400
+ — where they share the `resolve` oracle they had been carrying a second copy
1401
+ of — and the stamp spine's byte reads in `quillmark-pdf/tests/`. `emit` is
1402
+ private, and the saved regression seeds move to the paths proptest derives
1403
+ from the new sources. Closes #1684.
1404
+ - test(core,typst,content): **the property targets that could not fail are
1405
+ stated so they can.** `fuzz_emit_roundtrip_arbitrary` drew `\PC{0,1000}` and
1406
+ round-tripped what parsed, but `Document::parse` refuses any source without a
1407
+ `$quill` root block: 0 of 2000 draws reached the assertion. The body is now
1408
+ drawn under a spelled root block, where all 3000 do, and the property is that
1409
+ the loop settles — richtext's markdown is a lossy projection, so a first pass
1410
+ may move marks a second pass leaves alone; the shaped generators keep the
1411
+ identity. `fuzz_escape_string_injection_safety` scanned escaped output for
1412
+ three literal substrings its own generator draws once in ~10^15 cases, and
1413
+ would have failed a correct escaper if it ever had: it and its raw-quote twin
1414
+ are one property that parses the literal back with Typst and asserts the
1415
+ authored characters. The `CardWire` sweep gains the spelled envelope that the
1416
+ storage sweep already had, so every case reaches `Card::try_from` and the
1417
+ generator-reach guard it needed is gone. Deleted outright: the wide-payload
1418
+ loop (`spec_conformance_probe` pins the cap, and now the width it admits) and
1419
+ the three `1..20`-deep markdown loops, which approach no boundary —
1420
+ `MAX_NESTING_DEPTH` is 100, and content pins that.
1421
+ - docs(canon): **the stamp spine names both of its consumers.**
1422
+ `ARCHITECTURE.md` described `quillmark-pdf` as leaf infrastructure consumed
1423
+ by `quillmark-pdfform`. The Typst backend consumes it unconditionally — the
1424
+ fields a plate's `form-field` calls place go through the same `stamp` — and
1425
+ nothing under `crates/backends/typst/src/` is feature-gated, so a reader
1426
+ following canon concluded a Typst quill's form fields come from the pdfform
1427
+ backend, and settling #1640 took a read of three manifests. The sentence
1428
+ now names the layer and both consumers, meeting at `&[FieldSpec]` and never
1429
+ each other. Closes #1749.
1430
+ - ci(release): **the version arithmetic and changelog baseline are tested
1431
+ scripts.** `release-prepare.yml`'s `Compute next version` block runs only when
1432
+ a maintainer dispatches it, and one release candidate exists in the
1433
+ repository's history, so a defect in its `-rc.N` branches would surface at the
1434
+ release that needed them. The block moves to `scripts/next-version.sh`, taking
1435
+ the dispatch inputs verbatim so the workflow step holds no conditional of its
1436
+ own, and the changelog baseline's pre-release skip moves to
1437
+ `scripts/last-release-tag.sh`, reading a tag list on stdin so a test feeds it
1438
+ fixtures rather than a repository. `scripts/release-prepare.test.sh` covers
1439
+ twenty cases in ci.yml's lint job beside the canon spine lint: an
1440
+ override taken verbatim and one already carrying `-rc.N` left unsuffixed,
1441
+ iteration counting `rc.9` to `rc.10` rather than concatenating, promotion
1442
+ dropping the suffix at any N while ignoring `bump`, a minor bump zeroing the
1443
+ patch, and a pre-release tag passed over for the final beneath it. Covering it
1444
+ found the defect it was written for: a `bump` outside `patch` and `minor`, and
1445
+ a `CURRENT` no branch can shape — a `0.93.1-beta.1` an earlier free-text
1446
+ override left behind, whose arithmetic fails to stderr without failing the
1447
+ shell — each left the version empty and exited 0, and the workflow
1448
+ interpolated that empty string into `cargo release version`, the release
1449
+ branch name, the tag and the changelog heading. A version the arithmetic
1450
+ cannot compute is a refusal now. A 160-combination sweep of the original block
1451
+ against the script shows no other difference. Closes #1767.
1452
+ - build(wasm): **the package build reads its version without jq.** `jq` was a
1453
+ hard prerequisite of `build-wasm.sh` for one string: the version out of `cargo
1454
+ metadata`. `cargo pkgid` carries the number the crate inherits from
1455
+ `version.workspace`, and stripping past the last `#`, `@` or `:` reads every
1456
+ pkgid spelling cargo has used, since a semver holds none of the three. The
1457
+ guard is a semver match, so an unparseable pkgid fails the build rather than
1458
+ stamping a partial string; `--release-stamp` still stamps verbatim, which is
1459
+ what `release.yml` compares against the tag before publishing. The
1460
+ `pkg/.gitignore` the script wrote goes — the root `.gitignore` already ignores
1461
+ `pkg/`, and `package.template.json`'s `files` allowlist is what decides the
1462
+ published set — and so does the brotli line in the size report, a second
1463
+ compressor for a second number nothing acts on which printed only where it
1464
+ happened to be installed. The core artifact's one remaining gzip pass feeds
1465
+ both the report line and the size budget, which were compressing the same
1466
+ 8.7 MB twice. Closes #1766.
1467
+ - test(core): **`quillmark:blank_title` rides the transform schema, and only
1468
+ it.** The keyword labels the blank that leads an enum's wire-valid domain and
1469
+ had no test anywhere, so a rename or a drop shipped silently. The label and
1470
+ the blank are asserted as one object, the label being meaningless without the
1471
+ blank it names: a field carrying `ui.blank_title` emits both, a field without
1472
+ it emits the domain and no key at all. Whole-object equality, so an
1473
+ unconditional key emitting `""` reads as a failure rather than passing a
1474
+ presence check. The declaration view keeps emitting `values:` verbatim —
1475
+ injecting the blank there would emit `values: ["", …]`, which
1476
+ `quill::enum_blank_member` rejects, so a quill round-tripping through that
1477
+ view would stop loading. Closes #1765.
1478
+ - test(wasm): **the typed write and resolve suites run on the gated surface.**
1479
+ `runtime/runtime.js` patches `writer` and `reader` onto the core build's
1480
+ `Quill`, and `basic.test.js` drives the typst backend build — a different
1481
+ class over different memory, where those verbs do not exist. That is why its
1482
+ typed-commit and resolve suites reached for the `_`-prefixed `_commitField`
1483
+ and `_resolve`: the front door was not there to reach. Both move to
1484
+ `runtime.test.js`, where the gated surface is what a consumer holds, and no
1485
+ underscored call remains in either file. Five assertions `runtime.test.js`
1486
+ already made through `writer.set` and `writer.card(i).set` go. Ten had no
1487
+ public twin and carry over: `edit::field_coercion_failed` and
1488
+ `edit::field_not_inline`, which appeared nowhere else in the suite; the
1489
+ `DocPath` a refused write anchors to; `setAll`'s all-or-nothing abort at both
1490
+ the unknown-name and the coercion rung; `CardWriter.setAll`; and resolve's
1491
+ declaration-order rows, its `default` and `blank` rungs, and `body` as a
1492
+ sibling of `fields`. Six tests filed under the typed-commit title never
1493
+ touched that ABI — they drive `applyChange` and `mapMarks` — and are retitled
1494
+ where they sit. The foreign-module fixture copies the core build into an
1495
+ `mkdtemp` directory and removes it in `afterAll`, where it wrote `pkg/dup-core`
1496
+ and cleared it at the *next* run's start: a unique path per run cannot collide
1497
+ with a concurrent one, and a leak lands in the OS temp directory rather than
1498
+ in a build directory. Closes #1764.
1499
+ - test(quillmark): **the quiver sweeps share one walker and one loaded quiver.**
1500
+ `quillmark_fixtures::quill_names` is the single list of fixture quills,
1501
+ counting a directory when `quills_path` resolves it to a bundle carrying
1502
+ `Quill.yaml` — the versioned layout every fixture uses, which neither of the
1503
+ two bare `is_dir()` walkers knew about. The seed sweep joins the
1504
+ empty-document and blueprint sweeps in `quiver_test.rs` over one `LazyLock`
1505
+ quiver, so each quill loads once for all three rather than once per sweep and
1506
+ the three run in parallel inside one binary: fifteen loads become five, two
1507
+ engines become one, and the sweeps finish in 272 ms where the two binaries
1508
+ took 678 ms. All three documents stay, none subsuming another — the blueprint
1509
+ commits every `default:` and marks every defaultless cell `!must_fill`, the
1510
+ seed commits every `example:` and omits every defaulted field, and only the
1511
+ empty document carries no composable card. Closes #1763.
1512
+ - docs(bindings,migrations): **the parity table records differences; the
1513
+ migration index records steps.** `BINDINGS.md`'s table drops the six rows
1514
+ whose class was `identical`, which now read as one line above it, and three
1515
+ rows stop restating the model the section states 90 lines up. What the table
1516
+ holds is one row per forced difference. The migration index cuts each row to
1517
+ the step's headline break, from 3,729 words of table cells to 594 for the
1518
+ whole page: a chooser, not a fifth copy of each guide beside the commit, the
1519
+ changelog and the guide itself. The one storage-format move across the
1520
+ thirteen steps (0.111 → 0.112) is stated once in the preamble. Closes #1701.
1521
+ - docs: **the twenty pre-0.100 migration guides leave the published site.**
1522
+ `wasm-0.77-to-0.80.md` through `0.99-to-0.100.md` and the `0.92-to-0.102.md`
1523
+ span guide go, with their rows in the Migration overview: 5,519 lines routing
1524
+ a consumer onto `RenderSession`, `` ```card `` fences, `QUILL:` frontmatter,
1525
+ `$id` and `!fill`, none of which any release since 0.100 carries in any form.
1526
+ What keeps a pre-0.100 stored blob loading is the reader in
1527
+ `crates/core/src/document/dto.rs`, specified in
1528
+ `prose/canon/DOCUMENT_STORAGE.md`; no guide was ever load-bearing for it.
1529
+ `mkdocs.yml` states the nav exclusion as `/migrations/*.md`, one line in
1530
+ place of the 33 a release had to extend.
1531
+ - docs(core): **`ERROR.md`'s args table covers the three `validation::seed_*`
1532
+ codes it claimed.** The table is the `code` + `args` consumer contract, and
1533
+ `diagnostic_args_match_canon` holds it to the minted set — but
1534
+ `seed_unknown_kind`, `seed_overlay_shape` and `seed_unknown_field` were
1535
+ missing from both, so the two agreed by omitting the same three. The rows are
1536
+ minted from the overlay walk itself, on a document that trips all three.
1537
+ - docs(core,wasm): **`PREVIEW.md` points at the two surfaces it was copying.**
1538
+ § "The seam" claimed every `SessionHandle` method past `render` and
1539
+ `page_count` defaults to *absent* and that a backend's capabilities are
1540
+ exactly the defaults it overrode. `field_at` breaks both: its default
1541
+ hit-tests whatever `regions()` returns, so overriding `regions` alone buys a
1542
+ working `field_at`. The section says what the seam is and leaves the
1543
+ per-method contract to `crates/core/src/session.rs`. § "TypeScript surface"
1544
+ loses its 62-line transcription of `runtime.d.ts`, which carries more
1545
+ per-member contract than the copy did and is the file `npm run typecheck`
1546
+ checks.
1547
+ - docs(content,core): **the authored lane is `overwrite` and the op wire.**
1548
+ Canon and the 0.112 guide also named `install`, gone since 0.102, and
1549
+ `CardInput.body`, which has never rejected anything the storage lane takes.
1550
+ The card wire decodes storage-lane (`wire::body_from_wire` through
1551
+ `Codec::decode_field`) and owes that: every `Card` a read hands back is typed
1552
+ a valid `CardInput`, and a read emits a stored-only tolerance verbatim.
1553
+ `card_wire_body_decodes_storage_lane` pins the door's lane, which had drifted
1554
+ across canon, a released guide and four code comments with nothing catching
1555
+ it.
1556
+ - fix(docs): **the 57 comments and doc claims that contradicted the code now
1557
+ state it.** The user-visible ones: the Typst backend never searched system
1558
+ fonts, so its docs stop promising `#set text(font: "Arial")` and name what a
1559
+ world loads (`assets/fonts/*`, `packages/**`, the embedded Figtree fallback);
1560
+ the quickstart teaches `quill.parse`, the bound door, rather than the
1561
+ transport door; `@quillmark/wasm` has no `/runtime` subpath, and the seven
1562
+ places that named one now spell the package's sole export.
1563
+ - docs(wasm,typst,core): **the live session's edit verb is `update` in prose
1564
+ too.** The `@quillmark/wasm` README and the `LiveSession` rustdoc named an
1565
+ `apply(doc)` the class does not carry, so a consumer following them reached
1566
+ `session.apply is not a function`. The README also called `pageCount` and
1567
+ `pageSize(page)` stable for the session's lifetime, which a committed
1568
+ `update` invalidates: they read the current compile, the new count is
1569
+ `ChangeSet.pageCount`, and every page in `ChangeSet.dirtyPages` needs its
1570
+ `pageSize` re-read.
1571
+ - docs: **two copies that were copies, not two ends of a subject.**
1572
+ `prose/README.md` divides canon and `docs/` by audience and says neither
1573
+ restates the other. § "Addressing cards for re-render" was in both, code
1574
+ block included; canon keeps the rule and points at the page that carries the
1575
+ recipe. The text-type 2×2 table was in `creating-quills.md` and
1576
+ `quill-yaml-reference.md`, with the tutorial already linking at the
1577
+ reference; the tutorial keeps the two questions that pick a type.
1578
+ - docs(canon): **the crate compatibility position, and the third reading of an
1579
+ unknown content name.** `ARCHITECTURE.md` § "Crate Structure" carries what the
1580
+ withdrawn `COMPATIBILITY.md` left unstated: seven crates publish to crates.io
1581
+ and promise no API stability before 1.0. Cargo reads a `0.x` minor bump as a
1582
+ major and the workspace bumps nearly every release, so there is no compatible
1583
+ release for `#[non_exhaustive]`, a sealed `Backend` or a `semver-checks` gate
1584
+ to hold — all three are the 1.0 tag's decisions, and a removal, a rename or a
1585
+ dependency's type in a public signature escapes all three regardless.
1586
+ `ERROR.md`'s YAML boundary rests on that promise. `DOCUMENT_STORAGE.md`
1587
+ § "Content vocabularies" weighed projecting an unknown name and dropping it,
1588
+ and now weighs carrying it opaquely while refusing only to project it: that
1589
+ holds the bytes and costs totality.
1590
+ - test(fixtures): **`classic_resume` and `cmu_letter` leave the fixture tree.**
1591
+ 2.7 MB of the 4.3 MB was their fonts. No test named `cmu_letter`;
1592
+ `classic_resume` was named by one three-line gate that also runs on `taro`
1593
+ and `usaf_memo`, and by the usage line of `print_blueprint`. What they
1594
+ exercised beyond the other five is covered without them: `body.enabled:
1595
+ false` by `sample_form` and `richtext_form`, a templated `ui.title` by
1596
+ `core/src/quill/tests.rs`, an object-typed schema by `address_grammar.rs` and
1597
+ `default_values_test.rs`. The `quillmark-fixtures` crate is `publish =
1598
+ false`, so nothing outside the workspace read them.
1599
+ - test(wasm): **the `run_in_browser` test file goes; what it looked like it
1600
+ covered is covered by suites that run.** `tests/wasm_bindings.rs` held nine
1601
+ `#[wasm_bindgen_test]` cases and no runner — nothing under `.github/`,
1602
+ `scripts/` or `.cargo/` names wasm-pack or a browser harness, and `cargo test
1603
+ --workspace` compiles the file and runs zero tests out of it. Every assertion
1604
+ it made is made by a suite that does run. One was uncovered — `seedCard` on
1605
+ an undeclared kind answering `undefined` rather than throwing — and
1606
+ `core.test.js` now asserts it. `wasm-bindgen-test` leaves the
1607
+ dev-dependencies with the file.
1608
+ - fix(fuzz): **the wide-payload property requires the parse to succeed and to
1609
+ keep every field.** `fuzz_decompose_large_payload` swallowed a parse `Err`
1610
+ and asserted `payload().len() <= size`, a bound `Payload::len` cannot exceed,
1611
+ so a parse that refused the input or dropped every field passed. It expects
1612
+ the parse and pins `len() == size`.
1613
+
1614
+
3
1615
  ## v0.112.0 - 2026-09-01
4
1616
 
5
1617
  - feat(content)!: **every vocabulary member spells its payload in `attrs`.** The