@quillmark/wasm 0.92.1 → 0.95.1

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/README.md CHANGED
@@ -14,13 +14,13 @@ The package exposes **one import surface**:
14
14
  an `Engine` that renders them.
15
15
 
16
16
  `Quill` and `Document` are re-exported verbatim from the internal Typst-less
17
- core build, so engine-free editor/validation code (`Quill.fromTree`,
17
+ core build, so editor/validation code (`Quill.fromTree`,
18
18
  `Document.fromMarkdown`) loads only that small core binary — no backend is
19
- loaded until you render. The `Engine` hides everything else: each backend (Typst
20
- today) is a separate, private WASM binary with its own linear memory, lazily
21
- loaded on the first render. The Engine clones a `Quill` / `Document` into the
22
- backend's memory as data and frees the clones — you never hold a backend object
23
- or cross a memory boundary yourself.
19
+ loaded until you render. The `Engine` hides everything else: each backend
20
+ (`typst`, `pdfform`) is a separate, private WASM binary with its own linear
21
+ memory, lazily loaded on the first render. The Engine clones a `Quill` /
22
+ `Document` into the backend's memory as data and frees the clones — you never
23
+ hold a backend object or cross a memory boundary yourself.
24
24
 
25
25
  ## Build
26
26
 
@@ -28,9 +28,10 @@ or cross a memory boundary yourself.
28
28
  bash scripts/build-wasm.sh
29
29
  ```
30
30
 
31
- The script builds two feature variants — the core (no Typst) and the default
32
- (Typst backend) both with `--target bundler` and `--weak-refs` enabled
33
- (see [Lifecycle](#lifecycle)).
31
+ The script builds three variants — the core (no backend), the Typst backend
32
+ (default features), and the Typst-free pdfform backend (`pdfform` feature)
33
+ each with `--target bundler` and `--weak-refs` enabled (see
34
+ [Lifecycle](#lifecycle)).
34
35
 
35
36
  ## Test
36
37
 
@@ -46,7 +47,7 @@ npm test
46
47
  ```ts
47
48
  import { Document, Quill, Engine } from "@quillmark/wasm";
48
49
 
49
- const quill = Quill.fromTree(tree); // engine-free: build + validate
50
+ const quill = Quill.fromTree(tree); // no engine needed: build + validate
50
51
  const engine = new Engine(); // loads a backend lazily on first render
51
52
 
52
53
  const markdown = `~~~
@@ -84,6 +85,14 @@ never cloning the quill. Use them as non-failing pre-render probes.
84
85
  Build + validate a `Quill` from an in-memory tree. Pure — the declared backend
85
86
  is resolved at render time, not here. Loads no backend binary.
86
87
 
88
+ ### `new Document(quillRef)`
89
+ A blank document: a main card carrying only `$quill`, an empty body, and no
90
+ composable cards — the programmatic blank canvas. Absent fields resolve at
91
+ render time (schema `default`, else type-empty zero), so nothing the caller
92
+ did not set reaches the output. Build it up with `storeFields` / `insertCard`.
93
+ For an example-filled starter use `quill.seedDocument()`. Throws on an
94
+ invalid quill reference.
95
+
87
96
  ### `Document.fromMarkdown(markdown)`
88
97
  Parse markdown to a parsed document. Throws a JS `Error` (with `.diagnostics`
89
98
  attached, see [Errors](#errors)) on any parse failure, including a missing
@@ -145,9 +154,9 @@ genuinely malformed Markdown.
145
154
 
146
155
  ### Storage compatibility across versions
147
156
 
148
- The `schema` value (`quillmark/document@0.82.0`) is the **model version**,
157
+ The `schema` value (`quillmark/document@0.93.0`) is the **model version**,
149
158
  not the running crate version. It is a hand-set constant, bumped only when
150
- the `Document` model itself changes — so every `0.82.x` patch release reads
159
+ the `Document` model itself changes — so every `0.93.x` patch release reads
151
160
  and writes that same value.
152
161
 
153
162
  - **Upgrading is safe.** A newer build always reads documents written by an
@@ -188,15 +197,15 @@ and compare instead of re-parsing on every keystroke.
188
197
  ### `doc.cardCount`
189
198
  O(1) getter for the number of composable cards (excluding the main card).
190
199
  Use this to validate indices before calling card mutators (`removeCard`,
191
- `updateCardField`, etc.) without allocating the full `cards` array.
200
+ `storeField({ card, field }, …)`, etc.) without allocating the full `cards` array.
192
201
 
193
202
  ### `quill.validate(doc)`
194
203
 
195
204
  Returns `Diagnostic[]` — the document validated against the quill schema,
196
205
  without invoking the backend. An empty array means the document is valid.
197
206
  Each diagnostic carries the canonical `validation::*` `code`, `path`, and
198
- `hint`. Includes the non-fatal `validation::field_absent` completeness
199
- signal that `render` demotes (an absent Unendorsed field zero-fills rather
207
+ `hint`. Includes the non-fatal `validation::must_fill` warning for each
208
+ `!must_fill` marker left in the document (render zero-fills these rather
200
209
  than failing), so filter by `severity`/`code` for blockers vs. hints:
201
210
 
202
211
  ```ts
@@ -204,9 +213,9 @@ const diagnostics = quill.validate(Document.fromMarkdown(markdown));
204
213
  const errors = diagnostics.filter(d => d.severity === "error");
205
214
  ```
206
215
 
207
- To render a form editor, read field definitions from `quill.schema` (sort
208
- fields by each field's `ui.order`) and the authored values from the
209
- `Document` payload — there is no separate form-view projection.
216
+ To render a form editor, read field definitions from `quill.schema` (walk
217
+ `fields` in key order — declaration order is display order) and the authored
218
+ values from the `Document` payload — there is no separate form-view projection.
210
219
 
211
220
  ### `quill.seedDocument()`
212
221
 
@@ -223,51 +232,128 @@ const markdown = doc.toMarkdown();
223
232
 
224
233
  For per-card seeding, `quill.seedMain()` returns just the `$kind: main` card
225
234
  and `quill.seedCard(kind)` returns a starter composable card (or `undefined`
226
- if the kind is not declared). Both return the same `Card` shape as
227
- `doc.main` / `doc.cards`, which `doc.pushCard` / `doc.insertCard` accept
228
- directly:
235
+ if the kind is not declared). Both return the read `Card` shape of
236
+ `doc.main` / `doc.cards`, which `doc.insertCard` accepts directly:
229
237
 
230
238
  ```ts
231
- doc.pushCard(quill.seedCard("note")); // seed → push
232
- doc.pushCard(Document.makeCard("note", { x: 1 })); // build from a flat map
239
+ doc.insertCard(quill.seedCard("note")); // seed → append
240
+ doc.insertCard(Document.makeCard("note", { x: 1 })); // build from a flat map
241
+ doc.insertCard({ kind: "note", body: "Plain **markdown**." }); // bare inline
242
+ doc.insertCard({ kind: "note" }, 0); // insert at index 0
233
243
  ```
234
244
 
235
- There is one `Card` shape in both directions `pushCard` / `insertCard` take
236
- exactly what `cards` / `removeCard` / `seedCard` return. Build a fresh card
237
- from a flat field map with `Document.makeCard(kind, fields?, body?)`.
245
+ Reads and writes are two aligned shapes. A read `Card` always has `body:
246
+ Content` (canonical content, never a raw string) no narrowing, no guessing
247
+ whether the body was normalized. The write shape `CardInput` widens `body` to
248
+ `Content | string` (a markdown string imports to the content) and makes every
249
+ field but `kind` optional. Every `Card` is a valid `CardInput`, so `insertCard`
250
+ still takes exactly what `cards` / `removeCard` / `seedCard` return.
251
+ Build a fresh card from a flat field map with
252
+ `Document.makeCard(kind, fields?, body?)`.
253
+
254
+ **One address for the whole surface.** Reads and writes navigate by an `Addr` —
255
+ `{ card?, field? }`, absent `card` = main, absent `field` = body — and a bare
256
+ string is shorthand for `{ field }`. So `doc.storeField("qty", 3)` targets the
257
+ main card's `qty`, `doc.storeField({ card: 2, field: "qty" }, 3)` a composable
258
+ card's. Reads are total over the field axis (`get` → `undefined`, `isFill` → `false` for
259
+ an absent field; only an out-of-range card throws); field writes throw on a body
260
+ address. `getMarkdown` is the body markdown read (a `CardAddr`; a field's
261
+ markdown is read through `quill.view(doc).get(field)`). Card-scoped verbs take a
262
+ `CardAddr` (`{ card? }`) first: `doc.getExt({ card: 2 })`, and the batch below.
263
+
264
+ Batch mutation: `doc.storeFields({}, {...})` / `doc.storeFields({ card: index }, {...})`
265
+ apply a whole object atomically — on any invalid field nothing is applied and
266
+ the thrown error carries one diagnostic per offending field (`path` = field
267
+ name). The address is first (never shape-overloaded, since `card` is a legal
268
+ field name), and parses strictly — a stray key throws rather than silently
269
+ reading as `{}`. The main card is `{}`, or **`MAIN_CARD_ADDR`** (from
270
+ `@quillmark/wasm/runtime`), a frozen alias that spells the intent:
271
+ `doc.storeFields(MAIN_CARD_ADDR, {...})`.
272
+
273
+ ### Typed writes: `commit*` is the default, `store*` is the quill-free primitive
274
+
275
+ A `Document` holds only a `$quill` *reference*, not the resolved schema, so typed
276
+ writes go through the schema-bound writer while the quill-free opaque store sits
277
+ on `Document` itself (**store** = verbatim, **set** = typed):
278
+
279
+ - **`quill.writer(doc)` — the typed door whenever a quill is in hand.** Bind the
280
+ schema once and issue bare `set` / `setAll` / `setBody` / `reviseField` /
281
+ `addCard` / `card(i)`. Each resolves the field's schema `type`, coerces the
282
+ value to its canonical form (`"3"` → `3`, a markdown string → a richtext
283
+ content), and **fails now** on a mismatch instead of at render. A name the schema
284
+ does not declare throws `UnknownField` rather than falling to the opaque store —
285
+ on the typed path an undeclared name is a typo, not a fallback. The batch form
286
+ (`setAll`) is all-or-nothing: an undeclared name aborts the whole write and its
287
+ per-field diagnostics name every offending field, so a whole-form submit
288
+ surfaces every typo `storeFields` would silently absorb. (The raw wasm class
289
+ carries the quill-taking `_commitField` / `_commitFields` / `_addCard` /
290
+ `_reviseField` ABI the writer delegates to, hidden from the `.d.ts`.)
291
+
292
+ - **`store*` — the deliberate quill-free primitive.** `doc.storeField(addr, value)`
293
+ / `doc.storeFields(cardAddr, {...})` (and `storeFill`) validate only the field
294
+ name/depth/kind and store the value verbatim, no quill required. Reach for it
295
+ on purpose when you *want* the opaque store: quill-agnostic storage/migration
296
+ infra that has no bundle and must write regardless of a drifted schema;
297
+ store-now-validate-later editors holding in-progress input that `commit`
298
+ would reject; or verbatim passthrough of fields the schema doesn't own. It is
299
+ the lower layer, not a lighter `commit` — a typo'd field name stores silently
300
+ and only surfaces at `quill.validate` / render.
301
+
302
+ Per-keystroke cost is the same either way (both mutate the in-memory `Document`
303
+ in place; no seam is crossed), so steering to the writer buys the type check for
304
+ free.
305
+
306
+ #### `DocumentWriter` / `CardWriter` — bind the quill once
307
+
308
+ `quill.writer(doc)` binds the quill's schema to the document once, so a form
309
+ editor or MCP writer that holds both issues bare verbs (the writer forwards to
310
+ the per-call `_commit*` ABI):
238
311
 
239
- ### `engine.render(quill, parsed, opts?)` vs. `engine.open(quill, parsed)`
312
+ ```ts
313
+ const ed = quill.writer(doc); // Rust `quill.writer(doc)` twin; new DocumentWriter(quill, doc) also works
314
+ ed.set("subject", "Q3 results"); // strict-committed to the schema type
315
+ ed.setAll({ qty: "3", subject: "Q3" }); // all-or-nothing batch
316
+ ed.reviseField("subject", "Q3 **results**"); // typed AND anchor-preserving; returns a Delta
317
+ ed.set("titel", "x"); // throws UnknownField — a typo, not a fallback
318
+ ed.card(2).set("body", "**note**"); // composable card, resolved by its $kind
319
+ ```
240
320
 
241
- > **Experimental:** the entire session surface `engine.open`,
242
- > `RenderSession`, `paint`, `PaintOptions`, `PaintResult`, `PageSize`, and the
243
- > `supportsCanvas` probe ships ahead of its first production consumer and
244
- > may change shape in any 0.x release. `engine.render` is the stable path.
321
+ `DocumentWriter` / `CardWriter` are pure JS holding references to your existing
322
+ `quill` and `doc` — no WASM handle of their own, nothing to `free()`. `card(i)`
323
+ is lazy: it never throws; an out-of-range index throws `IndexOutOfRange` at the
324
+ write.
325
+
326
+ ### `engine.render(quill, parsed, opts?)` vs. `engine.open(quill, parsed)`
245
327
 
246
328
  Use **`engine.render`** for one-shot exports (PDF/SVG/PNG) — compiles, emits
247
- artifacts, done. Use **`RenderSession`** (returned by `engine.open`) for
248
- reactive previews where
249
- you'll paint or re-emit pages multiple times: the session retains the compiled
250
- snapshot so subsequent `paint` / `render` calls skip recompilation. Don't open
251
- a session per export.
329
+ artifacts, done. Use **`LiveSession`** (returned by `engine.open`) for
330
+ reactive previews: the session is a persistent compiler. `paint` / `render` /
331
+ `regions` / `fieldAt` read its current compile without recompiling, and `apply(doc)`
332
+ recompiles in place on each edit, returning a `ChangeSet` whose `dirtyPages`
333
+ tells you which pages to repaint (`dirty ∩ visible`). Apply is transactional —
334
+ on throw, every read keeps serving the last-good compile. Don't open a session
335
+ per export, and don't re-open per edit — `apply` instead.
252
336
 
253
337
  ### `engine.render(quill, parsed, opts?)`
254
- Render a pre-parsed `Document` against `quill`. Throws `UnsupportedBackend` if
255
- no registered backend matches the quill's declared backend.
338
+ Render a pre-parsed `Document` against `quill`. Throws an
339
+ `engine::backend_not_found` error if no registered backend matches the quill's
340
+ declared backend.
256
341
 
257
342
  ### `engine.open(quill, parsed)` + `session.render(opts?)`
258
343
  Open once, render all or selected pages (`opts.pages`).
259
344
 
260
345
  The session also exposes `pageCount`, `backendId`, `supportsCanvas`,
261
- `warnings` (snapshot of session-level diagnostics attached at `open` time),
262
- `pageSize(page)`, and `paint(ctx, page, opts?)` for canvas previews. See
263
- below.
346
+ `warnings` (non-fatal diagnostics of the current compile — set at `open`,
347
+ refreshed by each committed `apply`),
348
+ `apply(doc)` for in-place recompiles, `pageSize(page)`, and
349
+ `paint(ctx, page, opts?)` for canvas previews. See below.
264
350
 
265
351
  A document that compiles to zero pages still produces a valid session
266
352
  (`pageCount === 0`); `paint(ctx, 0)` and `pageSize(0)` then throw
267
353
  `page index 0 out of range (pageCount=0)`. Branch on `pageCount === 0` to
268
354
  render a "no pages to preview" UI without relying on the throw.
269
355
 
270
- ### Canvas Preview (Typst only)
356
+ ### Canvas Preview
271
357
 
272
358
  `session.paint(ctx, page, opts?)` rasterizes a page directly into a
273
359
  `CanvasRenderingContext2D` (main thread) or
@@ -281,7 +367,7 @@ sets them) and read `layoutWidth` / `layoutHeight` from the returned
281
367
 
282
368
  ```ts
283
369
  const result = session.paint(canvas.getContext("2d"), 0, {
284
- layoutScale: 1, // layout px per Typst pt
370
+ layoutScale: 1, // layout px per pt (page geometry unit)
285
371
  densityScale: window.devicePixelRatio, // backing-store density
286
372
  });
287
373
 
@@ -291,17 +377,26 @@ canvas.style.height = `${result.layoutHeight}px`;
291
377
 
292
378
  - `layoutScale` (default 1) sets the canvas's display-box size:
293
379
  `layoutWidth = widthPt * layoutScale`. For on-screen canvases this is
294
- CSS pixels per Typst point. Defaults to 1 (one CSS pixel per pt).
380
+ CSS pixels per point. Defaults to 1 (one CSS pixel per pt).
295
381
  - `densityScale` (default 1) is the backing-store density multiplier.
296
382
  Fold `window.devicePixelRatio`, in-app zoom, and `visualViewport.scale`
297
383
  (pinch-zoom) into a single value here. Pass `devicePixelRatio` for
298
384
  crisp output on high-DPI displays.
299
385
  - The effective rasterization scale is `layoutScale * densityScale`. If
300
386
  that would exceed the safe maximum (16384 px per side), `densityScale`
301
- is clamped proportionally; compare `result.pixelWidth` against
302
- `Math.round(result.layoutWidth * densityScale)` to detect.
387
+ is clamped proportionally; `result.clamped` reports it and
388
+ `result.effectiveDensityScale` is the density actually applied. A
389
+ clamped page renders soft at the same `canvas.style` size.
390
+ - `paint` writes the whole backing store with `putImageData`, which
391
+ ignores the 2D context transform, `globalAlpha`, and clip. Give each
392
+ visible page its own `` element — you cannot composite two pages,
393
+ a sub-rect, or a context transform through `paint`.
303
394
  - `paint` is always a full repaint — setting the backing-store width /
304
- height clears it. No `clearRect` required.
395
+ height clears it. No `clearRect` required. Each call re-rasterizes from
396
+ scratch (no per-page raster cache), so keep a page's canvas alive while
397
+ it stays near the viewport rather than pooling one canvas across pages:
398
+ an idle canvas retains its pixels for free, whereas reusing a canvas on
399
+ scroll re-runs a full render.
305
400
  - `pageCount` and `pageSize(page)` are stable for the session's
306
401
  lifetime (immutable snapshot) — cache them.
307
402
  - Worker support: pass an `OffscreenCanvasRenderingContext2D` and the
@@ -319,19 +414,19 @@ canvas.style.height = `${result.layoutHeight}px`;
319
414
 
320
415
  A field's *cell* is inferred from whether its schema declares a `default:`:
321
416
 
322
- - **Unendorsed** (no `default:`) — `quill.blueprint` renders `<must-fill>`
323
- in the value cell. An absent Unendorsed field is a non-fatal signal
324
- (`validation::field_absent`) the render path zero-fills it
325
- silently. A surviving `<must-fill>` sentinel is fatal
326
- (`validation::must_fill_sentinel`). Partial documents are
327
- first-class; `engine.render(quill, doc)` only throws for malformed input.
417
+ - **Unendorsed** (no `default:`) — `quill.blueprint` renders the
418
+ `!must_fill` marker in the value cell (carrying the field's `example` as a
419
+ suggested value when one exists). An absent Unendorsed field zero-fills
420
+ silently. A `!must_fill` marker left in the document is non-fatal: it emits
421
+ the `validation::must_fill` warning and still renders. Partial documents
422
+ are accepted; `engine.render(quill, doc)` only throws for malformed
423
+ input.
328
424
  - **Endorsed** (with `default:`) — `quill.blueprint` renders the
329
- default value followed by a `; delete-ok` annotation, and the default
330
- is used when the document omits the field.
425
+ default value with a type-only `# <type>` annotation (shippable as-is),
426
+ and the default is used when the document omits the field.
331
427
 
332
- `QuillFieldSchema` has no `required` axis. Absent Unendorsed fields emit
333
- `validation::field_absent`; a surviving `<must-fill>` sentinel emits
334
- `validation::must_fill_sentinel`.
428
+ `QuillFieldSchema` has no `required` axis. A `!must_fill` marker left in the
429
+ document emits the non-fatal `validation::must_fill` warning.
335
430
 
336
431
  ### Errors
337
432
 
@@ -367,20 +462,26 @@ compilation failures. The same shape applies to every throw site:
367
462
 
368
463
  - `Document.fromMarkdown` — parse errors (missing root `$quill` metadata, YAML
369
464
  errors, `parse::input_too_large` for inputs > 10 MB).
370
- - `Document` mutators (`setField`, `updateCardField`, etc.) — `EditError`
465
+ - `Document` mutators (`storeField`, the writer's `set`, etc.) — `EditError`
371
466
  variants (`InvalidFieldName`, `InvalidKindName`, `ReservedKind`,
372
- `IndexOutOfRange`) appear in `diagnostics[0].message` with the
373
- `[EditError::<Variant>]` prefix.
467
+ `IndexOutOfRange`, `ValueTooDeep`, `Import`) appear in `diagnostics[0].message`
468
+ with the `[EditError::<Variant>]` prefix.
374
469
  - `engine.render` / `session.render` — backend compilation failures and
375
470
  validation errors.
376
471
 
377
472
  ### Lifecycle
378
473
 
379
474
  The wasm bindings are built with `--weak-refs`, so dropped `Document`,
380
- `Quill`, and `RenderSession` handles are reclaimed by `FinalizationRegistry`
475
+ `Quill`, and `LiveSession` handles are reclaimed by `FinalizationRegistry`
381
476
  without manual `.free()` discipline. `.free()` is still emitted as an eager
382
477
  teardown hook for callers that want deterministic release.
383
478
 
479
+ `engine.render` and `engine.open` read the `quill` and `doc` handles
480
+ synchronously, before their first await, so freeing a handle as soon as the
481
+ call returns — `try { return engine.render(quill, doc); } finally
482
+ { doc.free(); }` — is safe even on the first render, while the backend
483
+ binary is still loading.
484
+
384
485
  The package floor is Node 22+ (`engines: { node: ">=22" }`) and current
385
486
  evergreen browsers; `--weak-refs` itself only needs Node 14.6+. The `using`
386
487
  sugar shown below ([explicit resource management][erm]) needs Node 24, but is
@@ -390,7 +491,7 @@ For environments where `using` (the [explicit resource management][erm]
390
491
  proposal) hasn't landed, use an explicit `try` / `finally`:
391
492
 
392
493
  ```ts
393
- const session = engine.open(quill, doc);
494
+ const session = await engine.open(quill, doc);
394
495
  try {
395
496
  for (let p = 0; p < session.pageCount; p++) {
396
497
  session.paint(ctx, p);
@@ -405,7 +506,7 @@ try {
405
506
  ## Notes
406
507
 
407
508
  - Parsed markdown requires a root `~~~` block (a bare three-tilde fence;
408
- the legacy `~~~card-yaml` opener is still accepted but non-canonical)
509
+ `~~~card-yaml` is also accepted as a non-canonical alias)
409
510
  with a `$quill` system-metadata line. Empty input surfaces a dedicated
410
511
  "Empty markdown input cannot be parsed" message.
411
512
  - A `$quill` mismatch during `engine.render(quill, parsed)` is a thrown error, not a warning: rendering with a quill whose *name* differs (`quill::name_mismatch`) or whose *version* falls outside the selector (`quill::version_mismatch`) is rejected.
@@ -413,8 +514,8 @@ try {
413
514
 
414
515
  ## Changelog
415
516
 
416
- See the [changelog](https://github.com/quillmark-org/quillmark/blob/main/CHANGELOG.md)
417
- and the [GitHub Releases](https://github.com/quillmark-org/quillmark/releases) page for
517
+ See the [changelog](https://github.com/borb-sh/quillmark/blob/main/CHANGELOG.md)
518
+ and the [GitHub Releases](https://github.com/borb-sh/quillmark/releases) page for
418
519
  release notes and version history.
419
520
 
420
521
  ## License