@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 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.93.0`) is the **model version**,
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.93.x` patch release reads
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
- * The open arm blocks discriminant narrowing, so read `level`/`lang` behind a
78
- * check of the arm you want. */
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: unknown };
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
- | { container: "list_item"; ordered: boolean; start: number; ordinal: number; instance: number }
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: unknown; instance: number };
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: unknown }
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\'s `Content`.
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\'s `span`-bearing rects, so inter-paragraph
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. `\"cards.indorsement[1].from\"`):
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\'s `Content`
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. `\"cards.indorsement[0].signature_block\"`),
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 points,
1101
- * bottom-left origin, as in `fieldAt`. The offset is cluster-exact and
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;