rapier-markdown-kit 1.1.104 → 1.2.5

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.
Files changed (60) hide show
  1. package/LICENSE +8 -4
  2. package/README.md +50 -7
  3. package/dist/images/gif.mjs +49 -0
  4. package/dist/images/playback.mjs +103 -0
  5. package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
  6. package/dist/kit/conformance/04-wrap-box.md +1 -1
  7. package/dist/kit/conformance/05-behind.md +1 -1
  8. package/dist/kit/conformance/06-front.md +1 -1
  9. package/dist/kit/conformance/07-rotate-raster.md +1 -1
  10. package/dist/kit/conformance/08-drawing-rotation.md +1 -1
  11. package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
  12. package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
  13. package/dist/kit/conformance/10-both-sides.md +1 -1
  14. package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
  15. package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
  16. package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
  17. package/dist/kit/conformance/README.md +19 -28
  18. package/dist/kit/conformance/run.mjs +10 -8
  19. package/dist/kit/ledger/authored-origin.mjs +49 -0
  20. package/dist/kit/ledger/authorship.mjs +18 -9
  21. package/dist/kit/ledger/carried.mjs +10 -5
  22. package/dist/kit/ledger/data.mjs +7 -4
  23. package/dist/kit/ledger/format.mjs +37 -11
  24. package/dist/kit/ledger/hash.mjs +31 -15
  25. package/dist/kit/ledger/history.mjs +48 -10
  26. package/dist/kit/ledger/inverse-source.mjs +111 -49
  27. package/dist/kit/ledger/journal-records.mjs +24 -29
  28. package/dist/kit/ledger/merge.mjs +51 -15
  29. package/dist/kit/ledger/text-merge.mjs +75 -29
  30. package/dist/kit/ledger/transport.mjs +30 -45
  31. package/dist/kit/pdf-vectors.mjs +6 -2
  32. package/dist/kit/print-capitals.mjs +0 -1
  33. package/dist/kit/rapier-ledger.mjs +4 -2
  34. package/dist/kit/render-ink.mjs +1 -1
  35. package/dist/kit/render-markdown.mjs +155 -31
  36. package/dist/kit/render-print.mjs +173 -61
  37. package/dist/kit/render-sanitize.mjs +25 -15
  38. package/dist/kit/render-styles.mjs +3 -3
  39. package/dist/kit/render.mjs +37 -115
  40. package/dist/layout/model.mjs +10 -8
  41. package/dist/layout/paragraph-fit.mjs +1 -15
  42. package/dist/layout/typography.mjs +2 -1
  43. package/dist/packages/rapier-diff/concerns.mjs +163 -0
  44. package/dist/packages/rapier-diff/diff.mjs +68 -0
  45. package/dist/packages/rapier-diff/index.mjs +4 -701
  46. package/dist/packages/rapier-diff/matching.mjs +157 -0
  47. package/dist/packages/rapier-diff/merge.mjs +226 -0
  48. package/dist/packages/rapier-diff/moves.mjs +130 -0
  49. package/dist/packages/rapier-diff/structure.mjs +50 -0
  50. package/dist/packages/rapier-diff/tokens.mjs +72 -0
  51. package/dist/packages/rapier-diff/walk.mjs +219 -0
  52. package/dist/spec/document-fonts.mjs +1 -1
  53. package/dist/spec/document-settings.mjs +11 -164
  54. package/dist/spec/html-blocks.mjs +618 -79
  55. package/dist/spec/md-assets.mjs +29 -6
  56. package/dist/spec/md-layout.mjs +9 -1
  57. package/dist/spec/md-marks.mjs +25 -147
  58. package/dist/spec/md-source.mjs +69 -26
  59. package/package.json +1 -1
  60. package/style.css +7 -51
package/LICENSE CHANGED
@@ -21,8 +21,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
22
 
23
23
  Files under this licence carry `SPDX-License-Identifier: MIT` in their first lines: the standard's
24
- grammar, marks and reference style (`spec/md-layout.mjs`, `spec/md-marks.mjs`, `spec/md-source.mjs`, `spec/flowchart.mjs`, `spec/markdown-style.css`), the layout model and line planner
25
- (`layout/model.mjs`, `layout/line-plan.mjs`, `layout/typography.mjs`, `layout/preparation.mjs`),
24
+ grammar, marks and reference style (`spec/md-layout.mjs`, `spec/md-marks.mjs`, `spec/md-source.mjs`, `spec/flowchart.mjs`, `spec/markdown-style.css`), ink geometry
25
+ (`spec/ink.mjs`),
26
+ the layout model and line planner (`layout/model.mjs`, `layout/line-plan.mjs`, `layout/typography.mjs`, `layout/preparation.mjs`,
27
+ `layout/prepared-paragraph.mjs`, `layout/hyphenation.mjs`, `layout/paragraph-fit.mjs`), the animated-picture reader and playback
28
+ the HTML blocks use (`images/gif.mjs`, `images/playback.mjs`),
26
29
  the portable HTML and document metadata helpers (`spec/html-blocks.mjs`, `spec/frontmatter.mjs`,
27
30
  `spec/document-settings.mjs`, `spec/document-fonts.mjs`), the Will grammar (`agent/will.mjs`), the picture appendix reader and writer
28
31
  (`spec/md-assets.mjs`), the two shared-page reference readers (`tools/read-shared-page.mjs`,
@@ -32,8 +35,9 @@ own MIT notices. The edit-ledger module (`kit/rapier-ledger.mjs`, `kit/ledger/*.
32
35
  its editor compatibility import (`editor/journal-records.mjs`) are also covered. The
33
36
  record validator/replayer and text-only conflict merger were moved, not copied,
34
37
  into that module; the Notes index merger and the rest of the agent/editor remain
35
- AGPL-3.0-only. The wrapper's carrier-staging helper
36
- (`skills/rapier-html/package-files.mjs`) is MIT. The kit's MIT-marked facades,
38
+ AGPL-3.0-only. The page wrapper (`skills/rapier-html/wrap.mjs`, `skills/rapier-html/page.mjs`,
39
+ `skills/rapier-html/return-address.mjs`) and its carrier-staging helper
40
+ (`skills/rapier-html/package-files.mjs`) are MIT. The kit's MIT-marked facades,
37
41
  `kit/render-entry.mjs`, `kit/render*.mjs`, `kit/export-fonts.mjs`, `kit/print-capitals.mjs`,
38
42
  `kit/pdf-vectors.mjs`, `kit/pdf-glyphs.mjs`, `kit/stage.mjs` and MIT-marked conformance
39
43
  modules are covered too. These names describe the Rapier-owned files carrying the
package/README.md CHANGED
@@ -37,7 +37,7 @@ import {parseAssets} from 'rapier-markdown-kit/assets'; // the picture appendi
37
37
  Rapier is a phone-first Markdown editor for notes, diagrams, drawing and watercolor painting. It runs offline
38
38
  as one HTML file on Android, Web and Windows.
39
39
 
40
- 1. **Work together live.** Connect to `https://mcp.rapier.website/mcp`, then call `open` to share one document with the person.
40
+ 1. **Work together live.** Connect to `https://mcp.rapier.website/door/mcp`, then call `open` to share one document with the person.
41
41
  2. **Deliver an offline editor.** Run `npx rapier-html notes.md` to put the document and editor in one HTML file ([rapier-html](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-html)).
42
42
  3. **Embed in your app.** Install [rapier-embed](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-embed); mount the editor or reader, with your app's agent over WebMCP in the editor.
43
43
  4. **Keep portable Markdown.** Use [rapier-markdown-kit](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-markdown-kit) for pictures, editable SVG drawings and layout in one `.md` file. Respect [Will/1](https://github.com/jackskip22/will) `keep`, `append` and `edit` regions.
@@ -64,6 +64,27 @@ const {html, filename} = await writer.render(source, {filename: 'report.md'});
64
64
  ```
65
65
 
66
66
  `createMarkdownRenderer(host).render(source, metadata)` returns the semantic HTML.
67
+ `createMarkdownRenderer(host).renderWithMathML(source, metadata)` returns
68
+ `{html, equations}` using the ready host math provider's `renderWithMathML` operation.
69
+ The provider must also support the existing `renderToString` operation.
70
+ Each equation is parsed once for its SVG and MathML. The HTML stays identical to
71
+ ordinary SVG rendering; `equations` follows render order and carries `source`
72
+ (the admitted token spelling, including its delimiters), `tex`, `displayMode`, `semantics`, and SVG
73
+ `errors`. Pass `{docKind: 'markdown'}` as metadata for Markdown documents.
74
+ The existing Markdown grammar may normalize display-block indentation; the original
75
+ document remains the source of untouched document bytes.
76
+ Inspect both `errors` and `semantics.status`: a rendered SVG can have refused or
77
+ partial semantic output. The method throws if the combined provider is unavailable
78
+ or fails, rather than returning an empty semantic success. It does not return an AST.
79
+
80
+ This explicit method makes structured output available to consuming applications;
81
+ Rapier's live editor and ordinary HTML/DOCX/print exports still use the current
82
+ engine's Latin Modern SVG path. They do not insert hidden MathML, synthesize speech,
83
+ or claim screen-reader qualification. A consumer that inserts MathML must preserve
84
+ its namespace and semantics/annotation elements, name enclosing links meaningfully,
85
+ and qualify its own accessibility presentation. The current ordinary document
86
+ sanitizer is not a MathML composition API.
87
+
67
88
  The page writer's `render` returns `{html, filename}` asynchronously and preserves the
68
89
  original source, including BOM and line endings, in the SHA-256-verified source carrier.
69
90
  Every existing lower-level method is also returned for hosts that compose the stages.
@@ -235,8 +256,7 @@ are ignored; invalid/unclosed style strings admit no facts. Inner spans override
235
256
  clearing a selection removes that property's overrides and preserves the surrounding words' styles.
236
257
 
237
258
  Keep each opener with its first words and each closer with its last words. Soft/hard breaks may stay inside one paragraph; blank lines cannot. An opener alone on a line can become a raw HTML block.
238
- The old leading colour comment has the same portability fault: it can suppress Markdown parsing or disappear
239
- with its whole block in a non-HTML conversion. With inline HTML enabled, the new form preserves words, emphasis,
259
+ With inline HTML enabled, the new form preserves words, emphasis,
240
260
  links and heading rank; GitHub strips the styles, and HTML-disabled readers may show the tags. Rapier's **Copy
241
261
  markdown without typography** removes recognized wrappers while retaining semantic Markdown and literal code.
242
262
  Its separate Pandoc/Quarto copy option writes bracketed spans deliberately; Pandoc HTML carries the CSS, but
@@ -563,6 +583,8 @@ no accessors, sparse arrays, cycles, undefined, negative zero or unpaired surrog
563
583
  Readers reject unknown envelope/start/head fields, bad checksums, invalid records,
564
584
  invalid source boundaries, a replay/root mismatch, or a head different from the
565
585
  actual document. `historyEnvelope` additionally proves the editor's Undo/Redo branch.
586
+ Its optional third and fourth arguments are the expected text and metadata, as in
587
+ `readLedger`; installing history can prove both source and navigation in one pass.
566
588
 
567
589
  **Roots are path-dependent.** `textRoot(text)` mints the existing
568
590
  `chars:fnv-base36:adler-base36` initial root. Each splice advances it with
@@ -601,7 +623,9 @@ the authorship projection recomputed from the ledger must agree exactly.
601
623
  text. Runs are `[start, end, actorKind, actorId, at]`, in order, nonoverlapping and
602
624
  covering every current character. A start without earlier authorship is attributed
603
625
  to `system/unattributed`, time zero. Insertions take the inserting record's actor
604
- and time; deleted runs disappear. Thus the projection contains **no removed text**.
626
+ and time, except text an Undo or Redo puts back: a splice inserting exactly the text
627
+ that a splice of the referenced act removed keeps the runs that text had. Deleted
628
+ runs disappear. Thus the projection contains **no removed text**.
605
629
  An authorship-only import becomes a clearly incomplete insertion ledger; future
606
630
  attribution is again derived from that one ledger, never a mutable blame database.
607
631
 
@@ -628,6 +652,15 @@ The comparison page's `#rapier-base` is independent: `text/markdown`, with
628
652
  display reference. It is not reconstructed from the optional ledger and never
629
653
  replaces current source. `rapier-html --compare original.md revised.md` packages both texts.
630
654
 
655
+ A comparison may also carry `data-fonts`, an HTML-attribute-escaped JSON string of
656
+ canonical embedded `[font:]` reference definitions. The admitted base record exposes
657
+ this as `fonts`. These resources belong to the exact comparison, outside its text,
658
+ digest and revision; they never become a source edit or ledger record. Font preparation
659
+ validates their actual bytes and reads font usage only from the original comparison.
660
+ Existing source-owned assets remain authoritative. A missing personal-library font is
661
+ embedded here when available, otherwise export refuses. Reopening and resaving need
662
+ no library or network, and never substitute the current device's default font.
663
+
631
664
  ## Merging copies without a server
632
665
 
633
666
  `merge(first, second)` locates a common checkpoint with the same root **and exact
@@ -638,9 +671,19 @@ editor's restore reader can replay the result. Both merge orders yield the same
638
671
  text for independently edited nonoverlapping ranges; their ordered ledgers need
639
672
  not be identical. Full and conflict receipts make repeated imports idempotent.
640
673
 
641
- Overlaps fall back to the existing text three-way merge. Unresolved alternatives
642
- are carried in the existing `note-conflict:v1` envelope, never silently selected.
643
- Different Will `keep` regions require conflict review even when one copy was
674
+ An identical replacement converges with both original acts retained and one physical
675
+ splice. The format identifier stays `rapier-ledger/2`. That unchanged identifier is not
676
+ backward compatibility with an older reader. There is no `rapier-ledger/3`, no
677
+ compatibility reader, and no migration. An older reader rejects the empty physical
678
+ witness with `ledger_invalid` and does not mutate the ledger. Mixed-version sync throws
679
+ `notes_history_unavailable` (`the note and its canonical history cannot be proved`).
680
+ The older participant's store snapshot and remote object count stay unchanged. That
681
+ throw is not a per-note `held` or `caughtUp` result. The same format string is not
682
+ interoperability, and the ledger is not rewritten into the older shape. Reactivating
683
+ any author of that replacement checks the shared text again; undoing that author
684
+ withdraws it once no author remains. A different overlap returns `text_overlap_unpreserved` and the caller's exact
685
+ ledger, including the text merge's alternatives for review, and does not mint a synthetic
686
+ act. Different Will `keep` regions require conflict review even when one copy was
644
687
  unchanged. `review.required` names an unresolved result; a UI must not auto-accept
645
688
  it. Rapier offers carried-copy imports from Compare through its existing review,
646
689
  checks the live document again at commit, and installs the proven ledger before
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Container admission and a single-frame carrier, not a pixel/LZW decoder.
3
+ // Original bytes remain the asset. Native ImageDecoder owns frame composition.
4
+ export function inspectGIF(bytes) {
5
+ const fail = message => {throw Object.assign(new Error(message), {code: 'GIF_ADMISSION'});};
6
+ if (!(bytes instanceof Uint8Array) || bytes.length < 13 || bytes.length > 16 * 1024 * 1024 ||
7
+ !/^GIF8[79]a$/.test(String.fromCharCode(...bytes.subarray(0, 6)))) fail('Invalid GIF header or byte budget');
8
+ const u16 = at => bytes[at] + bytes[at + 1] * 256;
9
+ const width = u16(6), height = u16(8);
10
+ if (!width || !height || width > 16384 || height > 16384 || width * height > 24000000) fail('GIF dimensions exceed the image budget');
11
+ let at = 13, frames = 0, control = null, first = null, duration = 0;
12
+ const need = n => {if (at + n > bytes.length) fail('Truncated GIF');};
13
+ if (bytes[10] & 128) at += 3 * (2 << (bytes[10] & 7));
14
+ need(0); const prefix = at;
15
+ const blocks = () => {for (;;) {need(1); const n = bytes[at++]; need(n); at += n; if (!n) break;}};
16
+ while (at < bytes.length) {
17
+ const start = at, code = bytes[at++];
18
+ if (code === 0x3b) {
19
+ if (!first) fail('GIF contains no image');
20
+ const still = new Uint8Array(prefix + (first.control?.length || 0) + first.image.length + 1);
21
+ still.set(bytes.subarray(0, prefix)); let offset = prefix;
22
+ if (first.control) {still.set(first.control, offset); offset += first.control.length;}
23
+ still.set(first.image, offset); still[still.length - 1] = 0x3b;
24
+ return {type: 'image/gif', width, height, displayWidth: width, displayHeight: height, orientation: 1,
25
+ frames, animated: frames > 1, duration, first: still};
26
+ }
27
+ if (code === 0x21) {
28
+ need(1); const label = bytes[at++];
29
+ if (label === 0xf9) {
30
+ need(6); if (bytes[at] !== 4 || bytes[at + 5] !== 0 || (bytes[at + 1] >> 2 & 7) > 3) fail('Invalid GIF frame control');
31
+ control = bytes.subarray(start, at + 6); at += 6;
32
+ } else {blocks(); if (label === 1) fail('GIF plain-text rendering is unsupported');}
33
+ continue;
34
+ }
35
+ if (code !== 0x2c) fail('Invalid GIF record');
36
+ need(9);
37
+ const x = u16(at), y = u16(at + 2), w = u16(at + 4), h = u16(at + 6), packed = bytes[at + 8];
38
+ if (!w || !h || x + w > width || y + h > height) fail('Invalid GIF frame bounds');
39
+ at += 9; if (packed & 128) at += 3 * (2 << (packed & 7));
40
+ need(1); if (bytes[at] < 2 || bytes[at] > 8) fail('Invalid GIF code size');
41
+ at++; blocks(); frames++;
42
+ // A delay of 0 or 1 centisecond shows for 100 ms, as the browser draws it (images/playback.mjs times frames the same).
43
+ const delay = control ? control[4] + control[5] * 256 : 0;
44
+ duration += delay > 1 ? delay * 10 : 100;
45
+ if (frames > 240 || width * height * frames > 96000000 || duration > 120000) fail('GIF animation exceeds the frame, work or duration budget');
46
+ first ||= {control, image: bytes.subarray(start, at)}; control = null;
47
+ }
48
+ fail('GIF trailer is missing');
49
+ }
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Shared, self-contained playback for the host and its opaque HTML frames.
3
+ // No source/history authority; at most one decoder and one returned frame per object.
4
+ export function createMediaPlayback(win, inspectGIF) {
5
+ const active = new Map(), encode = bytes => bytes.toBase64();
6
+ // Base64 decodes in 1 MiB slices with a task yield between them, so a large animation never holds the main thread.
7
+ const bytes = async url => {
8
+ const text = url.slice(url.indexOf(',') + 1), slice = 1 << 20;
9
+ const out = new Uint8Array(text.length / 4 * 3 - (text.endsWith('==') ? 2 : text.endsWith('=') ? 1 : 0));
10
+ if (out.length > 16 * 1024 * 1024) throw new Error('Animation exceeds the byte budget');
11
+ for (let i = 0, at = 0; i < text.length; i += slice) {
12
+ if (i) await new Promise(resolve => win.setTimeout(resolve, 0));
13
+ at += out.subarray(at).setFromBase64(text.slice(i, i + slice)).written;
14
+ }
15
+ return out;
16
+ };
17
+ function raster(url, canvas, changed = () => {}, dimensions = null) {
18
+ // setFromBase64 refuses any character outside base64 while decoding.
19
+ if (url.length > 24 * 1024 * 1024 || !/^data:image\/(gif|jxl);base64,/i.test(url) || (url.length - url.indexOf(',') - 1) % 4) throw new Error('Unsupported animation source');
20
+ const type = url.slice(5, url.indexOf(';')).toLowerCase();
21
+ let data = null, info = null, size = null, decoder = null, timer = 0, closed = false, paused = true, ticket = 0, next = 0, loops = 0, duration = 0, single = false, held = false;
22
+ const state = {animated: true, status: 'loading', first: '', frames: 0};
23
+ const admitted = (async () => {
24
+ const buffer = await bytes(url); info = type === 'image/gif' ? inspectGIF(buffer) : null;
25
+ size = info || dimensions;
26
+ if (!size || !Number.isInteger(size.width) || !Number.isInteger(size.height) || size.width < 1 || size.height < 1 ||
27
+ size.width > 16384 || size.height > 16384 || size.width * size.height > 24000000) throw new Error('Animation requires bounded inspected dimensions');
28
+ data = buffer; state.animated = info?.animated ?? true; state.frames = info?.frames || 0;
29
+ if (info) state.first = 'data:image/gif;base64,' + encode(info.first);
30
+ if (!closed) changed(state);
31
+ })();
32
+ admitted.catch(error => {if (!closed) {state.status = 'admission-failed'; state.error = String(error.message).slice(0, 160); changed(state);}});
33
+ // A decoder closes only after its in-flight decode settles; Chrome 155's JPEG XL decoder fails when closed mid-decode.
34
+ // Its budget place goes with it: a poster or a finished loop holds no decoder.
35
+ let inflight = null;
36
+ const closeDecoder = () => {ticket++; win.clearTimeout(timer); timer = 0; active.delete(canvas); const old = decoder; decoder = null; if (old) inflight ? inflight.then(() => old.close()) : old.close();};
37
+ const settle = async promise => {
38
+ let timeout;
39
+ try {return await Promise.race([promise, new Promise((_, reject) => {timeout = win.setTimeout(() => reject(new Error('Animation decode timeout')), 5000);})]);}
40
+ finally {win.clearTimeout(timeout);}
41
+ };
42
+ async function step() {
43
+ if (closed || paused) return;
44
+ const stamp = ticket;
45
+ try {await admitted;} catch {return;}
46
+ try {
47
+ if (!win.ImageDecoder || !await win.ImageDecoder.isTypeSupported(type)) {
48
+ state.status = 'playback-unavailable'; paused = true; changed(state); return;
49
+ }
50
+ if (closed || paused || stamp !== ticket) return;
51
+ if (!decoder) {
52
+ if ([...active].reduce((sum, [other, pixels]) => sum + (other === canvas ? 0 : pixels), 0) + size.width * size.height > 24000000 || !active.has(canvas) && active.size >= 8) {
53
+ state.status = 'playback-budget'; paused = true; changed(state); return;
54
+ }
55
+ active.set(canvas, size.width * size.height);
56
+ decoder = new win.ImageDecoder({data, type, preferAnimation: true});
57
+ const current = decoder; await settle(current.tracks.ready);
58
+ if (closed || paused || stamp !== ticket || decoder !== current) return;
59
+ const track = current.tracks.selectedTrack;
60
+ if (!track || track.frameCount < 1 || track.frameCount > 240 || info && track.frameCount !== info.frames) throw new Error('Animation frame budget or container mismatch');
61
+ state.frames = track.frameCount; state.animated = track.animated;
62
+ }
63
+ const current = decoder, decoding = current.decode({frameIndex: next, completeFramesOnly: true});
64
+ const settled = inflight = decoding.then(() => {}, () => {}).then(() => {if (inflight === settled) inflight = null;});
65
+ decoding.then(result => {if (closed || stamp !== ticket) result.image.close();}, () => {});
66
+ const result = await settle(decoding);
67
+ const frame = result.image;
68
+ try {
69
+ if (closed || paused || stamp !== ticket || decoder !== current) return;
70
+ const w = frame.displayWidth, h = frame.displayHeight;
71
+ if (!result.complete || w !== size.width || h !== size.height || w * h * state.frames > 96000000) throw new Error('Decoded animation exceeds the image budget');
72
+ if (canvas.width !== w) canvas.width = w;
73
+ if (canvas.height !== h) canvas.height = h;
74
+ const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, w, h); ctx.drawImage(frame, 0, 0);
75
+ if (!state.first && next === 0) state.first = canvas.toDataURL('image/png');
76
+ // A frame of 10 ms or less shows for 100 ms, as the browser draws an <img> (images/gif.mjs counts the same).
77
+ const delay = frame.duration > 10000 ? Math.max(20, frame.duration / 1000) : 100;
78
+ duration += delay; if (duration > 120000) throw new Error('Animation exceeds the duration budget');
79
+ state.status = 'ready'; state.frame = next; changed(state);
80
+ if (single) {paused = true; closeDecoder(); return;}
81
+ next++;
82
+ if (next === state.frames) {
83
+ next = 0; duration = 0; loops++;
84
+ if (!state.animated || Number.isFinite(current.tracks.selectedTrack.repetitionCount) && loops > current.tracks.selectedTrack.repetitionCount) {paused = true; closeDecoder(); return;}
85
+ }
86
+ timer = win.setTimeout(step, delay);
87
+ } finally {frame.close();}
88
+ } catch (error) {
89
+ if (closed || stamp !== ticket) return;
90
+ state.status = 'decode-failed'; state.error = String(error.message).slice(0, 160); paused = true; closeDecoder(); changed(state);
91
+ }
92
+ }
93
+ return {state, play() {if (!closed && paused && !['decode-failed', 'admission-failed'].includes(state.status)) {paused = false; void step();}},
94
+ // A suspension that arrives during the one-frame decode (an offscreen host's first visibility report) waits for it.
95
+ async first() {if (!state.first && !closed) {single = true; next = 0; paused = false; try {await step();} finally {single = false; paused = true; closeDecoder(); if (held) {held = false; this.suspend();}}} return state.first;},
96
+ // Pause keeps the decoder and its frame position; only suspension and removal release it.
97
+ pause() {paused = true; ticket++; win.clearTimeout(timer); timer = 0;},
98
+ suspend() {if (single) {held = true; return;} paused = true; closeDecoder(); canvas.width = canvas.height = 0; state.status = 'suspended'; changed(state);},
99
+ close() {closed = true; paused = true; closeDecoder(); canvas.width = canvas.height = 0;},
100
+ get paused() {return paused;}};
101
+ }
102
+ return {raster, inspectGIF, encode, source: '(' + createMediaPlayback.toString() + ')(window, ' + inspectGIF.toString() + ')'};
103
+ }
@@ -1,7 +1,7 @@
1
1
 
2
2
  # Wrap around a silhouette
3
3
 
4
- This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a heading -- so the words below keep their own separate life.
4
+ This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a table -- so the words below keep their own separate life.
5
5
 
6
6
  ![A silhouette picture][diamond] <!--md-layout:v1 width=35% wrap=around x=20%-->
7
7
 
@@ -1,7 +1,7 @@
1
1
 
2
2
  # Wrap box
3
3
 
4
- This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a heading -- so the words below keep their own separate life.
4
+ This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a table -- so the words below keep their own separate life.
5
5
 
6
6
  ![A tight-box picture][opaque] <!--md-layout:v1 width=35% wrap=box x=75%-->
7
7
 
@@ -1,7 +1,7 @@
1
1
 
2
2
  # Behind
3
3
 
4
- This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a heading -- so the words below keep their own separate life.
4
+ This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a table -- so the words below keep their own separate life.
5
5
 
6
6
  ![A behind picture][opaque] <!--md-layout:v1 width=35% wrap=behind x=50%-->
7
7
 
@@ -1,7 +1,7 @@
1
1
 
2
2
  # Front
3
3
 
4
- This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a heading -- so the words below keep their own separate life.
4
+ This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a table -- so the words below keep their own separate life.
5
5
 
6
6
  ![A front picture][opaque] <!--md-layout:v1 width=35% wrap=front x=50%-->
7
7
 
@@ -1,7 +1,7 @@
1
1
 
2
2
  # Rotate a raster
3
3
 
4
- This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a heading -- so the words below keep their own separate life.
4
+ This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a table -- so the words below keep their own separate life.
5
5
 
6
6
  ![A turned raster picture][opaque] <!--md-layout:v1 width=35% wrap=box x=75% rotate=25deg-->
7
7
 
@@ -1,7 +1,7 @@
1
1
 
2
2
  # A drawing with its own rotation
3
3
 
4
- This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a heading -- so the words below keep their own separate life.
4
+ This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a table -- so the words below keep their own separate life.
5
5
 
6
6
  ![A drawing already turned in its own bytes][rotated] <!--md-layout:v1 width=35% wrap=around x=75%-->
7
7
 
@@ -60,40 +60,34 @@
60
60
  "bottom": 153.98
61
61
  },
62
62
  {
63
- "left": 0,
64
- "top": 154,
65
- "right": 61.91,
66
- "bottom": 184.8
67
- },
68
- {
69
- "left": 115.08,
63
+ "left": 103.98,
70
64
  "top": 154,
71
- "right": 353.98,
65
+ "right": 354,
72
66
  "bottom": 184.8
73
67
  },
74
68
  {
75
69
  "left": 0,
76
70
  "top": 184.8,
77
71
  "right": 354,
78
- "bottom": 215.59
72
+ "bottom": 215.6
79
73
  },
80
74
  {
81
75
  "left": 0,
82
- "top": 215.59,
76
+ "top": 215.6,
83
77
  "right": 354,
84
- "bottom": 246.39
78
+ "bottom": 246.4
85
79
  },
86
80
  {
87
81
  "left": 0,
88
- "top": 246.39,
82
+ "top": 246.4,
89
83
  "right": 354,
90
- "bottom": 277.19
84
+ "bottom": 277.2
91
85
  },
92
86
  {
93
87
  "left": 0,
94
- "top": 277.19,
88
+ "top": 277.2,
95
89
  "right": 354,
96
- "bottom": 307.98
90
+ "bottom": 308
97
91
  },
98
92
  {
99
93
  "left": 0,
@@ -105,13 +99,19 @@
105
99
  "left": 0,
106
100
  "top": 338.8,
107
101
  "right": 354,
108
- "bottom": 369.59
102
+ "bottom": 369.6
103
+ },
104
+ {
105
+ "left": 0,
106
+ "top": 369.6,
107
+ "right": 354,
108
+ "bottom": 400.4
109
109
  },
110
110
  {
111
111
  "left": 0,
112
- "top": 369.59,
112
+ "top": 400.4,
113
113
  "right": 354,
114
- "bottom": 400.39
114
+ "bottom": 431.2
115
115
  }
116
116
  ],
117
117
  "glyphWidths": {
@@ -1,7 +1,7 @@
1
1
 
2
2
  # An unfilled circle's interior
3
3
 
4
- This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a heading -- so the words below keep their own separate life.
4
+ This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a table -- so the words below keep their own separate life.
5
5
 
6
6
  ![A ring drawing with an empty interior][ring] <!--md-layout:v1 width=45% wrap=around x=25%-->
7
7
 
@@ -1,7 +1,7 @@
1
1
 
2
2
  # Both-sides wrapping
3
3
 
4
- This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a heading -- so the words below keep their own separate life.
4
+ This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a table -- so the words below keep their own separate life.
5
5
 
6
6
  ![A centred picture][opaque] <!--md-layout:v1 width=22% wrap=around x=50%-->
7
7
 
@@ -1,7 +1,7 @@
1
1
 
2
2
  # The neighbour search skips another image paragraph
3
3
 
4
- This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a heading -- so the words below keep their own separate life.
4
+ This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a table -- so the words below keep their own separate life.
5
5
 
6
6
  ![The picture under test][diamond] <!--md-layout:v1 width=35% wrap=around x=20%-->
7
7
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "document": "12-heading-barrier.md",
2
+ "document": "12-rule-barrier.md",
3
3
  "measurer": {
4
4
  "font": "normal 400 17.6px/30.8px Geist, system-ui, sans-serif",
5
5
  "letterSpacing": 0,
@@ -1,11 +1,11 @@
1
1
 
2
- # The neighbour search stops at a heading
2
+ # The neighbour search stops at a rule
3
3
 
4
4
  This paragraph comes before the picture and stays the anchor whenever the block right after the picture cannot be one -- another image paragraph, or a heading -- so the words below keep their own separate life.
5
5
 
6
6
  ![The picture under test][opaque] <!--md-layout:v1 width=35% wrap=around x=75%-->
7
7
 
8
- ## A heading right after the picture
8
+ ***
9
9
 
10
10
  This paragraph comes after the picture and is its ordinary anchor whenever nothing else stands in the way, wrapping beside it line by line the way the fixture is built to show plainly.
11
11
 
@@ -5,10 +5,12 @@ standard](https://rapier.website/markdown-standard): inline (no layout comment),
5
5
  with a real alpha silhouette, `wrap=box`, `behind`, `front`, `rotate=` on a plain raster, a foreign
6
6
  SVG "drawing" already turned in its own bytes, a ring drawing whose unfilled interior takes a line of
7
7
  text, both-sides wrapping around a centred picture, the neighbour search skipping a second, unrelated
8
- picture, a heading after a picture, and RTL (Arabic) text.
8
+ picture, a rule after a picture (a barrier, so the paragraph before it anchors), and RTL (Arabic) text.
9
9
 
10
- Headings can anchor images. `12-heading-barrier.expected.json` records an earlier paragraph
11
- anchor; that historical measurement is not a normative neighbour-selection expectation.
10
+ The fixtures were captured at a 30.8 px line (`measurer.lineHeight`, 1.75 × the 17.6 px body); the reference
11
+ scale's line is now 28 px at M. The line height is an input to the comparison, so the recorded lines stay
12
+ self-consistent. In `01-inline` and `12-rule-barrier` the sentence "another image paragraph, or a heading" is
13
+ measured anchor text and stays as recorded; headings do anchor pictures.
12
14
 
13
15
  ## What each document's JSON carries
14
16
 
@@ -59,7 +61,10 @@ anchor; that historical measurement is not a normative neighbour-selection expec
59
61
 
60
62
  ## The expectations
61
63
 
62
- `<name>.expected.json` is compared against the exported page's own reflow within 1.5 px; a missing one is a failure. `profileHint` is hand-computed from how each fixture's picture bytes were built ("Generation notes" below) and is restored by hand if a file is regenerated.
64
+ `<name>.expected.json` is compared against the exported page's own reflow within 1.5 px; a missing one is a failure.
65
+ Rows 5–13 of `09-drawing-ring-interior` come from the kit's own planner with the recorded measurer: the word-deferral
66
+ rule moves "ordinary" whole into the ring's wide slot, and the exported page wraps with a native `shape-outside` float,
67
+ which exposes no per-slot line boxes to record. `profileHint` is hand-computed from how each fixture's picture bytes were built ("Generation notes" below) and is restored by hand if a file is regenerated.
63
68
 
64
69
  ## Running the comparison
65
70
 
@@ -72,31 +77,17 @@ Prints two separate tables (preservation and presentation are different claims):
72
77
 
73
78
  - **Preservation** — does each fixture's own layout comment round-trip exactly through
74
79
  `parseLayout`/`formatLayout`. No measurer, no font: either exactly right, or a real bug. 12/12.
75
- - **Presentation** — does the planned line count and each line's left/right agree with the recorded
76
- fact, within `--tolerance` pixels (default 2). This depends entirely on the measurer supplied;
80
+ - **Presentation** — does the planned line count, each flowed line's row (its top) and each line's left/right
81
+ agree with the recorded fact, within `--tolerance` pixels (default 2). A wrong row is DIFFERS. This depends entirely on the measurer supplied;
77
82
  `run.mjs`'s own default (`measurer.mjs`) replays Rapier's real Pretext trace wherever one was
78
- captured, falling back to the character-glyph sum only where none exists. Recorded result (not a current-run receipt): **3/13
79
- AGREE outright** (`04-wrap-box`, `10-both-sides`, `12-heading-barrier` — every
80
- one a case where the compared edge is pure obstacle-slot geometry, unaffected by text fidelity
81
- either way), **10 CLOSE, 0 DIFFERS**, each CLOSE row's cause named rather than left as "close
82
- enough": `01-inline`/`02-width-x-align`/`05-behind`/`06-front` have no real trace at all (see
83
- `measurements` above) so still rest on the character-sum fallback; `03-wrap-around-silhouette`,
84
- `11-neighbour-skips-image` and `13-rtl-text` run Pretext for real during export (`13-rtl-text` at
85
- 100% — every query of its own answered from the real trace) yet still carry a small, constant
86
- left-edge delta (9.64px) that is a picture-obstacle *geometry* fact, not a text one: their
87
- `profileHint` is a hand-computed, quantized alpha-band descriptor for a picture Rapier's own export
88
- never serializes an occupancy descriptor for, and that descriptor's own precision — not the
89
- measurer — is the residual gap. `07-rotate-raster` and `08-drawing-rotation` have right-edge geometry deltas of 4.48px and 5.31px, with no measurer calls. `09-drawing-ring-interior` plans the recorded 14 lines, but one row
90
- differs by a whole slot (115.08px left, 292.09px right): its recorded trace, taken before a word
91
- that must break inside itself was given the widest slot of its row, cuts "ordinary" into "ordinar"
92
- and "y" in the ring's narrow left slot, where the planner now leaves that slot empty and sets the
93
- word whole in the wide one (`layout/line-plan.mjs`). Its `expected.json` still holds the earlier cut;
94
- it is regenerated from a browser export, not by hand. Before this
95
- measurer existed (a per-character glyph-sum with no real trace at all), the same run reported 4
96
- AGREE / 8 CLOSE / 1 DIFFERS — `13-rtl-text` disagreed by a whole line (7 expected, 8 planned): real
97
- Arabic shaping (ligatures, diacritic combination) changes a run's total measured width in a way an
98
- isolated-character sum cannot reproduce, and that was enough to move one word to the next line. The
99
- real trace above fixes exactly that: `13-rtl-text` now plans the correct 7 lines.
83
+ captured, falling back to the character-glyph sum only where none exists. Result: **3/13
84
+ AGREE outright** (`04-wrap-box`, `10-both-sides`, `12-rule-barrier`, each a case where the compared edge is
85
+ pure obstacle-slot geometry), **10 CLOSE, 0 DIFFERS**. `01-inline`, `02-width-x-align`, `05-behind` and `06-front`
86
+ have no real trace (see `measurements` above) and rest on the character-sum fallback. `03-wrap-around-silhouette`,
87
+ `11-neighbour-skips-image` and `13-rtl-text` run Pretext for real yet carry a constant 9.64px left-edge delta, a
88
+ picture-obstacle geometry fact: their `profileHint` is a hand-computed, quantized alpha-band descriptor. `09` carries
89
+ 7.8px of the same kind. `07-rotate-raster` and `08-drawing-rotation` have right-edge geometry deltas of 4.48px and
90
+ 5.31px, with no measurer calls.
100
91
 
101
92
  ## Generation notes (how the pictures were built)
102
93
 
@@ -77,18 +77,22 @@ for (const expected of documents) {
77
77
  if (!plan) { rows.push({document: expected.document, verdict: 'ERROR', detail: 'flowLines returned null (no plan fits)'}); continue; }
78
78
 
79
79
  const lineCountAgrees = plan.lines.length === expected.lines.length;
80
- let maxLeftDelta = 0, maxRightDelta = 0;
80
+ let maxLeftDelta = 0, maxRightDelta = 0, maxTopDelta = 0;
81
81
  for (let i = 0; i < Math.min(plan.lines.length, expected.lines.length); i++) {
82
82
  const got = plan.lines[i], want = expected.lines[i];
83
+ // Flowed lines are line boxes; an unflowed line's recorded top is its glyphs' own.
84
+ if (expected.anchorFlowed) maxTopDelta = Math.max(maxTopDelta, Math.abs(got.y - want.top));
83
85
  // Flowed lines compare slot geometry (x+width); unflowed lines compare the measured tight extent.
84
86
  const right = expected.anchorFlowed ? got.x + got.width : got.x + measurer.measure(font, got.fragments.map(f => f.text).join(''));
85
87
  maxLeftDelta = Math.max(maxLeftDelta, Math.abs(got.x - want.left));
86
88
  maxRightDelta = Math.max(maxRightDelta, Math.abs(right - want.right));
87
89
  }
88
- const withinTolerance = lineCountAgrees && maxLeftDelta <= args.tolerance && maxRightDelta <= args.tolerance;
90
+ // A line that keeps its edges but lands on another row is a different layout.
91
+ const rowsAgree = maxTopDelta <= args.tolerance;
92
+ const withinTolerance = lineCountAgrees && rowsAgree && maxLeftDelta <= args.tolerance && maxRightDelta <= args.tolerance;
89
93
  rows.push({
90
94
  document: expected.document,
91
- verdict: withinTolerance ? 'AGREE' : lineCountAgrees ? 'CLOSE' : 'DIFFERS',
95
+ verdict: withinTolerance ? 'AGREE' : lineCountAgrees && rowsAgree ? 'CLOSE' : 'DIFFERS',
92
96
  expectedLines: expected.lines.length, gotLines: plan.lines.length,
93
97
  maxLeftDelta: Math.round(maxLeftDelta * 100) / 100, maxRightDelta: Math.round(maxRightDelta * 100) / 100,
94
98
  hits: measurer.stats.hits - before.hits, misses: measurer.stats.misses - before.misses,
@@ -109,16 +113,14 @@ console.log(`\n${agree}/${rows.length} documents AGREE within ${args.tolerance}p
109
113
  console.log(`Measurer: ${measurer.stats.hits} queries answered from Rapier's own real browser trace, ${measurer.stats.misses} fell back to a character-glyph sum (no kerning/shaping).`);
110
114
  console.log(`A row's own hits/misses can undercount its real coverage: several fixtures share one identical anchor sentence, and Pretext's own per-process`);
111
115
  console.log(`segment cache (agent/vendor/pretext/measurement.js) serves a repeated (font, segment) query straight from that cache without calling`);
112
- console.log(`measureText again at all -- real data, just not counted against the SECOND document to ask for it. Three named, separate reasons a row still`);
116
+ console.log(`measureText again at all -- real data, just not counted against the SECOND document to ask for it. Two named, separate reasons a row still`);
113
117
  console.log(`reads CLOSE rather than AGREE, none of them "close enough": (a) no real trace exists at all for 01-inline/02-width-x-align/05-behind/06-front`);
114
- console.log(`-- their picture pushes no flow obstacle, so layout/interchange.js's own prepare() never calls Pretext for this paragraph during export (see`);
118
+ console.log(`-- their picture pushes no flow obstacle, so kit/render.mjs's exported projector's prepare() never calls Pretext for this paragraph during export (see`);
115
119
  console.log(`measurer.mjs) -- the from-scratch character-sum approximation disagrees with real shaping by a bounded, named amount; (b) 03/11/13 DO run`);
116
120
  console.log(`Pretext for real (hits > 0, 13-rtl-text at 100% -- every query answered from the real trace) yet still carry a small, constant left-edge delta`);
117
121
  console.log(`(9.64px today, a constant from before this measurer existed) -- a picture-obstacle geometry fact, not a text one: these`);
118
122
  console.log(`fixtures' profileHint is a hand-computed, quantized alpha band descriptor for a raster Rapier's own export never serialized an occupancy`);
119
- console.log(`descriptor for (conformance/README.md, "profileHint"), and that descriptor's own precision -- not this lane's measurer -- is the residual gap; (c) 09 plans the recorded 14 lines but one row`);
120
- console.log(`differs by a whole slot: its recorded trace cuts a word in the ring's narrow left slot, and the planner now gives a word that must break inside itself`);
121
- console.log(`the widest slot of its row (layout/line-plan.mjs) -- the recorded lines are the earlier planner's.`);
123
+ console.log(`descriptor for (conformance/README.md, "profileHint"), and that descriptor's own precision -- not this lane's measurer -- is the residual gap; 09's 7.8px rows are the same kind of fact.`);
122
124
  if (measurer.stats.missedQueries.length) {
123
125
  console.log(`\nFirst ${Math.min(10, measurer.stats.missedQueries.length)} of ${measurer.stats.misses} missed (font, text) queries (fell back to the character sum):`);
124
126
  for (const {font, text} of measurer.stats.missedQueries.slice(0, 10)) console.log(` ${JSON.stringify(text)} (${font})`);