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