@quillmark/wasm 0.110.0 → 0.112.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 +322 -0
- package/README.md +2 -2
- package/backends/pdfform/wasm.d.ts +75 -23
- package/backends/pdfform/wasm.js +60 -7
- package/backends/pdfform/wasm_bg.wasm +0 -0
- package/backends/pdfform/wasm_bg.wasm.d.ts +3 -2
- package/backends/typst/wasm.d.ts +75 -23
- package/backends/typst/wasm.js +60 -7
- package/backends/typst/wasm_bg.wasm +0 -0
- package/backends/typst/wasm_bg.wasm.d.ts +3 -2
- package/core/wasm.d.ts +54 -12
- package/core/wasm.js +41 -0
- package/core/wasm_bg.wasm +0 -0
- package/core/wasm_bg.wasm.d.ts +1 -0
- package/package.json +1 -1
- package/runtime/runtime.d.ts +28 -16
- package/runtime/runtime.js +27 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,327 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.112.0 - 2026-09-01
|
|
4
|
+
|
|
5
|
+
- feat(content)!: **every vocabulary member spells its payload in `attrs`.** The
|
|
6
|
+
canonical content had two spellings for one thing: a built-in put its payload
|
|
7
|
+
in named siblings (`{"kind":"heading","level":1}`), an unknown put it in one
|
|
8
|
+
opaque bag, and which a document used depended on whether the build that wrote
|
|
9
|
+
it happened to know the name. Promoting a name from unknown to built-in was
|
|
10
|
+
therefore an encoding change, and five mechanisms existed to bridge the split.
|
|
11
|
+
Now `Heading{level}` ⇄ `attrs.level`, `Code{lang}` ⇄ `attrs.lang`, `Link{url}`
|
|
12
|
+
⇄ `attrs.url`, `Anchor{id}` ⇄ `attrs.id`, and `ListItem`'s
|
|
13
|
+
`ordered`/`start`/`ordinal` ⇄ the same three under `attrs`, with the envelope
|
|
14
|
+
keys (`kind`/`type`/`container`, `containers`, `continues`, `start`/`end`,
|
|
15
|
+
`instance`) staying siblings and an empty bag omitted. The Rust types are
|
|
16
|
+
unchanged — typed in memory, uniform on the wire. `fold_legacy_attrs`, the
|
|
17
|
+
`reject_*_attrs` family, `MarkKind::ord` and its placement rule, and
|
|
18
|
+
`RESERVED_*`'s wire role are all deleted; the canonical mark tie-break becomes
|
|
19
|
+
`MarkKind::sort_key`, the `(type, attrs)` pair the wire carries, which two
|
|
20
|
+
builds compute identically whether or not either knows the member. **Stored
|
|
21
|
+
documents need no migration**: the decoder reads the old spelling wherever the
|
|
22
|
+
`attrs` bag is absent. That is the load-bearing half — a `richtext` field
|
|
23
|
+
rests as a content object inside an opaque payload value carrying no schema
|
|
24
|
+
tag, so most stored content reaches no migration and a walk cannot safely find
|
|
25
|
+
it (`$ext` is arbitrary host data by contract). The fallback is frozen: unlike
|
|
26
|
+
the fold, a promotion neither grows it nor inherits it. The **break is at the
|
|
27
|
+
seam**: a host reading `line.level` / `line.lang` / `mark.url` / `mark.id` or a
|
|
28
|
+
list item's shape reads them under `attrs` (the TS narrowing types move too, so
|
|
29
|
+
`typecheck` reports it), and the authored lane — `overwrite`, `install`,
|
|
30
|
+
`CardInput.body`, and the op wire — *rejects* the old spelling rather than
|
|
31
|
+
reading it, so a stale write throws instead of landing somewhere it did not
|
|
32
|
+
aim. A foreign bag beside a built-in becomes legal in exchange, dropping unread
|
|
33
|
+
as it always did in effect. Canonical bytes move (`attrs` on built-ins, and
|
|
34
|
+
coincident marks reorder on the new tie-break), so **content hashes recompute
|
|
35
|
+
once**; generated Typst nests coincident wraps the other way for identical
|
|
36
|
+
glyphs, and `exportMarkdown` nests them the same way (`**~~x~~**` becomes
|
|
37
|
+
`~~**x**~~`, parsing back to the same content), so goldens and any hash over
|
|
38
|
+
either projection move too. A consumer holding bare seam JSON outside a
|
|
39
|
+
stored document gets a hard break, having no tag to dispatch on. The storage
|
|
40
|
+
tag becomes `quillmark/document@0.112.0`; `@0.93.0` rows migrate forward on
|
|
41
|
+
read. See [0.111 → 0.112](docs/migrations/0.111-to-0.112.md).
|
|
42
|
+
- feat(core)!: **`fieldAt` and `positionAt` take a pointer tolerance, and
|
|
43
|
+
resolve to the nearest ink rather than the first containing it.** A glyph's
|
|
44
|
+
box is its run's ink height by its own advance, so a text column answers over
|
|
45
|
+
a fraction of the area it occupies: the boxes of one line abut, but the
|
|
46
|
+
leading between two lines is inside the paragraph and on no glyph. An 11pt
|
|
47
|
+
paragraph is live over about two thirds of its own height at default leading
|
|
48
|
+
and under half of it double-spaced, which is the whole of why clicking the
|
|
49
|
+
preview to place a caret misses. Both queries now take `tol` in PDF points
|
|
50
|
+
(`tolPt`, optional, on the WASM seam) and answer with the nearest placement
|
|
51
|
+
within it, a radius in both axes. The caller derives it from the scale it drew
|
|
52
|
+
the page at, slack being a property of the pointer rather than of the
|
|
53
|
+
document: a tolerance fixed in points shrinks under the cursor exactly as the
|
|
54
|
+
target does. Ranking by distance rather than growing each rect is what keeps
|
|
55
|
+
the answer the nearer item's — outset boxes overlap, and a first match over
|
|
56
|
+
them decides by paint order — and makes the tolerance a pure widening:
|
|
57
|
+
containment is distance zero, so no point that resolves exactly changes answer
|
|
58
|
+
however high `tol` goes, and later-painted still wins a tie.
|
|
59
|
+
`RenderedRegion::distance` is the measure, and `contains` is now stated in
|
|
60
|
+
terms of it. The break is the added argument on
|
|
61
|
+
`SessionHandle::{field_at,position_at}` and their `LiveSession` forwarders,
|
|
62
|
+
which a type checker reports; `0.0` is the previous behaviour exactly. The
|
|
63
|
+
WASM argument is optional and defaults to `0`, so no JS caller changes.
|
|
64
|
+
- fix(content): **markdown export escapes what markdown strips at a line's
|
|
65
|
+
edges.** A line's leading and trailing space/tab crossed verbatim, so
|
|
66
|
+
`from_markdown(to_markdown(rt))` dropped it — and where the leading run
|
|
67
|
+
opened an indented code block, or hid a `- ` / `# ` / `> ` / `N. ` marker
|
|
68
|
+
from the position-0 escapes, the re-import rewrote the line's kind and
|
|
69
|
+
containers with it: `" foo"` returned as `"foo"` under `Code`, and
|
|
70
|
+
`" - item"` as `"item"` inside a `ListItem` nobody wrote. Neither takes
|
|
71
|
+
adversarial input, since `apply_text_delta` and `from_plaintext` both mint
|
|
72
|
+
edge whitespace: a `Codec::Plaintext` field holding an indented sample was
|
|
73
|
+
corrupted by `Document::to_markdown`. The verify-and-drop net could not catch
|
|
74
|
+
it, being gated on flanking marks — a mark-free line never reaches it, and
|
|
75
|
+
the `,…,` probe wrapping the lines that do blocks every leading-block
|
|
76
|
+
construct by design, so it reads such a line as safe while the `**` ships
|
|
77
|
+
into the text. Space and tab at an edge now cross as character references
|
|
78
|
+
(` ` / `	`), markdown having no backslash escape for whitespace. A
|
|
79
|
+
literal `&` still escapes, so text authored as ` ` returns as itself. An
|
|
80
|
+
image `alt` is the one edge run a reference cannot carry — the parser trims
|
|
81
|
+
alt after decoding it — and joins the module's documented codec limits.
|
|
82
|
+
Emitted markdown moves for content holding an edge run.
|
|
83
|
+
- fix(content): **a `=` run on a continuation line stays text.** It underlined
|
|
84
|
+
the paragraph line above it into a setext heading, taking the hard break's
|
|
85
|
+
`\` into the text with it: `"abc\n==="` returned as an H1 reading `"abc\"`.
|
|
86
|
+
`\=` joins the block starters escaped at position 0.
|
|
87
|
+
- fix(content): **an unknown island's type cannot leave its placeholder.**
|
|
88
|
+
`emit_island` wrote `island_type` — an open wire string no lane constrains —
|
|
89
|
+
raw into `<!-- island:… -->`, where a `-->` closes the comment early and a
|
|
90
|
+
line break ends the HTML block, either way leaking the rest as content text.
|
|
91
|
+
At column zero that opens whatever the rest spells, a `~~~` card fence
|
|
92
|
+
included, which the document layer reads as another card. The type crosses
|
|
93
|
+
with `<` / `>` as entities and control characters as spaces; nothing reads it
|
|
94
|
+
back out of markdown.
|
|
95
|
+
- fix(content): **an inline unknown island keeps its line whole.**
|
|
96
|
+
`fix_html_comment_fences` inserted a newline after any `-->` carrying text
|
|
97
|
+
behind it, but only a comment opening a line — at most three spaces of indent
|
|
98
|
+
— starts an HTML block. One reached mid-line is inline HTML and swallows
|
|
99
|
+
nothing, so an inline island turned `ab` into `"a b"`. The repair fires for
|
|
100
|
+
the block-opening case alone.
|
|
101
|
+
- fix(core): **the payload ingresses refuse what no parse can produce.** The
|
|
102
|
+
wire `TryFrom`, the storage DTO and `push_card` / `insert_card` each admitted
|
|
103
|
+
a payload whose markdown does not read back: a duplicate field key, more
|
|
104
|
+
fields than `MAX_FIELD_COUNT`, a repeated `$` entry, a comment whose text
|
|
105
|
+
spans lines, and a composable card carrying `$quill` or `$seed`. The comment
|
|
106
|
+
is the one that fails quietly — `#` opens a single line, so a text of
|
|
107
|
+
`"hi\ninjected: pwned"` emits bare YAML after the first line and re-reads as
|
|
108
|
+
a field nobody wrote. The rest emit markdown the parser rejects, and a
|
|
109
|
+
composable `$quill` also trips the `debug_assert` in `from_main_and_cards`,
|
|
110
|
+
which the rebuild behind `compile_data` runs: a render panicked on a debug
|
|
111
|
+
build where it owed an error. `PayloadViolation` carries the verdict —
|
|
112
|
+
`FieldViolation`'s seam one level up, reading the item list rather than one
|
|
113
|
+
item — which the wire maps to `WireError::InvalidPayload` and the DTO to
|
|
114
|
+
`StorageError::Malformed`, so both boundaries share one message. The
|
|
115
|
+
`$quill` / `$seed` half is positional, since a `CardWire` is equally how the
|
|
116
|
+
main card is read back, so it sits at placement beside the `$kind` gate as
|
|
117
|
+
`EditError::RootOnlyEntry` (`edit::root_only_entry`). The DTO refuses a root
|
|
118
|
+
`$kind` other than `main` and synthesises an absent one, as the parser does.
|
|
119
|
+
Additive: `validate_payload`, `PayloadViolation`, `MetaKey::ALL`. Both error
|
|
120
|
+
enums are `#[non_exhaustive]`, so no compiling caller changes, and no
|
|
121
|
+
document the format calls readable is refused.
|
|
122
|
+
- fix(cli): **`render -f svg` / `-f png` writes every page.** The Typst backend
|
|
123
|
+
emits one artifact per page and the command wrote `artifacts.first()`,
|
|
124
|
+
discarding the rest with no warning, so a multi-page document produced a
|
|
125
|
+
single-page file that looked complete. A multi-page render now writes one
|
|
126
|
+
numbered file per page: `out.svg` becomes `out-1.svg`, `out-2.svg`, …. Page
|
|
127
|
+
one is numbered too, so no unnumbered file claims to be the whole document.
|
|
128
|
+
`--stdout` carries one artifact and refuses a multi-page render.
|
|
129
|
+
- fix(core): **a `!must_fill` marker on a mapping is refused at every ingress,
|
|
130
|
+
not just at parse.** `Card::store_fill`, the `CardWire` boundary and the
|
|
131
|
+
`@0.92.0` storage DTO took `fill: true` against an object and emitted
|
|
132
|
+
`x: a: 1`, which `Document::parse` then refuses — breaking the emit round
|
|
133
|
+
trip. The rule the parser enforces now sits beside the other field invariants
|
|
134
|
+
as `edit::validate_fill_targets`, raising `edit::fill_on_mapping`. A canonical
|
|
135
|
+
content object stays legal: emit projects it to its markdown scalar first,
|
|
136
|
+
which is the shape `!must_fill` emits against.
|
|
137
|
+
- fix(core): **a comment after a nested key that needs quoting keeps its
|
|
138
|
+
position.** A comment's position is its index among its mapping's children,
|
|
139
|
+
and the prescan matched only a bare `[A-Za-z_][A-Za-z0-9_]*` nested key. So
|
|
140
|
+
`"a b": 1`, which the emitter writes itself, was not counted, and every comment
|
|
141
|
+
after it in that mapping round-tripped one slot early. The prescan reads a
|
|
142
|
+
nested key in both spellings the emitter writes at depth: quoted, and plain
|
|
143
|
+
with characters the bare form excludes.
|
|
144
|
+
- fix(pdfform): **an array-bound text widget is multiline.** `resolve::coerce_text`
|
|
145
|
+
joins an array's elements with newlines unconditionally while the widget took
|
|
146
|
+
`multiline` from `ui`, defaulting to false: a viewer collapsed the `/V` at its
|
|
147
|
+
first line while the flattened SVG/PNG stacked every line, so the interactive
|
|
148
|
+
PDF and the raster disagreed.
|
|
149
|
+
- fix(core): **a version segment is plain digits, and a `$quill` `@` carries a
|
|
150
|
+
selector.** `u32::from_str` accepts a leading `+`, so `memo@+2.+1` parsed as
|
|
151
|
+
`Minor(2, 1)` and `version: "+1.0"` loaded, though `quill_ref_hint` promises
|
|
152
|
+
digits and neither spelling re-`Display`s to itself; and `memo@` yielded an
|
|
153
|
+
empty selector silently read as `latest`. An absent selector still means
|
|
154
|
+
latest.
|
|
155
|
+
- fix(core): **an unquoted numeric `version:` keeps its fraction.** The loader
|
|
156
|
+
accepts a YAML number by intent but converted it through `f64::to_string`,
|
|
157
|
+
which drops the fraction: `version: 1.0` became `"1"` and failed validation
|
|
158
|
+
with a hint naming the `'1.0'` the author had written. An unquoted `1.10` is
|
|
159
|
+
the YAML number `1.1` before the loader sees it, so VERSIONING.md's quote-it
|
|
160
|
+
rule now names that rather than the `x.0` case.
|
|
161
|
+
- fix(python): **a value with no JSON form raises instead of storing its
|
|
162
|
+
`repr`.** `py_to_json_at` fell through to `str()`, so a tuple stored
|
|
163
|
+
`"('a', 'b')"`, a `bytes` stored `"b'...'"` and a `set` stored `"{'x'}"` —
|
|
164
|
+
silently, surfacing as garbage at render or read-back. `datetime.date`,
|
|
165
|
+
`datetime.datetime` and `datetime.time` keep the stringified form the
|
|
166
|
+
fallback existed for; everything else is a `ValueError`, which is what the
|
|
167
|
+
WASM lane already refuses.
|
|
168
|
+
- fix(python): **the type stub declares `Diagnostic.args`**, the localization
|
|
169
|
+
channel the class exposes. Under a type checker `diag.args` was an attribute
|
|
170
|
+
error on a `@final` class.
|
|
171
|
+
- docs(python): **the error contract names both classes the binding raises.**
|
|
172
|
+
`errors.rs` claimed every raised exception is `QuillmarkError` carrying
|
|
173
|
+
diagnostics, while an argument the binding cannot convert raises `ValueError`
|
|
174
|
+
— the behavior the binding's own tests pin. Engine refusals carry diagnostics;
|
|
175
|
+
an unconvertible argument raises `ValueError` before the engine is called.
|
|
176
|
+
Stated in the module doc and in the error-handling guide.
|
|
177
|
+
- fix(wasm): **`new Document(ref)` refuses an invalid reference with the code
|
|
178
|
+
and hint `setQuillRef` attaches.** The same input was classified two ways by
|
|
179
|
+
one binding: `parse::invalid_quill_reference` plus the canonical grammar hint,
|
|
180
|
+
or a bare message. Both doors mint it through one helper now.
|
|
181
|
+
- fix(typst): **a literal `;` after emitted inline markup survives to the
|
|
182
|
+
page.** Typst's markup parser reads a semicolon directly after an embedded
|
|
183
|
+
code expression as that expression's terminator and renders nothing, so
|
|
184
|
+
``Use `--force`; otherwise`` lowered to `#raw("--force"); otherwise` and
|
|
185
|
+
dropped the character. `continues_expr` guarded `(` and `.ident` for the same
|
|
186
|
+
reason; it guards `;` now, and the emitter writes the same `\` before it.
|
|
187
|
+
- fix(typst): **document text reaches the page as the characters it holds, not
|
|
188
|
+
Typst's substitutions for them.** `escape_markup` escaped `~`, whose lexer
|
|
189
|
+
shorthand is a non-breaking space, and left the rest of that class active:
|
|
190
|
+
`pages 3--5` rendered an en dash, `wait...` an ellipsis, `-5` a minus sign,
|
|
191
|
+
and `-?` an invisible soft hyphen, taking both authored characters off the
|
|
192
|
+
page. A mark decided it too, since Typst reads the text behind one as a fresh
|
|
193
|
+
token: `x-5` stayed literal but `**x**-5` lowered to `#strong[x]-5`, a minus
|
|
194
|
+
sign the content never held. Each shorthand's head is escaped now, which is
|
|
195
|
+
enough — what one leaves behind is too short to re-form it. **Smart quotes
|
|
196
|
+
stay.** `'` and `"` are an element with a set rule, not a lexer shorthand, so
|
|
197
|
+
a quill picks its own typography with `#set smartquote(enabled: false)`; the
|
|
198
|
+
emitter escaping them would settle that for every quill with no way back.
|
|
199
|
+
Documents holding a dash pair, an ellipsis or a signed number render
|
|
200
|
+
differently.
|
|
201
|
+
- fix(typst): **an island that renders as nothing no longer joins the text
|
|
202
|
+
either side of it.** An island type this build does not know, and an empty
|
|
203
|
+
table island, emit no markup, so the two text runs their slot separated abut
|
|
204
|
+
in the output — where the escapers, which run per text run, see one side of
|
|
205
|
+
the join at a time. `a/`, such an island, `/b` wrote `a//b`: a Typst comment
|
|
206
|
+
that swallowed the rest of the paragraph. The emitter guards that seam with
|
|
207
|
+
the same `\` it writes at a line anchor and an expression tail.
|
|
208
|
+
- fix(typst): **the caret one past a field's last character resolves to the last
|
|
209
|
+
glyph, not the paragraph's first.** `Scan::locate` admits the end position but
|
|
210
|
+
`forward_pos` matched runs half-open, so the most common caret position while
|
|
211
|
+
typing fell through to the segment's generated start. A position no run
|
|
212
|
+
contains now resolves against the nearest preceding run.
|
|
213
|
+
- fix(typst): **a vendored package whose manifest declares a non-semver
|
|
214
|
+
`version` or an unusable `entrypoint` path warns instead of failing the
|
|
215
|
+
session.** Both aborted `QuillWorld::new` even when the plate never imports
|
|
216
|
+
that package, where an unparseable manifest, an unusable asset path, and a
|
|
217
|
+
missing entrypoint already warn and carry on. A non-semver `version` skips the
|
|
218
|
+
package; an unusable `entrypoint` path keeps the files already loaded and
|
|
219
|
+
skips only the check that the entrypoint is among them.
|
|
220
|
+
- fix(wasm): **`RenderResult` crosses a diagnostic's `args` as the
|
|
221
|
+
`Record<string, unknown>` it declares.** It returns through tsify's ABI, whose
|
|
222
|
+
default serializer emits a `Map` for a map-typed field, where every
|
|
223
|
+
hand-serialized diagnostic path passes `serialize_maps_as_objects(true)`.
|
|
224
|
+
`RenderResult.warnings` puts the document's parse warnings ahead of the
|
|
225
|
+
render's own, and a `plate::unsupported_construct` parse warning carries
|
|
226
|
+
`args`: a quill declining a construct its body still holds renders
|
|
227
|
+
successfully, and `args.construct` read back `undefined`. `RenderResult` and
|
|
228
|
+
`Diagnostic` declare `hashmap_as_object`, and a `const` assertion holds each
|
|
229
|
+
to it.
|
|
230
|
+
- fix(content): **a table island whose `aligns` or a row is not an array
|
|
231
|
+
normalizes to one the store can reload.** `normalize_table_props` repaired a
|
|
232
|
+
non-array `header` but fell through for the other two, while `table_shape_error`
|
|
233
|
+
read them as width 0 — so an island op carrying `{"aligns": "bogus"}` was
|
|
234
|
+
accepted and serialized, and the exact bytes then failed to reopen with
|
|
235
|
+
`TableAlignsMismatch`. Normalizing a table now makes its shape check pass, as
|
|
236
|
+
`KnownIslandType::normalize_props` promises.
|
|
237
|
+
- fix(content): **a code block's `lang` is reduced to an identifier on the
|
|
238
|
+
storage lane and refused on the authored one.** `sanitize_lang` ran on import
|
|
239
|
+
only, so the storage decode and the `setKind` op wire took any string and
|
|
240
|
+
`emit_code` wrote it onto the fence header verbatim: a `lang` of
|
|
241
|
+
`"rust\ninjected line"` exported a content line the document never had, and a
|
|
242
|
+
backtick made the fence illegal. The storage decode sanitizes, so a blob
|
|
243
|
+
written that way still opens; the authored wire raises a shape error, since
|
|
244
|
+
the host is writing now and the repair would be silent.
|
|
245
|
+
- fix(core): **a variant-bearing enum refuses a non-string `default:` at load,
|
|
246
|
+
as a plain enum already does.** The scalar branch applied the render floor's
|
|
247
|
+
leniency to a schema literal, so `default: 1` against `values: ["1"]` loaded
|
|
248
|
+
clean and then selected nothing: `selected_member`, `resolve_variant_sourced`
|
|
249
|
+
and the absent-discriminant fallback all read the default through `as_str`, so
|
|
250
|
+
the field compiled to the blank world instead.
|
|
251
|
+
- fix(core): **a seeded variant container commits at its resting form.**
|
|
252
|
+
`seed_parts` runs every other field through the strict write, but
|
|
253
|
+
`seed_variant` pushed its assembled container straight through, so a `$seed`
|
|
254
|
+
overlay for a richtext cell rested as raw markdown and the next `conform`
|
|
255
|
+
rewrote bytes on a document nobody edited — the divergence the shared write
|
|
256
|
+
exists to prevent.
|
|
257
|
+
|
|
258
|
+
## v0.111.0 - 2026-08-30
|
|
259
|
+
|
|
260
|
+
- feat(wasm): **`mapMarks(content, bundle)` answers where a `ChangeBundle`'s
|
|
261
|
+
text-moving channels leave a field's marks**, the coordinates its `markOps`
|
|
262
|
+
are written in. Each of `delta`, `islandOps` and `lineOps` rebases the marks
|
|
263
|
+
already in the field — a range's `start` takes assoc `after` and its `end`
|
|
264
|
+
`before`, a zero-width mark takes `before` — and that rule reached the
|
|
265
|
+
boundary only as a comment on a private method, so an editor deciding which
|
|
266
|
+
`markOps` to emit had to reimplement it, and one that read the range rule as
|
|
267
|
+
the whole rule drifted an anchor a character on text typed at the anchor's own
|
|
268
|
+
position. `Content::map_marks` and `Content::apply_field_change` walk one
|
|
269
|
+
channel list, so the prediction and the store cannot answer a position
|
|
270
|
+
differently, and the answer is normalized as the store's is, so a bundle
|
|
271
|
+
carrying no `markOps` names the marks the field will hold. The rule is stated
|
|
272
|
+
on `ChangeBundle` and in `BINDINGS.md`.
|
|
273
|
+
|
|
274
|
+
- fix(core): **a `plaintext` field declared `inline: true` keeps the flag on the
|
|
275
|
+
declaration wire.** `FieldSchema::serialize` projected the flag back out of
|
|
276
|
+
the type enum with a `RichText { inline: true }` match only, so
|
|
277
|
+
`type: plaintext, inline: true` serialized as `{"type":"plaintext"}` — WASM
|
|
278
|
+
`quill.schema()`, the Python binding, and the CLI `schema` command all lost
|
|
279
|
+
the single-line constraint, and a serde round-trip degraded the field to
|
|
280
|
+
`inline: false`.
|
|
281
|
+
- fix(core): **a comment on a CRLF line no longer carries its `\r` into the
|
|
282
|
+
emitted document.** The prescan splits on `\n`, so a trailing- or own-line
|
|
283
|
+
comment slice ran to end-of-line including the `\r`; it rode through the DTO
|
|
284
|
+
and wire and was written back verbatim, against `to_markdown`'s "line endings:
|
|
285
|
+
`\n` only". Field values were never affected. A `\r` *inside* a comment still
|
|
286
|
+
reaches emit.
|
|
287
|
+
- fix(facade): **loading a path that names no directory says so.** The walk
|
|
288
|
+
answered a missing root with an empty tree, so `quill_from_path("/typo")`
|
|
289
|
+
failed later with `Quill.yaml not found in file tree`, pointing at the
|
|
290
|
+
bundle's contents instead of the path. Python's `Quill.from_path` surfaced
|
|
291
|
+
that directly; the CLI's pre-checks are gone but for `validate`'s
|
|
292
|
+
missing-`Quill.yaml` one, gated on the directory existing so it names the
|
|
293
|
+
bundle a real directory lacks without shadowing the loader's answer for a
|
|
294
|
+
typo. `validate`'s load-failure summary names no file, that branch covering a
|
|
295
|
+
missing directory too.
|
|
296
|
+
- fix(content): **`LineOp::SetKind` refuses a heading level outside `1..=6`.**
|
|
297
|
+
The arm checked kind/text agreement but not the level, so a Rust caller could
|
|
298
|
+
apply `Heading { level: 9 }` and leave a content whose `validate()` fails and
|
|
299
|
+
whose export emits `#########` — read back as a literal-hash paragraph on the
|
|
300
|
+
next import. The JSON wires already range-checked it. New
|
|
301
|
+
`ApplyError::BadHeadingLevel`.
|
|
302
|
+
- fix(typst): **diagnostic columns count characters, not bytes.** Any multi-byte
|
|
303
|
+
character earlier on the source line inflated the reported column, which an
|
|
304
|
+
editor reads as a jump target.
|
|
305
|
+
- fix(pdf): **a non-finite widget `/Rect` is refused (`pdf::bad_rect`) rather
|
|
306
|
+
than written.** `form.json` rect values deserialize as plain `f32` and
|
|
307
|
+
saturate to `inf`, and `flip_rect` arithmetic can reach `NaN`; pdf-writer
|
|
308
|
+
prints a non-finite float verbatim, so `stamp` returned `Ok` with `inf`/`NaN`
|
|
309
|
+
tokens in the output — no PDF number grammar admits them. `flatten` guards the
|
|
310
|
+
geometry it draws. Matches the posture `font_size` already took. `regions_of`
|
|
311
|
+
is still unguarded.
|
|
312
|
+
- fix(pdf): **a base PDF that already carries an `/AcroForm` is refused
|
|
313
|
+
(`pdf::existing_acroform`).** The catalog rewrite appended a second
|
|
314
|
+
`/AcroForm` key without looking, leaving a dict the spec does not define and
|
|
315
|
+
the old form's widgets live in the preserved page `/Annots`. Stripping was
|
|
316
|
+
already the documented authoring rule; it is now checked.
|
|
317
|
+
- fix(pdf): **`fonts_used` registers only the faces a `/DA` names.** Only `Text`
|
|
318
|
+
and `Choice` widgets write one, so a checkbox or signature spec carrying
|
|
319
|
+
`Times`/`Courier` emitted an unreferenced Type1 object and a dead `/DR /Font`
|
|
320
|
+
entry into every stamped PDF.
|
|
321
|
+
- refactor(core): the unreachable null arm in the `Date`/`DateTime` coercion is
|
|
322
|
+
deleted (`conform_value` returns on any null before the type match), and
|
|
323
|
+
`Version` derives the ordering its field order already spells.
|
|
324
|
+
|
|
3
325
|
## v0.110.0 - 2026-08-26
|
|
4
326
|
|
|
5
327
|
- **breaking** wasm: **the seam spells a container's `instance`, so the read
|
package/README.md
CHANGED
|
@@ -173,9 +173,9 @@ discriminates the two formats without exceptions as control flow:
|
|
|
173
173
|
const doc = Document.tryFromJson(content) ?? Document.fromMarkdown(content);
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
-
The `schema` value (`quillmark/document@0.
|
|
176
|
+
The `schema` value (`quillmark/document@0.112.0`) is the **model version**,
|
|
177
177
|
not the running crate version. It is a hand-set constant, bumped only when
|
|
178
|
-
the `Document` model itself changes, so every `0.
|
|
178
|
+
the `Document` model itself changes, so every `0.112.x` patch release reads
|
|
179
179
|
and writes that same value.
|
|
180
180
|
|
|
181
181
|
- **Upgrading is safe.** A newer build always reads documents written by an
|
|
@@ -74,8 +74,9 @@ export interface Content {
|
|
|
74
74
|
|
|
75
75
|
/** One `\n`-separated segment of `Content.text`, in order. `kind` is an open set:
|
|
76
76
|
* an unknown role round-trips with opaque `attrs` and renders as a paragraph.
|
|
77
|
-
*
|
|
78
|
-
*
|
|
77
|
+
* Every role spells its payload in `attrs`, known or not, so promoting one moves
|
|
78
|
+
* no bytes. The open arm blocks discriminant narrowing, so read
|
|
79
|
+
* `attrs.level`/`attrs.lang` behind a check of the arm you want. */
|
|
79
80
|
export type ContentLine = {
|
|
80
81
|
containers: ContentContainer[];
|
|
81
82
|
/** A within-block hard line break rather than a new block. Omitted (false) in the common case. */
|
|
@@ -85,11 +86,11 @@ export type ContentLine = {
|
|
|
85
86
|
/** A line's block role, shared by `ContentLine` and the `setKind` op. */
|
|
86
87
|
export type ContentLineKind =
|
|
87
88
|
| { kind: "para" }
|
|
88
|
-
| { kind: "heading"; level: number }
|
|
89
|
-
| { kind: "code"; lang?: string }
|
|
89
|
+
| { kind: "heading"; attrs: { level: number } }
|
|
90
|
+
| { kind: "code"; attrs?: { lang?: string } }
|
|
90
91
|
| { kind: "island" }
|
|
91
92
|
| { kind: "rule" }
|
|
92
|
-
| { kind: string; attrs
|
|
93
|
+
| { kind: string; attrs?: unknown };
|
|
93
94
|
|
|
94
95
|
/** An ancestor block a line nests inside, outermost first. Open like
|
|
95
96
|
* `ContentLine.kind`: an unrecognized container round-trips with opaque `attrs`
|
|
@@ -118,22 +119,26 @@ export type ContentLineKind =
|
|
|
118
119
|
* Content parsed from a stored document is the one shape that arrives without
|
|
119
120
|
* it — storage omits a zero — and needs a cast. */
|
|
120
121
|
export type ContentContainer =
|
|
121
|
-
| {
|
|
122
|
+
| {
|
|
123
|
+
container: "list_item";
|
|
124
|
+
attrs: { ordered: boolean; start: number; ordinal: number };
|
|
125
|
+
instance: number;
|
|
126
|
+
}
|
|
122
127
|
| { container: "quote"; instance: number }
|
|
123
|
-
| { container: string; attrs
|
|
128
|
+
| { container: string; attrs?: unknown; instance: number };
|
|
124
129
|
|
|
125
130
|
/** A mark over char range `[start, end)` into `Content.text`. The open `type`
|
|
126
131
|
* arm blocks discriminant narrowing, so read a payload-carrying arm behind its
|
|
127
|
-
* guard: `isLinkMark` (`url`) / `isAnchorMark` (`id`), from
|
|
132
|
+
* guard: `isLinkMark` (`attrs.url`) / `isAnchorMark` (`attrs.id`), from
|
|
128
133
|
* `@quillmark/wasm/runtime`. An `anchor`'s `id` is a caller-supplied opaque
|
|
129
134
|
* handle, unique per `Content` and invariant while the mark lives (positions
|
|
130
135
|
* rebase, the id never does); it has no markdown projection and survives only
|
|
131
136
|
* through the edit lane. */
|
|
132
137
|
export type ContentMark = { start: number; end: number } & (
|
|
133
138
|
| { type: "strong" | "emph" | "underline" | "strike" | "code" }
|
|
134
|
-
| { type: "link"; url: string }
|
|
135
|
-
| { type: "anchor"; id: string }
|
|
136
|
-
| { type: string; attrs
|
|
139
|
+
| { type: "link"; attrs: { url: string } }
|
|
140
|
+
| { type: "anchor"; attrs: { id: string } }
|
|
141
|
+
| { type: string; attrs?: unknown }
|
|
137
142
|
);
|
|
138
143
|
|
|
139
144
|
/** A cell in a `TableProps`. `marks` rides the prose `ContentMark` shape, but
|
|
@@ -290,6 +295,18 @@ export type IslandOp =
|
|
|
290
295
|
* Within each channel ops apply in sequence against the state the earlier ones
|
|
291
296
|
* left: an island `insert`'s `at` counts earlier ops' slots, and `lineOps`
|
|
292
297
|
* positions and indices renumber through earlier `split`/`join`.
|
|
298
|
+
*
|
|
299
|
+
* **Mark rebase.** `delta`, `islandOps` and `lineOps` each move text, and each
|
|
300
|
+
* rebases the marks already in the field by one rule: a range mark's `start`
|
|
301
|
+
* takes assoc `after` and its `end` `before`, so an insertion at either edge
|
|
302
|
+
* grows text *outside* the span; a **zero-width** mark takes `before`, so an
|
|
303
|
+
* insertion at its own position leaves it put. That last case is the one
|
|
304
|
+
* position where the two assocs differ, and where an anchor most often sits.
|
|
305
|
+
*
|
|
306
|
+
* `markOps` name the result, so a caller emitting them predicts this rebase.
|
|
307
|
+
* `mapMarks(content, bundle)` runs it instead: pass the bundle's text-moving
|
|
308
|
+
* channels, diff the marks it returns against the ones you intend, and emit
|
|
309
|
+
* only the difference. Reproducing the rule by hand is a second copy to drift.
|
|
293
310
|
*/
|
|
294
311
|
export interface ChangeBundle {
|
|
295
312
|
delta?: Delta;
|
|
@@ -548,7 +565,7 @@ export interface ContentHit {
|
|
|
548
565
|
*/
|
|
549
566
|
field: string;
|
|
550
567
|
/**
|
|
551
|
-
* USV offset into the field
|
|
568
|
+
* USV offset into the field's `Content`.
|
|
552
569
|
*/
|
|
553
570
|
pos: number;
|
|
554
571
|
/**
|
|
@@ -565,12 +582,12 @@ export interface ContentHit {
|
|
|
565
582
|
* (paragraph, heading, whole code fence) and per page each touches, a scalar
|
|
566
583
|
* referenced at several plate sites surfaces each site, and tracked content
|
|
567
584
|
* plus a `field:`-bound widget yields both. Group by `field`. The whole-field
|
|
568
|
-
* highlight is the union of a page
|
|
585
|
+
* highlight is the union of a page's `span`-bearing rects, so inter-paragraph
|
|
569
586
|
* whitespace stays uncovered; `LiveSession.fieldBoxes(field)` owns that union.
|
|
570
587
|
*/
|
|
571
588
|
export interface FieldRegion {
|
|
572
589
|
/**
|
|
573
|
-
* Canonical `DocPath` field address (e.g.
|
|
590
|
+
* Canonical `DocPath` field address (e.g. `"cards.indorsement[1].from"`):
|
|
574
591
|
* the grammar `parseDocPath` reads and `Diagnostic.path` carries. Feed it
|
|
575
592
|
* back to `fieldBoxes` / `locate`.
|
|
576
593
|
*/
|
|
@@ -584,7 +601,7 @@ export interface FieldRegion {
|
|
|
584
601
|
*/
|
|
585
602
|
rect: [number, number, number, number];
|
|
586
603
|
/**
|
|
587
|
-
* The slice this box covers: USV `[start, end)` into the field
|
|
604
|
+
* The slice this box covers: USV `[start, end)` into the field's `Content`
|
|
588
605
|
* for one content segment, `undefined` for a scalar site or widget.
|
|
589
606
|
*/
|
|
590
607
|
span?: [number, number];
|
|
@@ -599,7 +616,7 @@ export interface Diagnostic {
|
|
|
599
616
|
message: string;
|
|
600
617
|
location?: Location;
|
|
601
618
|
/**
|
|
602
|
-
* Document-model path anchor (e.g.
|
|
619
|
+
* Document-model path anchor (e.g. `"cards.indorsement[0].signature_block"`),
|
|
603
620
|
* set on schema validation diagnostics and `undefined` otherwise.
|
|
604
621
|
*/
|
|
605
622
|
path?: string;
|
|
@@ -720,6 +737,10 @@ export class Document {
|
|
|
720
737
|
* Throws on an out-of-range card, a field that is not richtext, a malformed
|
|
721
738
|
* bundle, or an op that applies out of bounds; the value is unchanged on a
|
|
722
739
|
* failed apply.
|
|
740
|
+
*
|
|
741
|
+
* Each text-moving channel rebases the marks already in the field, by the
|
|
742
|
+
* rule on `ChangeBundle`; `mapMarks` answers where they land, so a caller
|
|
743
|
+
* building `markOps` need not predict it.
|
|
723
744
|
*/
|
|
724
745
|
applyChange(addr: Addr | string, bundle: ChangeBundle): void;
|
|
725
746
|
/**
|
|
@@ -1056,8 +1077,14 @@ export class LiveSession {
|
|
|
1056
1077
|
* `FieldRegion.rect`, so from a canvas click use
|
|
1057
1078
|
* `x = clickPx.x / renderScale`, `y = pageHeightPt - clickPx.y / renderScale`.
|
|
1058
1079
|
* Unlike `regions()`, *every* placement answers, not just the first.
|
|
1080
|
+
*
|
|
1081
|
+
* `tolPt` is how far off the ink a click still counts, in the same points,
|
|
1082
|
+
* and defaults to `0` — exact. Convert the pointer slack a surface wants
|
|
1083
|
+
* from CSS pixels at the scale it drew the page (`slackPx / renderScale`),
|
|
1084
|
+
* so it stays the same size under the cursor as the page zooms. The
|
|
1085
|
+
* nearest placement answers, so raising it only fills a miss.
|
|
1059
1086
|
*/
|
|
1060
|
-
fieldAt(page: number, x: number, y: number): string | undefined;
|
|
1087
|
+
fieldAt(page: number, x: number, y: number, tol_pt?: number | null): string | undefined;
|
|
1061
1088
|
/**
|
|
1062
1089
|
* The whole-field highlight boxes for `field`: one union rect per page over
|
|
1063
1090
|
* the field's `span`-bearing content segments, the union `regions()` leaves
|
|
@@ -1097,11 +1124,15 @@ export class LiveSession {
|
|
|
1097
1124
|
/**
|
|
1098
1125
|
* A point → **content position**: the field *and* a USV offset into its
|
|
1099
1126
|
* `Content`, for placing a caret or mapping a selection into the content
|
|
1100
|
-
* model, or `undefined` off all content ink. `x`/`y` are PDF
|
|
1101
|
-
* bottom-left origin, as in `fieldAt`. The offset is cluster-exact
|
|
1102
|
-
* degrades to the containing segment's start on origin-less ink.
|
|
1127
|
+
* model, or `undefined` off all content ink. `x`/`y`/`tolPt` are PDF
|
|
1128
|
+
* points, bottom-left origin, as in `fieldAt`. The offset is cluster-exact
|
|
1129
|
+
* and degrades to the containing segment's start on origin-less ink.
|
|
1130
|
+
*
|
|
1131
|
+
* `tolPt` earns the most here: the leading between two lines lies inside a
|
|
1132
|
+
* paragraph and on no glyph, and under `tolPt` such a point takes the
|
|
1133
|
+
* nearer line.
|
|
1103
1134
|
*/
|
|
1104
|
-
positionAt(page: number, x: number, y: number): ContentHit | undefined;
|
|
1135
|
+
positionAt(page: number, x: number, y: number, tol_pt?: number | null): ContentHit | undefined;
|
|
1105
1136
|
/**
|
|
1106
1137
|
* Schema-field geometry for this compiled session: each content field's
|
|
1107
1138
|
* **first placement** (one region per page it touches) plus widget and
|
|
@@ -1296,11 +1327,31 @@ export function formatDocPath(segs: DocPathSeg[]): string;
|
|
|
1296
1327
|
*/
|
|
1297
1328
|
export function importMarkdown(markdown: string): Content;
|
|
1298
1329
|
|
|
1330
|
+
/**
|
|
1331
|
+
* Where `bundle`'s text-moving channels (`delta`, then `islandOps`, then
|
|
1332
|
+
* `lineOps`) leave `content`'s marks: the final-text coordinates the bundle's
|
|
1333
|
+
* `markOps` are written in, under the rebase rule stated on `ChangeBundle`.
|
|
1334
|
+
* The document-free read an editor diffs against to decide which `markOps` to
|
|
1335
|
+
* emit, rather than reproducing that rule in its own language.
|
|
1336
|
+
*
|
|
1337
|
+
* `bundle.markOps` are ignored. The answer is normalized, as the store's is:
|
|
1338
|
+
* marks a text move drops (out of range, zero-width formatting) are absent,
|
|
1339
|
+
* and same-kind runs a move left adjacent arrive already unioned, so a bundle
|
|
1340
|
+
* carrying no `markOps` names the marks the field will hold.
|
|
1341
|
+
* Throws on a non-content `content`, a malformed bundle, or an op that applies
|
|
1342
|
+
* out of bounds: `applyChange`'s errors on the same ops.
|
|
1343
|
+
*/
|
|
1344
|
+
export function mapMarks(content: Content, bundle: ChangeBundle): ContentMark[];
|
|
1345
|
+
|
|
1299
1346
|
/**
|
|
1300
1347
|
* Map a base content position (a USV index into `Content.text`, not a UTF-16
|
|
1301
1348
|
* offset) through a `delta` to its new position, holding a caret stable across
|
|
1302
1349
|
* a `revise`. `assoc` decides the side of a same-position insertion (`"after"`
|
|
1303
1350
|
* moves past it). Throws on a malformed `delta`.
|
|
1351
|
+
*
|
|
1352
|
+
* This maps a position the *caller* holds. For the marks already in a field,
|
|
1353
|
+
* `mapMarks` applies the store's own assoc rule across every channel of a
|
|
1354
|
+
* `ChangeBundle`.
|
|
1304
1355
|
*/
|
|
1305
1356
|
export function mapPos(delta: Delta, pos: number, assoc: Assoc): number;
|
|
1306
1357
|
|
|
@@ -1394,18 +1445,19 @@ export interface InitOutput {
|
|
|
1394
1445
|
readonly formatDocPath: (a: number, b: number) => void;
|
|
1395
1446
|
readonly importMarkdown: (a: number, b: number, c: number) => void;
|
|
1396
1447
|
readonly livesession_backendId: (a: number, b: number) => void;
|
|
1397
|
-
readonly livesession_fieldAt: (a: number, b: number, c: number, d: number, e: number) => void;
|
|
1448
|
+
readonly livesession_fieldAt: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
|
1398
1449
|
readonly livesession_fieldBoxes: (a: number, b: number, c: number, d: number) => void;
|
|
1399
1450
|
readonly livesession_locate: (a: number, b: number, c: number, d: number) => number;
|
|
1400
1451
|
readonly livesession_pageCount: (a: number) => number;
|
|
1401
1452
|
readonly livesession_pageSize: (a: number, b: number, c: number) => void;
|
|
1402
1453
|
readonly livesession_paint: (a: number, b: number, c: number, d: number, e: number) => void;
|
|
1403
|
-
readonly livesession_positionAt: (a: number, b: number, c: number, d: number) => number;
|
|
1454
|
+
readonly livesession_positionAt: (a: number, b: number, c: number, d: number, e: number) => number;
|
|
1404
1455
|
readonly livesession_regions: (a: number, b: number) => void;
|
|
1405
1456
|
readonly livesession_render: (a: number, b: number, c: number) => void;
|
|
1406
1457
|
readonly livesession_supportsCanvas: (a: number) => number;
|
|
1407
1458
|
readonly livesession_update: (a: number, b: number, c: number) => void;
|
|
1408
1459
|
readonly livesession_warnings: (a: number, b: number) => void;
|
|
1460
|
+
readonly mapMarks: (a: number, b: number, c: number) => void;
|
|
1409
1461
|
readonly mapPos: (a: number, b: number, c: number, d: number) => void;
|
|
1410
1462
|
readonly parseDocPath: (a: number, b: number, c: number) => void;
|
|
1411
1463
|
readonly quill_backendId: (a: number, b: number) => void;
|