rapier-markdown-kit 1.1.102 → 1.2.3

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 (82) hide show
  1. package/LICENSE +5 -2
  2. package/README.md +245 -54
  3. package/dist/agent/vendor/hyphen-en-us/LICENSE +14 -0
  4. package/dist/agent/vendor/hyphen-en-us/SOURCE.json +33 -0
  5. package/dist/agent/vendor/hyphen-en-us/hyph-en-us.hyp.txt +14 -0
  6. package/dist/agent/vendor/hyphen-en-us/hyph-en-us.pat.txt +4938 -0
  7. package/dist/agent/vendor/hyphen-en-us/patterns.js +25 -0
  8. package/dist/agent/vendor/pretext/LICENSE +208 -0
  9. package/dist/agent/vendor/pretext/NOTICE +26 -13
  10. package/dist/agent/vendor/pretext/SOURCE.json +213 -45
  11. package/dist/agent/vendor/pretext/analysis.js +675 -1224
  12. package/dist/agent/vendor/pretext/entry-geometry.js +183 -0
  13. package/dist/agent/vendor/pretext/gecko-line-breaks.js +688 -0
  14. package/dist/agent/vendor/pretext/generated/engine-break-data.js +57 -0
  15. package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +80 -0
  16. package/dist/agent/vendor/pretext/graphemes.js +98 -0
  17. package/dist/agent/vendor/pretext/han-kerning.js +253 -0
  18. package/dist/agent/vendor/pretext/layout.js +352 -290
  19. package/dist/agent/vendor/pretext/line-break.js +1154 -588
  20. package/dist/agent/vendor/pretext/line-breaks.js +892 -0
  21. package/dist/agent/vendor/pretext/line-text.js +81 -39
  22. package/dist/agent/vendor/pretext/measurement.js +1105 -176
  23. package/dist/agent/vendor/pretext/prepare.js +807 -0
  24. package/dist/agent/vendor/pretext/rich-inline.js +1242 -273
  25. package/dist/agent/will.mjs +2 -0
  26. package/dist/images/gif.mjs +47 -0
  27. package/dist/images/playback.mjs +101 -0
  28. package/dist/kit/assets.mjs +5 -1
  29. package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
  30. package/dist/kit/conformance/04-wrap-box.md +1 -1
  31. package/dist/kit/conformance/05-behind.md +1 -1
  32. package/dist/kit/conformance/06-front.md +1 -1
  33. package/dist/kit/conformance/07-rotate-raster.md +1 -1
  34. package/dist/kit/conformance/08-drawing-rotation.md +1 -1
  35. package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
  36. package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
  37. package/dist/kit/conformance/10-both-sides.md +1 -1
  38. package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
  39. package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
  40. package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
  41. package/dist/kit/conformance/README.md +19 -28
  42. package/dist/kit/conformance/run.mjs +13 -11
  43. package/dist/kit/export-fonts.mjs +26 -11
  44. package/dist/kit/index.mjs +1 -0
  45. package/dist/kit/ledger/carried.mjs +17 -10
  46. package/dist/kit/ledger/data.mjs +73 -13
  47. package/dist/kit/ledger/format.mjs +176 -54
  48. package/dist/kit/ledger/hash.mjs +91 -18
  49. package/dist/kit/ledger/history.mjs +156 -116
  50. package/dist/kit/ledger/inverse-source.mjs +158 -19
  51. package/dist/kit/ledger/journal-records.mjs +292 -67
  52. package/dist/kit/ledger/merge.mjs +4 -3
  53. package/dist/kit/model.mjs +1 -1
  54. package/dist/kit/pdf-glyphs.mjs +4 -4
  55. package/dist/kit/pdf-vectors.mjs +4 -1
  56. package/dist/kit/rapier-ledger.mjs +2 -2
  57. package/dist/kit/render-ink.mjs +1 -1
  58. package/dist/kit/render-markdown.mjs +400 -148
  59. package/dist/kit/render-print.mjs +131 -55
  60. package/dist/kit/render-sanitize.mjs +30 -7
  61. package/dist/kit/render-work.mjs +99 -20
  62. package/dist/kit/render.mjs +133 -140
  63. package/dist/kit/typography.mjs +2 -0
  64. package/dist/layout/hyphenation.mjs +127 -0
  65. package/dist/layout/line-plan.mjs +88 -96
  66. package/dist/layout/model.mjs +71 -88
  67. package/dist/layout/paragraph-fit.mjs +191 -0
  68. package/dist/layout/preparation.mjs +373 -0
  69. package/dist/layout/prepared-paragraph.mjs +223 -0
  70. package/dist/layout/typography.mjs +345 -111
  71. package/dist/packages/rapier-diff/index.mjs +359 -224
  72. package/dist/spec/document-fonts.mjs +14 -5
  73. package/dist/spec/document-settings.mjs +11 -164
  74. package/dist/spec/html-blocks.mjs +618 -79
  75. package/dist/spec/md-assets.mjs +178 -36
  76. package/dist/spec/md-layout.mjs +13 -2
  77. package/dist/spec/md-marks.mjs +220 -127
  78. package/dist/spec/md-source.mjs +157 -0
  79. package/package.json +2 -1
  80. package/style.css +15 -50
  81. package/dist/agent/vendor/pretext/bidi.js +0 -151
  82. package/dist/agent/vendor/pretext/generated/bidi-data.js +0 -3831
@@ -59,6 +59,8 @@ function _rapierWillParse(text) {
59
59
  const will = {
60
60
  present: false, fault: '', faults: [], blocks: [], markers: [], regions: [], text: source,
61
61
  };
62
+ // A marker, well formed or near, opens its line with one of the four spellings hasWillMarkers looks for.
63
+ if (!hasWillMarkers(source)) return will;
62
64
  let open = null;
63
65
  for (let index = 0, start = 0; start <= source.length; index++) {
64
66
  let contentEnd = start;
@@ -0,0 +1,47 @@
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
+ duration += control ? Math.max(20, (control[4] + control[5] * 256) * 10) : 100;
43
+ if (frames > 240 || width * height * frames > 96000000 || duration > 120000) fail('GIF animation exceeds the frame, work or duration budget');
44
+ first ||= {control, image: bytes.subarray(start, at)}; control = null;
45
+ }
46
+ fail('GIF trailer is missing');
47
+ }
@@ -0,0 +1,101 @@
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
+ let inflight = null;
35
+ const closeDecoder = () => {ticket++; win.clearTimeout(timer); timer = 0; const old = decoder; decoder = null; if (old) inflight ? inflight.then(() => old.close()) : old.close();};
36
+ const settle = async promise => {
37
+ let timeout;
38
+ try {return await Promise.race([promise, new Promise((_, reject) => {timeout = win.setTimeout(() => reject(new Error('Animation decode timeout')), 5000);})]);}
39
+ finally {win.clearTimeout(timeout);}
40
+ };
41
+ async function step() {
42
+ if (closed || paused) return;
43
+ const stamp = ticket;
44
+ try {await admitted;} catch {return;}
45
+ try {
46
+ if (!win.ImageDecoder || !await win.ImageDecoder.isTypeSupported(type)) {
47
+ state.status = 'playback-unavailable'; paused = true; changed(state); return;
48
+ }
49
+ if (closed || paused || stamp !== ticket) return;
50
+ if (!decoder) {
51
+ if ([...active].reduce((sum, [other, pixels]) => sum + (other === canvas ? 0 : pixels), 0) + size.width * size.height > 24000000 || !active.has(canvas) && active.size >= 8) {
52
+ state.status = 'playback-budget'; paused = true; changed(state); return;
53
+ }
54
+ active.set(canvas, size.width * size.height);
55
+ decoder = new win.ImageDecoder({data, type, preferAnimation: true});
56
+ const current = decoder; await settle(current.tracks.ready);
57
+ if (closed || paused || stamp !== ticket || decoder !== current) return;
58
+ const track = current.tracks.selectedTrack;
59
+ if (!track || track.frameCount < 1 || track.frameCount > 240 || info && track.frameCount !== info.frames) throw new Error('Animation frame budget or container mismatch');
60
+ state.frames = track.frameCount; state.animated = track.animated;
61
+ }
62
+ const current = decoder, decoding = current.decode({frameIndex: next, completeFramesOnly: true});
63
+ const settled = inflight = decoding.then(() => {}, () => {}).then(() => {if (inflight === settled) inflight = null;});
64
+ decoding.then(result => {if (closed || stamp !== ticket) result.image.close();}, () => {});
65
+ const result = await settle(decoding);
66
+ const frame = result.image;
67
+ try {
68
+ if (closed || paused || stamp !== ticket || decoder !== current) return;
69
+ const w = frame.displayWidth, h = frame.displayHeight;
70
+ if (!result.complete || w !== size.width || h !== size.height || w * h * state.frames > 96000000) throw new Error('Decoded animation exceeds the image budget');
71
+ if (canvas.width !== w) canvas.width = w;
72
+ if (canvas.height !== h) canvas.height = h;
73
+ const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, w, h); ctx.drawImage(frame, 0, 0);
74
+ if (!state.first && next === 0) state.first = canvas.toDataURL('image/png');
75
+ const delay = Math.max(20, (frame.duration || 100000) / 1000);
76
+ duration += delay; if (duration > 120000) throw new Error('Animation exceeds the duration budget');
77
+ state.status = 'ready'; state.frame = next; changed(state);
78
+ if (single) {paused = true; closeDecoder(); return;}
79
+ next++;
80
+ if (next === state.frames) {
81
+ next = 0; duration = 0; loops++;
82
+ if (!state.animated || Number.isFinite(current.tracks.selectedTrack.repetitionCount) && loops > current.tracks.selectedTrack.repetitionCount) {paused = true; closeDecoder(); return;}
83
+ }
84
+ timer = win.setTimeout(step, delay);
85
+ } finally {frame.close();}
86
+ } catch (error) {
87
+ if (closed || stamp !== ticket) return;
88
+ state.status = 'decode-failed'; state.error = String(error.message).slice(0, 160); paused = true; closeDecoder(); changed(state);
89
+ }
90
+ }
91
+ return {state, play() {if (!closed && paused && !['decode-failed', 'admission-failed'].includes(state.status)) {paused = false; void step();}},
92
+ // A suspension that arrives during the one-frame decode (an offscreen host's first visibility report) waits for it.
93
+ 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;},
94
+ // Pause keeps the decoder and its frame position; only suspension and removal release it.
95
+ pause() {paused = true; ticket++; win.clearTimeout(timer); timer = 0;},
96
+ suspend() {if (single) {held = true; return;} paused = true; closeDecoder(); canvas.width = canvas.height = 0; active.delete(canvas); state.status = 'suspended'; changed(state);},
97
+ close() {closed = true; paused = true; closeDecoder(); canvas.width = canvas.height = 0; active.delete(canvas);},
98
+ get paused() {return paused;}};
99
+ }
100
+ return {raster, inspectGIF, encode, source: '(' + createMediaPlayback.toString() + ')(window, ' + inspectGIF.toString() + ')'};
101
+ }
@@ -1,3 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Re-export from ../spec/md-assets.mjs only: ../images/assets.mjs pulls AGPL decoders into the MIT graph (tools/check-kit.mjs).
3
- export * from '../spec/md-assets.mjs';
3
+ // The kit's names are listed: that module also exports what only Rapier's own parser consumers share.
4
+ export {IMAGE_LIMITS, configureParser, normalizeLabel, assetTitle, installMarkdownImages, markdownParser, markdownBodyOffset,
5
+ dataImage, parseAssets, documentAssets, packAssetIndex, restoreAssetIndex, projectImageDefinitions, blockwiseAssets,
6
+ imageEnvironment, referenceOccurs, mayRetireImageDefinitions, retireDeletedImageDefinitions, assetOmissions, isAssetBlock,
7
+ escapeImageAlt, serializeAsset, appendAssetText, appendAsset} from '../spec/md-assets.mjs';
@@ -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
 
@@ -19,7 +19,7 @@ function parseArgs(argv) {
19
19
  const args = parseArgs(process.argv.slice(2));
20
20
  const impl = await import(args.impl.startsWith('file://') ? args.impl : 'file://' + args.impl);
21
21
 
22
- const need = ['parseLayout', 'formatLayout', 'prepareRun', 'prepareRichInline', 'flowLines', 'pictureSlices', 'bandsProfile', 'polygonProfile', 'rasterTiltProfile', 'parseProfile', 'wrapColumnFloor'];
22
+ const need = ['parseLayout', 'formatLayout', 'createPreparationScope', 'flowLines', 'pictureSlices', 'bandsProfile', 'polygonProfile', 'rasterTiltProfile', 'parseProfile', 'wrapColumnFloor'];
23
23
  const missing = need.filter(name => typeof impl[name] !== 'function');
24
24
  if (missing.length) { console.error('run.mjs: implementation at ' + args.impl + ' is missing: ' + missing.join(', ')); process.exit(2); }
25
25
 
@@ -34,6 +34,7 @@ function buildProfile(hint) {
34
34
  const documents = loadDocuments(here);
35
35
  // Install once over merged data: Pretext memoizes its context (measurer.mjs).
36
36
  const measurer = installMeasurer(documents);
37
+ const preparation = impl.createPreparationScope();
37
38
 
38
39
  // Preservation and presentation are separate claims; preservation is exact or a bug.
39
40
  const preservationRows = documents.map(expected => {
@@ -62,9 +63,8 @@ const rows = [];
62
63
  for (const expected of documents) {
63
64
  const font = expected.measurer.font.replace(/\/[^ ]+ /, ' '); // canvas fonts take no line-height
64
65
  const before = {hits: measurer.stats.hits, misses: measurer.stats.misses};
65
- const run = impl.prepareRun(expected.anchorText, font, expected.measurer.letterSpacing || 0);
66
- if (!run) { rows.push({document: expected.document, verdict: 'ERROR', detail: 'prepareRun refused this font/text/letterSpacing'}); continue; }
67
- const flow = impl.prepareRichInline([{text: run.raw, font, letterSpacing: expected.measurer.letterSpacing || 0}]);
66
+ const flow = preparation.prepare(expected, [{text: expected.anchorText, font, letterSpacing: expected.measurer.letterSpacing || 0}]);
67
+ if (!flow) { rows.push({document: expected.document, verdict: 'ERROR', detail: 'rich preparation refused this font/text/letterSpacing'}); continue; }
68
68
  let obstacles = [];
69
69
  if (expected.picture?.rect) {
70
70
  const profile = buildProfile(expected.profileHint);
@@ -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
118
  console.log(`-- their picture pushes no flow obstacle, so layout/interchange.js's own 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})`);
@@ -157,13 +157,17 @@ function unicodeIncludes(face, character) {
157
157
  });
158
158
  }
159
159
 
160
- function writeFace(face, bytes) {
160
+ function fontSource(bytes) {
161
161
  const signature = String.fromCharCode(...bytes.subarray(0, 4));
162
162
  const format = signature === 'wOFF' ? 'woff' : signature === 'wOF2' ? 'woff2' : signature === 'OTTO' ? 'opentype' : 'truetype';
163
163
  const mime = {woff: 'woff', woff2: 'woff2', opentype: 'otf', truetype: 'ttf'}[format];
164
164
  let binary = '';
165
165
  for (let at = 0; at < bytes.length; at += 16384) binary += String.fromCharCode(...bytes.subarray(at, at + 16384));
166
- const properties = {...face.properties, src: 'url("data:font/' + mime + ';base64,' + btoa(binary) + '") format("' + format + '")'};
166
+ return 'url("data:font/' + mime + ';base64,' + btoa(binary) + '") format("' + format + '")';
167
+ }
168
+
169
+ function writeFace(face, source) {
170
+ const properties = {...face.properties, src: source};
167
171
  delete properties['unicode-range'];
168
172
  return '@font-face{' + Object.entries(properties).map(([name, value]) => name + ':' + value).join(';') + '}';
169
173
  }
@@ -291,20 +295,31 @@ export async function exportFontCss(root, {settings, css = '', fontCss = '', sub
291
295
  const custom = new Set(fontFaces(customFontCss).map(face => face.family));
292
296
  candidates = candidates.filter(face => face.family === 'geist' && !custom.has(face.family));
293
297
  }
294
- const faces = fontRuns(root, {settings, css, faces: candidates}), output = [];
298
+ const faces = fontRuns(root, {settings, css, faces: candidates}), output = [], prepared = new Map();
295
299
  let payloadBytes = 0;
296
300
  for (const face of faces) {
297
- const original = Uint8Array.from(atob(face.source.slice(face.source.indexOf(',') + 1)), character => character.charCodeAt(0));
298
301
  // Adjacent inline nodes retain shaping sequences; independent runs cannot compose together.
299
- const bytes = await subset(original, face.runs.join('\n'));
302
+ const text = face.runs.join('\n');
303
+ if (!prepared.has(face.source)) prepared.set(face.source, new Map());
304
+ const variants = prepared.get(face.source);
305
+ if (!variants.has(text)) {
306
+ const original = Uint8Array.from(atob(face.source.slice(face.source.indexOf(',') + 1)), character => character.charCodeAt(0));
307
+ const bytes = await subset(original, text);
308
+ if (portableFactory) {
309
+ if (!(bytes instanceof Uint8Array) || !bytes.length || bytes.length >= original.length || bytes.length > FACTORY_FONT_BYTES) return '';
310
+ if (typeof validateFont !== 'function' || await validateFont(Uint8Array.from(bytes), {family: 'Geist'}) !== true) return '';
311
+ } else if (bytes === null) { variants.set(text, null); continue; }
312
+ if (!(bytes instanceof Uint8Array) || !bytes.length) throw new Error('The page font subset is empty.');
313
+ // Retain the encoded boundary value, not additional original or subset byte buffers.
314
+ variants.set(text, {source: fontSource(bytes), length: bytes.length});
315
+ }
316
+ const saved = variants.get(text);
317
+ if (saved === null) continue;
300
318
  if (portableFactory) {
301
- if (!(bytes instanceof Uint8Array) || !bytes.length || bytes.length >= original.length) return '';
302
- payloadBytes += bytes.length;
319
+ payloadBytes += saved.length;
303
320
  if (payloadBytes > FACTORY_FONT_BYTES) return '';
304
- if (typeof validateFont !== 'function' || await validateFont(Uint8Array.from(bytes), {family: 'Geist'}) !== true) return '';
305
- } else if (bytes === null) continue;
306
- if (!(bytes instanceof Uint8Array) || !bytes.length) throw new Error('The page font subset is empty.');
307
- output.push(writeFace(face, bytes));
321
+ }
322
+ output.push(writeFace(face, saved.source));
308
323
  }
309
324
  const result = output.join('\n');
310
325
  return portableFactory && new TextEncoder().encode(result).length > FACTORY_FONT_CSS_BYTES ? '' : result;
@@ -2,6 +2,7 @@
2
2
  export * from './layout.mjs';
3
3
  export * from './marks.mjs';
4
4
  export * from './model.mjs';
5
+ export * from './typography.mjs';
5
6
  export * from './will.mjs';
6
7
  export * from './assets.mjs';
7
8
  export * from './render-entry.mjs';