rapier-markdown-kit 1.1.102 → 1.1.104

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 +2 -2
  2. package/README.md +213 -53
  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/kit/assets.mjs +5 -1
  27. package/dist/kit/conformance/run.mjs +4 -4
  28. package/dist/kit/export-fonts.mjs +26 -11
  29. package/dist/kit/index.mjs +1 -0
  30. package/dist/kit/ledger/carried.mjs +9 -7
  31. package/dist/kit/ledger/data.mjs +73 -13
  32. package/dist/kit/ledger/format.mjs +166 -53
  33. package/dist/kit/ledger/hash.mjs +75 -18
  34. package/dist/kit/ledger/history.mjs +143 -115
  35. package/dist/kit/ledger/inverse-source.mjs +158 -19
  36. package/dist/kit/ledger/journal-records.mjs +292 -67
  37. package/dist/kit/model.mjs +1 -1
  38. package/dist/kit/pdf-glyphs.mjs +4 -4
  39. package/dist/kit/render-markdown.mjs +259 -122
  40. package/dist/kit/render-sanitize.mjs +24 -4
  41. package/dist/kit/render-work.mjs +99 -20
  42. package/dist/kit/render.mjs +101 -65
  43. package/dist/kit/typography.mjs +2 -0
  44. package/dist/layout/hyphenation.mjs +127 -0
  45. package/dist/layout/line-plan.mjs +88 -96
  46. package/dist/layout/model.mjs +61 -80
  47. package/dist/layout/paragraph-fit.mjs +191 -0
  48. package/dist/layout/preparation.mjs +373 -0
  49. package/dist/layout/prepared-paragraph.mjs +223 -0
  50. package/dist/layout/typography.mjs +345 -111
  51. package/dist/packages/rapier-diff/index.mjs +288 -196
  52. package/dist/spec/document-fonts.mjs +13 -4
  53. package/dist/spec/md-assets.mjs +149 -30
  54. package/dist/spec/md-layout.mjs +5 -2
  55. package/dist/spec/md-marks.mjs +218 -3
  56. package/dist/spec/md-source.mjs +122 -0
  57. package/package.json +2 -1
  58. package/style.css +9 -0
  59. package/dist/agent/vendor/pretext/bidi.js +0 -151
  60. 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);
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 += '-';
54
44
  }
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
- }
81
- }
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) {
@@ -384,11 +364,12 @@ export function slotsForBand(width, obstacles, top, height, minWidth = 40) {
384
364
  // layout. Otherwise its lines flow around the outline. A short heading can fit beside the outline in a slot narrower
385
365
  // than minWidth, so start-aligned, unbalanced words need the same contact check.
386
366
  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);
367
+ const items = paragraphItems(flow);
368
+ const plan = linePlan(flow, width, top, obstacles, lineHeight, minWidth, direction, balance, slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange, items);
388
369
  const side = align === 'center' ? 'center' : align === 'left' || align === 'right' ? align
389
370
  : (align === 'end') === (direction === 'rtl') ? 'left' : 'right';
390
371
  if (!plan || !obstacles?.length) return plan;
391
- const free = linePlan(flow, width, top, [], lineHeight, minWidth, direction, balance, slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange);
372
+ const free = linePlan(flow, width, top, [], lineHeight, minWidth, direction, balance, slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange, items);
392
373
  if (!free) return plan;
393
374
  const meets = free.lines.some(line => {
394
375
  const words = line.fragments.reduce((sum, fragment) => sum + (fragment.gapBefore || 0) + (fragment.occupiedWidth || 0), 0);