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
@@ -0,0 +1,127 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {next, weight, exceptions, maximum} from '../agent/vendor/hyphen-en-us/patterns.js';
3
+
4
+ // An original matcher over the permitted Kuiken/Plain TeX data. Its maximum pattern
5
+ // length bounds the work per input character. Its static prefix data need no decoder,
6
+ // runtime trie or font. Prepared paragraphs own the resulting positions for their lifetime.
7
+ function englishCuts(word) {
8
+ if (!/^[A-Z]?[a-z]+$/.test(word)) return null;
9
+ if (word.length < 6) return [];
10
+ const lower = word.toLowerCase(), known = exceptions[lower];
11
+ if (Array.isArray(known)) return known.filter(at => at >= 3 && at <= word.length-3);
12
+ const source = '.'+lower+'.', weights = new Uint8Array(source.length+1);
13
+ for (let start = 0; start < source.length; start++) {
14
+ let node = 0;
15
+ for (let end = start+1; end <= source.length && end <= start+maximum; end++) {
16
+ node = next(node, source.charCodeAt(end-1));
17
+ if (node < 0) break;
18
+ let packed = weight(node);
19
+ if (typeof packed !== 'number') continue;
20
+ for (let at = start; packed; at++, packed = Math.floor(packed/16)) weights[at] = Math.max(weights[at],packed%16);
21
+ }
22
+ }
23
+ const cuts = [];
24
+ for (let at = 3; at <= word.length-3; at++) if (weights[at+1]&1) cuts.push(at);
25
+ return cuts;
26
+ }
27
+
28
+ // Ask the host's offline hyphenator for successive word cuts. A zero-width word exposes
29
+ // its first available cut; increasing the minimum prefix then exposes the next one.
30
+ // Merely reading every line at width zero misses useful cuts (e.g. justifi-ca-tion).
31
+ // Each round writes every live word first, then reads every word: no per-word layout
32
+ // loop. This supplies positions only, never reusable font metrics or a second shaper.
33
+ // Three graphemes on either side and at most one automatic split per word are deliberate
34
+ // typography policy. Hosts without a dictionary for the language naturally return no cuts.
35
+ export function paragraphHyphens(words, text, items, offsets, language, document) {
36
+ const result = new Map();
37
+ if (!language) return result;
38
+ const exactEnglish = /^(?:en|en-US)(?:-u-.*)?$/i.test(language), unique = new Map(), english = new Map(), jobs = [];
39
+ let segmenter, itemIndex = -1;
40
+ for (const word of words) {
41
+ if (word.protected) continue;
42
+ const body = text.slice(word.start, word.end);
43
+ // The exact ASCII subset avoids Unicode-regexp setup for ordinary English.
44
+ const match = /^[-!"#%&'()*,.\/:;?@\[\]\\_{}]*([A-Za-z]+)[-!"#%&'()*,.\/:;?@\[\]\\_{}]*$/.exec(body) ||
45
+ /^\p{P}*([\p{L}\p{M}]+)\p{P}*$/u.exec(body);
46
+ if (!match || /[\u00a0\u00ad\u202f\u2060]/u.test(body)) continue;
47
+ const start = word.start + body.indexOf(match[1]), end = start + match[1].length;
48
+ // The prepared source words and runs are ordered. Visit only the overlapping
49
+ // runs, retaining the exact open-boundary policy even for empty authored runs.
50
+ // A hard-break chunk can begin far into the shared paragraph run array.
51
+ if (itemIndex < 0) {
52
+ let lo = 0, hi = items.length;
53
+ while (lo < hi) {
54
+ const middle = Math.floor((lo+hi)/2);
55
+ if (offsets[middle+1] <= start) lo = middle+1; else hi = middle;
56
+ }
57
+ itemIndex = lo;
58
+ } else while (itemIndex < items.length && offsets[itemIndex+1] <= start) itemIndex++;
59
+ let disabled = false;
60
+ for (let index = itemIndex; index < items.length && offsets[index] < end; index++) {
61
+ if (items[index].hyphenate === false) {disabled = true;break;}
62
+ }
63
+ if (disabled) continue;
64
+ if (exactEnglish) {
65
+ if (!english.has(match[1])) english.set(match[1],englishCuts(match[1]));
66
+ const local = english.get(match[1]);
67
+ if (local) {result.set(word,local.map(offset => start+offset));continue;}
68
+ }
69
+ let entry = unique.get(match[1]);
70
+ if (!entry) {
71
+ if (match[1].length < 6) continue;
72
+ // Exact English needs neither ICU grapheme setup nor native DOM probing.
73
+ // Language admission belongs to preparation; this is only its fallback owner.
74
+ segmenter ||= new Intl.Segmenter(language, {granularity:'grapheme'});
75
+ const units = [...segmenter.segment(match[1])].map(part => part.index);
76
+ if (units.length < 6) continue;
77
+ units.push(match[1].length);
78
+ entry = {text:match[1], units, cuts:[], minimum:3};
79
+ unique.set(entry.text, entry);
80
+ }
81
+ jobs.push({word,start,entry});
82
+ }
83
+ if (!jobs.length) return result;
84
+ // If this host cannot expose its language opportunities, preserve native automatic
85
+ // hyphenation for the paragraph instead of projecting a weaker whole-word substitute.
86
+ if (!document?.body || !document.defaultView?.CSS.supports('hyphenate-limit-chars', '6 3 3')) return null;
87
+ const host = document.createElement('span');
88
+ host.setAttribute('aria-hidden','true'); host.inert = true;
89
+ host.style.cssText = 'all:initial!important;position:fixed!important;left:0!important;top:0!important;width:0!important;height:0!important;overflow:hidden!important;contain:strict!important;visibility:hidden!important;pointer-events:none!important;';
90
+ const shadow = host.attachShadow({mode:'closed'});
91
+ let active = [...unique.values()];
92
+ for (const entry of active) {
93
+ const element = document.createElement('div'); element.lang = language;
94
+ // Do not reset `all` here: Blink's language declaration is a UA style, and an
95
+ // inline reset would erase it. The shadow root already isolates author selectors.
96
+ element.style.cssText = 'display:block;width:0;font:16px serif;line-height:20px;hyphens:auto;hyphenate-limit-chars:6 3 3;white-space:normal;word-break:normal;overflow-wrap:normal;text-wrap:wrap;';
97
+ element.textContent = entry.text; shadow.append(element); entry.element = element;
98
+ }
99
+ document.body.append(host);
100
+ const range = document.createRange();
101
+ try {
102
+ while (active.length) {
103
+ const next = [];
104
+ for (const entry of active) {
105
+ const node = entry.element.firstChild, {units} = entry;
106
+ const top = index => {
107
+ range.setStart(node,units[index]); range.setEnd(node,units[index+1]);
108
+ // At a native discretionary boundary a character Range can also report
109
+ // the preceding line's hyphen rectangle. Its final rect owns the character.
110
+ return [...range.getClientRects()].at(-1)?.top;
111
+ };
112
+ const first = top(0), last = top(units.length-2);
113
+ if (first == null || last == null || first === last) continue;
114
+ let lo = entry.minimum, hi = units.length-1;
115
+ while (lo < hi) { const middle = lo+hi>>>1; if (top(middle) === first) lo=middle+1; else hi=middle; }
116
+ if (lo < entry.minimum || lo > units.length-4) continue;
117
+ entry.cuts.push(units[lo]); entry.minimum = lo+1;
118
+ if (entry.minimum <= units.length-4) next.push(entry);
119
+ }
120
+ // All reads above finish before any mutation for the next shared layout.
121
+ for (const entry of next) entry.element.style.hyphenateLimitChars = '6 '+entry.minimum+' 3';
122
+ active = next;
123
+ }
124
+ } finally { host.remove(); }
125
+ for (const {word,start,entry} of jobs) result.set(word, entry.cuts.map(offset => start+offset));
126
+ return result;
127
+ }
@@ -1,119 +1,96 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // A word or an address wider than its slot has to break inside itself; the browser would let it run out of the column, a
3
- // projection cannot. Pretext breaks it after the last hyphen that fits, else after the last letter that fits. A person reads an
4
- // address better broken where it has its own punctuation, so the break moves back to the last of these that fits.
5
- const naturalBreak = /[/._?&=]/, hyphen = /[-\u058a\u2010\u2012\u2013\u2014]/;
6
- let clusters;
7
- const lettersOf = new WeakMap();
8
2
 
9
- function breakAtPunctuation(flow, range, cursor, layoutNextRichInlineLineRange) {
10
- const last = range.fragments.at(-1), end = last?.end;
11
- if (!end || end.graphemeIndex === 0) return range;
12
- const prepared = flow.items[last.itemIndex]?.prepared, segment = prepared?.segments?.[end.segmentIndex];
13
- if (typeof segment !== 'string') return range;
14
- // A long address is cut once per line; its letters are counted once.
15
- let byIndex = lettersOf.get(prepared);
16
- if (!byIndex) lettersOf.set(prepared, byIndex = new Map());
17
- let letters = byIndex.get(end.segmentIndex);
18
- if (!letters) {
19
- clusters ||= new Intl.Segmenter(undefined, {granularity: 'grapheme'});
20
- byIndex.set(end.segmentIndex, letters = [...clusters.segment(segment)].map(part => part.segment));
21
- }
22
- const from = last.start.segmentIndex === end.segmentIndex ? last.start.graphemeIndex : 0;
23
- if (end.graphemeIndex > letters.length || hyphen.test(letters[end.graphemeIndex - 1]) || naturalBreak.test(letters[end.graphemeIndex - 1])) return range;
24
- let target = end.graphemeIndex - 1;
25
- while (target > from && !naturalBreak.test(letters[target - 1])) target--;
26
- if (target <= from) return range;
27
- // Each narrower limit yields the break one letter back (`balanced` walks the breaks of a line the same way).
28
- for (let limit = range.width - .05, at = end.graphemeIndex; at > target;) {
29
- const shorter = layoutNextRichInlineLineRange(flow, limit, cursor), tail = shorter?.fragments.at(-1);
30
- if (!tail || tail.itemIndex !== last.itemIndex || tail.end.segmentIndex !== end.segmentIndex || tail.end.graphemeIndex >= at) return range;
31
- if (tail.end.graphemeIndex === target) return shorter;
32
- at = tail.end.graphemeIndex;
33
- limit = shorter.width - .05;
34
- }
35
- return range;
36
- }
37
-
38
- // Pretext lets a line end wherever one element ends and the next begins. The browser allows no break there unless white space
39
- // stands between them, so a comma, a colon or a closing bracket straight after `agents.md` (a chip, a link, bold words) would
40
- // start the next line alone. The line ends one break earlier instead, and the punctuation keeps its word.
41
- const closing = /^[,.:;!?)\]}%\u2019\u201d\u00bb\u203a\u2026\u060c\u061b\u061f\u3001\u3002\uff0c\uff0e\uff1a\uff1b\uff01\uff1f\uff09\u300d\u300f]/;
3
+ const lastSource = line => line.fragments.at(-1);
4
+ const afterSource = (point, previous) => !!point && (!previous || point.itemIndex > previous.itemIndex ||
5
+ point.itemIndex === previous.itemIndex && point.sourceEnd > previous.sourceEnd);
42
6
 
43
- function stranding(flow, range) {
44
- const {itemIndex, segmentIndex, graphemeIndex} = range.end;
45
- if (segmentIndex !== 0 || graphemeIndex !== 0) return false;
46
- for (let index = itemIndex; index < flow.items.length; index++) {
47
- const item = flow.items[index];
48
- if (!item?.establishesLine) continue;
49
- return !item.breakBefore && closing.test(item.prepared?.segments?.[0] ?? '');
50
- }
51
- return false;
7
+ // A chip may continue on later rows but does not cross a picture within the same row.
8
+ // Containment uses the original item and materialized source interval, never engine cursors.
9
+ function insideChip(items, line) {
10
+ const last = lastSource(line), item = items?.[last?.itemIndex];
11
+ return item?.extraWidth > 0 && typeof item.text === 'string' &&
12
+ /[^ \t\r\n\f]/.test(item.text.slice(last.sourceEnd));
52
13
  }
53
14
 
54
- function keepWithWord(flow, range, cursor, layoutNextRichInlineLineRange) {
55
- if (!stranding(flow, range)) return range;
56
- for (let limit = range.width - .05, tries = 64; tries > 0; tries--) {
57
- const shorter = layoutNextRichInlineLineRange(flow, limit, cursor);
58
- if (!shorter || shorter.width > limit + .05) return range;
59
- if (!stranding(flow, shorter)) return shorter;
15
+ function beforeChip(flow, range, line, cursor, items, next, materialize) {
16
+ let first = lastSource(line).itemIndex;
17
+ while (first > 0 && items[first - 1]?.extraWidth > 0) first--;
18
+ if (first <= line.fragments[0].itemIndex) return null;
19
+ for (let limit = range.width - .05, tries = 64; tries > 0 && limit > 0; tries--) {
20
+ const shorter = next(flow, limit, cursor);
21
+ if (!shorter || shorter.width > limit + .05) return null;
22
+ const materialized = materialize(flow, shorter), last = lastSource(materialized);
23
+ if (!last) return null;
24
+ if (last.itemIndex < first) return {range: shorter, line: materialized};
60
25
  limit = shorter.width - .05;
61
26
  }
62
- return range;
27
+ return null;
63
28
  }
64
29
 
65
- // A chip (inline code, a box each piece of it repeats) breaks at its own spaces and hyphens like any words, but never across a
66
- // picture: "npx rapier-" left of the picture and "html" right of it read as two things. When a line would end inside a chip
67
- // with another slot of the row still to come, the line ends before the chip instead and the chip goes whole to the next slot;
68
- // when nothing but the chip stands in the slot, the row's remaining slots are given up and the chip's pieces stack in this one.
69
- function insideChip(flow, range) {
70
- const {itemIndex, segmentIndex, graphemeIndex} = range.end;
71
- return (segmentIndex > 0 || graphemeIndex > 0) && flow.items[itemIndex]?.extraWidth > 0;
72
- }
73
- function beforeChip(flow, range, cursor, layoutNextRichInlineLineRange) {
74
- let first = range.end.itemIndex;
75
- while (first > 0 && flow.items[first - 1]?.extraWidth > 0) first--;
76
- if (first === 0 || first <= cursor.itemIndex && (cursor.segmentIndex > 0 || cursor.graphemeIndex > 0 || first < cursor.itemIndex)) return null;
77
- const before = shorter => shorter.end.itemIndex < first || shorter.end.itemIndex === first && shorter.end.segmentIndex === 0 && shorter.end.graphemeIndex === 0;
78
- for (let limit = range.width - .05, tries = 64; tries > 0; tries--) {
79
- const shorter = layoutNextRichInlineLineRange(flow, limit, cursor);
80
- if (!shorter || shorter.width > limit + .05) return null;
81
- if (before(shorter)) return shorter.end.itemIndex === cursor.itemIndex && shorter.end.segmentIndex === cursor.segmentIndex && shorter.end.graphemeIndex === cursor.graphemeIndex ? null : shorter;
82
- limit = shorter.width - .05;
30
+ // A lone alphabetic prefix in a narrow picture-side slot need not be split when the
31
+ // same engine continuation fits its whole word in a later slot on this row. This is
32
+ // a small placement policy, not a reconstruction of upstream legal break candidates.
33
+ // Authored no-break spaces stay with this alphabetic group. Punctuation, discretionary
34
+ // hyphens, mixed items and non-alphabetic scripts retain
35
+ // the engine's choice. All ranges and cursors remain untouched public API values.
36
+ function laterWholeWord(flow, cursor, line, items, bands, slot, next, materialize) {
37
+ if (line.fragments.length !== 1) return false;
38
+ const first = line.fragments[0], item = items?.[first.itemIndex], text = item?.text;
39
+ if (typeof text !== 'string' || item.break === 'never' || item.extraWidth > 0) return false;
40
+ const start = first.sourceStart, end = first.sourceEnd;
41
+ const letter = char => /\p{L}/u.test(char) && /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}]/u.test(char);
42
+ const join = char => char === '\u00a0' || char === '\u202f';
43
+ const member = char => letter(char) || /\p{M}/u.test(char) || join(char);
44
+ const point = at => at < text.length ? String.fromCodePoint(text.codePointAt(at)) : '';
45
+ const before = [...text.slice(Math.max(0, start - 2), start)].at(-1) || '';
46
+ if (!(end > start) || start === 0 && first.itemIndex > 0 || !letter(point(start)) ||
47
+ ![...text.slice(start, end)].every(member) || member(before) || /[\p{L}\p{M}\p{N}]/u.test(before) || !member(point(end))) return false;
48
+ let wordEnd = end, lastPoint = '';
49
+ while (wordEnd < text.length && member(point(wordEnd))) {
50
+ lastPoint = point(wordEnd); wordEnd += lastPoint.length;
83
51
  }
84
- return null;
52
+ if (join(lastPoint) || /[\p{L}\p{M}\p{N}]/u.test(point(wordEnd)) ||
53
+ wordEnd === text.length && first.itemIndex + 1 < items.length) return false;
54
+ for (let at = slot + 1; at < bands.length; at++) {
55
+ if (bands[at].width <= bands[slot].width) continue;
56
+ const range = next(flow, bands[at].width, cursor);
57
+ if (!range || !Number.isFinite(range.width) || range.width > bands[at].width + .5) continue;
58
+ const candidate = materialize(flow, range), begin = candidate.fragments[0], last = lastSource(candidate);
59
+ if (begin?.itemIndex !== first.itemIndex || begin.sourceStart !== start) continue;
60
+ if (last && (last.itemIndex > first.itemIndex ||
61
+ last.itemIndex === first.itemIndex && last.sourceEnd >= wordEnd)) return true;
62
+ }
63
+ return false;
85
64
  }
86
65
 
87
- export function linePlan(flow, width, top, obstacles, lineHeight, minWidth, direction, balance, slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange) {
88
- const lines = [], ranges = [];
89
- let cursor = {itemIndex: 0, segmentIndex: 0, graphemeIndex: 0}, y = 0, bottom = 0, rows = 0;
66
+ // Upstream owns every emitted break and emergency advance. Ordinary CSS balance
67
+ // stays native; Draw's existing token-only balance is kept independently below.
68
+ export function linePlan(flow, width, top, obstacles, lineHeight, minWidth, direction, balance,
69
+ slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange, items = null) {
70
+ if (!flow || balance) return null;
71
+ const lines = [];
72
+ let cursor, previous = null, y = 0, bottom = 0, rows = 0;
90
73
  for (let step = 0; step < 4096 && rows < 768; step++) {
91
74
  const bands = slotsForBand(width, obstacles, top + y, lineHeight, Math.min(minWidth, width));
92
75
  if (!bands) return null;
93
76
  if (direction === 'rtl') bands.reverse();
94
- const widest = Math.max(0, ...bands.map(band => band.width));
95
77
  let advanced = false;
96
78
  for (let slot = 0; slot < bands.length; slot++) {
97
79
  const band = bands[slot];
98
80
  let range = layoutNextRichInlineLineRange(flow, band.width, cursor);
99
- if (!range) return lines.length ? {lines: balance ? balanced(flow, width, balance, lines, ranges, lineHeight, layoutNextRichInlineLineRange, materializeRichInlineLineRange) : lines, height: Math.max(lineHeight, bottom)} : null;
100
- if (range.width > width + .5 || range.end.itemIndex === cursor.itemIndex &&
101
- range.end.segmentIndex === cursor.segmentIndex && range.end.graphemeIndex === cursor.graphemeIndex) return null;
81
+ if (!range) return lines.length ? {lines, height: Math.max(lineHeight, bottom)} : null;
82
+ if (!Number.isFinite(range.width) || range.width > width + .5) return null;
102
83
  if (range.width > band.width + .5) continue;
103
- range = breakAtPunctuation(flow, range, cursor, layoutNextRichInlineLineRange);
104
- // Ending a line earlier for a comma can cut a word too, so that cut is settled as well.
105
- if (!balance && stranding(flow, range)) range = breakAtPunctuation(flow, keepWithWord(flow, range, cursor, layoutNextRichInlineLineRange), cursor, layoutNextRichInlineLineRange);
106
- // A word or address that must break inside itself does it in the widest slot of the row. A narrower one, a sliver beside the
107
- // picture, waits and the widest takes it, so the head of an address, or "passage" and "s,", is never left alone in a corner.
108
- if (range.end.graphemeIndex > 0 && band.width < widest - .5) continue;
109
- let column = false;
110
- if (!balance && slot + 1 < bands.length && insideChip(flow, range)) {
111
- const whole = beforeChip(flow, range, cursor, layoutNextRichInlineLineRange);
112
- if (whole) range = whole; else column = true;
84
+ let line = materializeRichInlineLineRange(flow, range), column = false;
85
+ if (!afterSource(lastSource(line), previous)) return null;
86
+ if (laterWholeWord(flow, cursor, line, items, bands, slot, layoutNextRichInlineLineRange, materializeRichInlineLineRange)) continue;
87
+ if (slot + 1 < bands.length && insideChip(items, line)) {
88
+ const whole = beforeChip(flow, range, line, cursor, items, layoutNextRichInlineLineRange, materializeRichInlineLineRange);
89
+ if (whole) { range = whole.range; line = whole.line; } else column = true;
113
90
  }
114
- if (lines.length === 4096) return null;
115
- lines.push({...materializeRichInlineLineRange(flow, range), ...band, y}); ranges.push(range);
116
- cursor = range.end; advanced = true; bottom = y + lineHeight;
91
+ if (!afterSource(lastSource(line), previous) || lines.length === 4096) return null;
92
+ lines.push({...line, ...band, y});
93
+ previous = lastSource(line); cursor = range.end; advanced = true; bottom = y + lineHeight;
117
94
  if (column) break;
118
95
  }
119
96
  if (advanced) { y += lineHeight; rows++; continue; }
@@ -127,7 +104,22 @@ export function linePlan(flow, width, top, obstacles, lineHeight, minWidth, dire
127
104
  return null;
128
105
  }
129
106
 
130
- // `text-wrap: balance` as Chromium 154 lays it (score_line_breaker.cc, then paragraph_line_breaker.cc), over Pretext's own breaks:
107
+ // Draw's numeric tokens predate the rich engine contract and are not vendor handles. This is
108
+ // its existing whole-column greedy walk, with its existing balancing decisions below.
109
+ function measuredPlan(flow, width, lineHeight, fontSize, next, materialize) {
110
+ const lines = [], ranges = [];
111
+ let cursor = {itemIndex: 0, segmentIndex: 0, graphemeIndex: 0};
112
+ for (let row = 0; row < 768; row++) {
113
+ const range = next(flow, width, cursor);
114
+ if (!range) return lines.length ? {lines: fontSize ? balanced(flow, width, fontSize, lines, ranges, lineHeight, next, materialize) : lines} : null;
115
+ if (range.width > width + .5 || range.end.segmentIndex === cursor.segmentIndex) return null;
116
+ lines.push({...materialize(flow, range), x: 0, width, y: row * lineHeight}); ranges.push(range);
117
+ cursor = range.end;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ // Draw's existing `text-wrap: balance` approximation, over its own measured token breaks:
131
123
  // `balance` is the block's font size. Only a run of two to six greedy lines, each on the whole column, no word broken, balances.
132
124
  // The score breaker minimises (width - line)² per line with Minikin's penalties (4·width·font a line, 2·width·font a soft hyphen)
133
125
  // and 10000 on the break before the last word; it stands when there are three breaks to choose from and it keeps greedy's count.
@@ -226,7 +218,7 @@ export function measuredLines(tokens, width, lineHeight, fontSize) {
226
218
  while (end > first && tokens[end - 1].space) end--;
227
219
  return {width: range.width, end: range.end, fragments: [{...source, text: tokens.slice(first, end).map(token => token.text).join(''), sourceStart: tokens[first].start, sourceEnd: tokens[end - 1].end}]};
228
220
  };
229
- const plan = linePlan(flow, width, 0, [], lineHeight, 1, 'ltr', fontSize, () => [{x: 0, width}], next, materialize);
221
+ const plan = measuredPlan(flow, width, lineHeight, fontSize, next, materialize);
230
222
  return plan?.lines.map(line => {
231
223
  const fragment = line.fragments[0];
232
224
  return {text: fragment.text, start: fragment.sourceStart, end: fragment.sourceEnd, width: fragment.occupiedWidth};
@@ -1,116 +1,96 @@
1
1
  // SPDX-License-Identifier: MIT
2
- import {prepareWithSegments} from '../agent/vendor/pretext/layout.js';
3
2
  import {layoutNextRichInlineLineRange, materializeRichInlineLineRange} from '../agent/vendor/pretext/rich-inline.js';
4
3
  import {validLayout} from '../spec/md-layout.mjs';
5
4
  import {linePlan} from './line-plan.mjs';
5
+ import {captureFontAuthority, createPreparationScope, fontReady, invalidateRichMetrics, paragraphItems,
6
+ prepareRichParagraph, supportsRichStyle} from './preparation.mjs';
7
+ export {captureFontAuthority, createPreparationScope, invalidateRichMetrics, prepareRichParagraph, supportsRichStyle};
6
8
 
7
9
  const finite = value => typeof value === 'number' && Number.isFinite(value);
8
10
  const space = code => code === 32 || code === 9 || code === 10 || code === 13 || code === 12;
9
- let graphemeSegmenter;
10
-
11
- function normalize(raw) {
12
- let start = 0, end = raw.length;
13
- while (start < end && space(raw.charCodeAt(start))) start++;
14
- while (end > start && space(raw.charCodeAt(end - 1))) end--;
11
+ // Upstream materialization supplies ORIGINAL item UTF-16 offsets. The only local work is
12
+ // mapping the observed displayed text through whitespace/soft-hyphen spelling in that exact
13
+ // interval. Cursors and engine grapheme/segment tables remain opaque. A normalization the
14
+ // mapper cannot reproduce retains the native paragraph, never an approximate source address.
15
+ export function mapFragment(run, fragment, {whiteSpace = 'normal'} = {}) {
16
+ const raw = typeof run === 'string' ? run : run?.raw;
17
+ if (run?.break === 'never') whiteSpace = 'normal';
18
+ const start = fragment?.sourceStart, end = fragment?.sourceEnd;
19
+ if (typeof raw !== 'string' || typeof fragment?.text !== 'string' ||
20
+ !Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end > raw.length ||
21
+ whiteSpace !== 'normal' && whiteSpace !== 'pre-wrap') return null;
15
22
  const pieces = [], offsets = [start];
16
- for (let cursor = start; cursor < end;) {
17
- if (space(raw.charCodeAt(cursor))) {
18
- while (cursor < end && space(raw.charCodeAt(cursor))) cursor++;
19
- pieces.push(' ');
20
- offsets.push(cursor);
23
+ let lastSoftHyphen = -1;
24
+ for (let at = start; at < end;) {
25
+ const code = raw.charCodeAt(at);
26
+ if (code === 0xad) {
27
+ lastSoftHyphen = at;
28
+ offsets[offsets.length - 1] = ++at;
29
+ } else if (whiteSpace === 'normal' && space(code)) {
30
+ do { at++; } while (at < end && space(raw.charCodeAt(at)));
31
+ pieces.push(' '); offsets.push(at);
32
+ } else if (whiteSpace === 'pre-wrap' && (code === 10 || code === 13 || code === 12)) {
33
+ at += code === 13 && raw.charCodeAt(at + 1) === 10 && at + 1 < end ? 2 : 1;
34
+ offsets[offsets.length - 1] = at;
21
35
  } else {
22
- const from = cursor;
23
- while (cursor < end && !space(raw.charCodeAt(cursor))) offsets.push(++cursor);
24
- pieces.push(raw.slice(from, cursor));
36
+ pieces.push(raw[at]); offsets.push(++at);
25
37
  }
26
38
  }
27
- return {text: pieces.join(''), offsets, trimStart: start, trimEnd: end};
28
- }
29
-
30
- export function prepareRun(raw, font, letterSpacing = 0, measured = null) {
31
- if (typeof raw !== 'string' || typeof font !== 'string' || !font.trim() || !finite(letterSpacing)) return null;
32
- const normalized = normalize(raw);
33
- // A paragraph's rich-inline plan has already measured this run. Map those same segments to source
34
- // instead of measuring and segmenting it again; standalone callers still prepare their own run.
35
- const prepared = measured || prepareWithSegments(normalized.text, font, {letterSpacing});
36
- if (!Array.isArray(prepared.segments) || prepared.segments.join('') !== normalized.text) return null;
37
- const segmentOffsets = [0];
38
- for (const segment of prepared.segments) segmentOffsets.push(segmentOffsets.at(-1) + segment.length);
39
- return {raw, font, letterSpacing, ...normalized, prepared, segmentOffsets, graphemes: new Map()};
40
- }
41
-
42
- function normalizedOffset(run, cursor) {
43
- if (!run?.prepared || !cursor || !Number.isInteger(cursor.segmentIndex) || !Number.isInteger(cursor.graphemeIndex)) return null;
44
- const {segmentIndex, graphemeIndex} = cursor;
45
- const segments = run.prepared.segments;
46
- if (segmentIndex < 0 || segmentIndex > segments.length || graphemeIndex < 0) return null;
47
- if (segmentIndex === segments.length) return graphemeIndex === 0 ? run.text.length : null;
48
- let offsets = run.graphemes.get(segmentIndex);
49
- if (!offsets) {
50
- graphemeSegmenter ||= new Intl.Segmenter(undefined, {granularity: 'grapheme'});
51
- offsets = [0];
52
- for (const part of graphemeSegmenter.segment(segments[segmentIndex])) offsets.push(part.index + part.segment.length);
53
- run.graphemes.set(segmentIndex, offsets);
54
- }
55
- return graphemeIndex < offsets.length ? run.segmentOffsets[segmentIndex] + offsets[graphemeIndex] : null;
56
- }
57
-
58
- export function cursorOffset(run, cursor) {
59
- const offset = normalizedOffset(run, cursor);
60
- return offset === null ? null : run.offsets[offset];
61
- }
62
-
63
- export function mapFragment(run, fragment) {
64
- if (typeof fragment?.text !== 'string') return null;
65
- const from = normalizedOffset(run, fragment.start), to = normalizedOffset(run, fragment.end);
66
- if (from === null || to === null || to < from) return null;
67
- const source = run.text.slice(from, to);
68
- const plain = source.replaceAll('\u00ad', '');
69
- const hyphen = fragment.text === plain + '-' && source.endsWith('\u00ad') &&
70
- fragment.end.graphemeIndex === 0 && fragment.end.segmentIndex < run.prepared.segments.length;
71
- if (fragment.text !== plain && !hyphen) return null;
72
- const offsets = [run.offsets[from]];
73
- for (let index = from; index < to; index++) {
74
- if (run.text.charCodeAt(index) === 0xad) {
75
- if (hyphen && index === to - 1) {
76
- offsets.push(run.offsets[index + 1]);
77
- } else offsets[offsets.length - 1] = run.offsets[index + 1];
78
- } else {
79
- offsets.push(run.offsets[index + 1]);
80
- }
39
+ let text = pieces.join('');
40
+ if (lastSoftHyphen === end - 1 && fragment.text === text + '-') {
41
+ // Only the engine's actual materialized discretionary cut supplies this glyph.
42
+ offsets[offsets.length - 1] = lastSoftHyphen;
43
+ offsets.push(end); text += '-';
81
44
  }
82
- return {text: fragment.text, offsets, start: run.offsets[from], end: run.offsets[to]};
45
+ return text === fragment.text ? {text, offsets, start, end} : null;
83
46
  }
84
47
 
85
48
  // The line a picture is measured in (`lines`, spec/md-layout.mjs): the anchor's line box, its type's cap height, and where that
86
49
  // cap height stands in the first line box (the half-leading model: the baseline sits half the leading below the box's top plus
87
50
  // the ascent). Read from the font as the page lays it out, so a text size or a line spacing moves the picture with the words.
88
- export function createLineMeasurer() {
89
- const fontMetrics = new Map(), measurers = new WeakMap();
51
+ export function createLineMeasurer(authority = null) {
52
+ const measurers = new WeakMap();
90
53
  const finite = value => typeof value === 'number' && Number.isFinite(value);
91
54
  return function lineMetrics(element) {
92
55
  const view = element?.ownerDocument?.defaultView;
93
56
  if (!view) return null;
94
57
  const computed = view.getComputedStyle(element), size = parseFloat(computed.fontSize) || 16;
95
58
  const font = computed.fontStyle + ' ' + computed.fontWeight + ' ' + size + 'px ' + computed.fontFamily;
96
- let metrics = fontMetrics.get(font);
59
+ const document = element.ownerDocument, epoch = authority?.captureFontAuthority(document);
60
+ // The factory is also serialized on its own by the offline typography script. Without
61
+ // an injected authority it stores only the canvas, and measures afresh in its loaded font.
62
+ if (authority) { if (!authority.fontReady(document, font, 'H')) return null; }
63
+ else {
64
+ try {
65
+ if (!document.fonts || document.fonts.status === 'loading' || document.fonts.check(font, 'H') !== true ||
66
+ [...document.fonts].some(face => face.status === 'error')) return null;
67
+ } catch (_) { return null; }
68
+ }
69
+ let state = measurers.get(document);
70
+ if (!state || state.epoch !== epoch) {
71
+ state = {epoch, context: state?.context || document.createElement('canvas').getContext('2d'), metrics: authority ? new Map() : null};
72
+ measurers.set(document, state);
73
+ }
74
+ let metrics = state.metrics?.get(font);
97
75
  if (!metrics) {
98
- let context = measurers.get(element.ownerDocument);
99
- if (!context) measurers.set(element.ownerDocument, context = element.ownerDocument.createElement('canvas').getContext('2d'));
76
+ const context = state.context;
100
77
  if (!context) return null;
101
78
  context.font = font;
102
79
  const capital = context.measureText('H');
103
80
  metrics = {ascent: capital.fontBoundingBoxAscent, descent: capital.fontBoundingBoxDescent, cap: capital.actualBoundingBoxAscent};
104
81
  if (![metrics.ascent, metrics.descent, metrics.cap].every(finite) || !(metrics.cap > 0)) return null;
105
- // A face still loading measures as its fallback: keep the answer only once the face is in.
106
- if (element.ownerDocument.fonts?.check?.(font) !== false && element.ownerDocument.fonts?.status !== 'loading') fontMetrics.set(font, metrics);
82
+ // Only bounded metrics of this document's currently loaded faces are retained.
83
+ if (state.metrics) {
84
+ if (state.metrics.size === 64) state.metrics.delete(state.metrics.keys().next().value);
85
+ state.metrics.set(font, metrics);
86
+ }
107
87
  }
108
88
  const line = parseFloat(computed.lineHeight) || metrics.ascent + metrics.descent;
109
89
  return {line, cap: metrics.cap, capTop: (line - metrics.ascent - metrics.descent) / 2 + metrics.ascent - metrics.cap};
110
90
  };
111
91
  }
112
92
 
113
- export const lineMetrics = createLineMeasurer();
93
+ export const lineMetrics = createLineMeasurer({captureFontAuthority, fontReady});
114
94
 
115
95
  // `lines` tall: from the first line's cap height to the Nth line's baseline, N - 1 lines and one cap.
116
96
  export function linesHeight(lines, metrics) {
@@ -271,13 +251,11 @@ export function bandsProfile(bands) {
271
251
  export function serializeProfile(profile, count = shapeGrid) {
272
252
  if (!profile) return '';
273
253
  const bands = [];
274
- let any = false;
275
254
  for (let index = 0; index < count; index++) {
276
255
  const t = (index + .5) / count;
277
256
  let runs = profile.runsAt ? profile.runsAt(t) : null;
278
257
  if (!runs) { const span = profile.spanAt ? profile.spanAt(t) : null; runs = span ? [span] : []; }
279
258
  const clean = (runs || []).map(([a, b]) => [Math.max(0, Math.min(1, +a)), Math.max(0, Math.min(1, +b))]).filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b) && b > a);
280
- if (clean.length) any = true;
281
259
  bands.push(clean.map(([a, b]) => {
282
260
  let left = Math.round(a * 1e4), right = Math.round(b * 1e4);
283
261
  // A narrow occupied run must not round to an invalid zero-width descriptor.
@@ -285,13 +263,13 @@ export function serializeProfile(profile, count = shapeGrid) {
285
263
  return (left / 1e4) + '-' + (right / 1e4);
286
264
  }).join(','));
287
265
  }
288
- return any ? bands.join('|') : '';
266
+ return bands.join('|');
289
267
  }
290
268
 
291
269
  export function parseProfile(text) {
292
- if (typeof text !== 'string' || !text) return null;
270
+ if (typeof text !== 'string' || !text || text.length > 262144) return null;
293
271
  const bands = text.split('|').map(band => band ? band.split(',').map(run => run.split('-').map(Number)) : []);
294
- if (bands.length < 2 || bands.some(band => band.some(run => run.length !== 2 || !run.every(Number.isFinite) || run[0] < 0 || run[1] > 1 || run[1] <= run[0]))) return null;
272
+ if (bands.length < 2 || bands.length > 192 || bands.some(band => band.length > 192 || band.some(run => run.length !== 2 || !run.every(Number.isFinite) || run[0] < 0 || run[1] > 1 || run[1] <= run[0]))) return null;
295
273
  return bandsProfile(bands);
296
274
  }
297
275
 
@@ -329,9 +307,13 @@ export function pictureSlices(profile, x, y, width, height, scale = 1) {
329
307
  return span ? [span] : [];
330
308
  }).filter(run => Number.isFinite(run[0]) && Number.isFinite(run[1]) && run[1] > run[0])
331
309
  .sort((a, b) => a[0] - b[0]);
332
- // Words wrap the outer contour: a band reserves from its leftmost ink to its rightmost, so no word lands
333
- // inside a hollow outline or between two strokes of one picture.
334
- const merged = runs.length ? [[runs[0][0], Math.max(...runs.map(run => run[1]))]] : [];
310
+ // Preserve transparent holes and gaps; only overlapping runs share an obstacle.
311
+ const merged = [];
312
+ for (const run of runs) {
313
+ const tail = merged.at(-1);
314
+ if (tail && run[0] <= tail[1]) tail[1] = Math.max(tail[1], run[1]);
315
+ else merged.push([...run]);
316
+ }
335
317
  const current = new Map();
336
318
  for (const [left, right] of merged) {
337
319
  const key = left + ':' + right;
@@ -384,11 +366,12 @@ export function slotsForBand(width, obstacles, top, height, minWidth = 40) {
384
366
  // layout. Otherwise its lines flow around the outline. A short heading can fit beside the outline in a slot narrower
385
367
  // than minWidth, so start-aligned, unbalanced words need the same contact check.
386
368
  export function flowLines(flow, width, top, obstacles, lineHeight, minWidth, direction = 'ltr', balance = 0, align = 'start') {
387
- const plan = linePlan(flow, width, top, obstacles, lineHeight, minWidth, direction, balance, slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange);
369
+ const items = paragraphItems(flow);
370
+ const plan = linePlan(flow, width, top, obstacles, lineHeight, minWidth, direction, balance, slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange, items);
388
371
  const side = align === 'center' ? 'center' : align === 'left' || align === 'right' ? align
389
372
  : (align === 'end') === (direction === 'rtl') ? 'left' : 'right';
390
373
  if (!plan || !obstacles?.length) return plan;
391
- const free = linePlan(flow, width, top, [], lineHeight, minWidth, direction, balance, slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange);
374
+ const free = linePlan(flow, width, top, [], lineHeight, minWidth, direction, balance, slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange, items);
392
375
  if (!free) return plan;
393
376
  const meets = free.lines.some(line => {
394
377
  const words = line.fragments.reduce((sum, fragment) => sum + (fragment.gapBefore || 0) + (fragment.occupiedWidth || 0), 0);