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