rapier-markdown-kit 1.1.101 → 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,191 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Original paragraph-wide optimization over immutable Pretext geometry. Four spacing
3
+ // classes and the discretionary run length are the actual transition policy, not
4
+ // buckets approximating a continuous history. The numerical solver requests exact
5
+ // missing edges; the preparation scope observes them before declaring a plan current.
6
+ // Normal spaces retain 60–180% of their natural advance. At most two consecutive
7
+ // discretionary endings are allowed, and the final line starts whole.
8
+ export function createParagraphFitter() {
9
+ const indexes = new WeakMap();
10
+ function prepare(input) {
11
+ if (!input?.segments || typeof input.text !== 'string') return null;
12
+ const segments = input.segments.map(segment => {
13
+ const prefix = [0], gaps = [0], minimum = [0];
14
+ const points = [{word: 0, cut: null, cutIndex: -1, end: segment.start, next: segment.start, kind: 'start'}];
15
+ segment.words.forEach((word, index) => {
16
+ prefix.push(prefix.at(-1) + word.width + (word.gap?.width || 0));
17
+ gaps.push(gaps.at(-1) + (word.gap?.width || 0));
18
+ // Conservative bound for interior whole words; no substring-width subtraction.
19
+ minimum.push(minimum.at(-1) + Math.max(0, word.width - word.endTrim) + Math.max(0, (word.gap?.width || 0) * .6));
20
+ word.breaks.forEach((cut, cutIndex) => points.push({word: index, cut, cutIndex, end: cut.offset, next: cut.offset, kind: cut.kind}));
21
+ points.push({word: index + 1, cut: null, cutIndex: -1, end: word.end, next: word.gap?.end ?? word.end,
22
+ kind: index === segment.words.length - 1 ? 'end' : 'space'});
23
+ });
24
+ if (segment.words.length) points[0].end = points[0].next = segment.words[0].start;
25
+ return {...segment, prefix, gaps, minimum, points};
26
+ });
27
+ // Numeric indexes are private and never written after this point. Their opaque
28
+ // handle keeps hot numeric arrays out of public custody without freezing them
29
+ // into a different element representation on each engine.
30
+ const handle = Object.freeze({});
31
+ indexes.set(handle, {text: input.text, segments});
32
+ return handle;
33
+ }
34
+
35
+ function* solveSteps(handle, width, {indent = 0, left = 0, right = 0} = {}, known) {
36
+ const prepared = indexes.get(handle);
37
+ if (!prepared || ![width, indent, left, right].every(Number.isFinite) || width <= 0) return null;
38
+ const lines = []; let work = 0, visited = 0, totalCost = 0;
39
+ for (let segmentIndex = 0; segmentIndex < prepared.segments.length; segmentIndex++) {
40
+ const {words, points, prefix, gaps, minimum} = prepared.segments[segmentIndex];
41
+ if (!words.length) continue;
42
+ const ceiling = width + Math.max(0, left) + Math.max(0, right) + Math.max(0, -indent);
43
+ const lastIndex = points.length - 1;
44
+ let smallest = Infinity;
45
+ for (let index = 0; index < words.length - 1; index++) smallest = Math.min(smallest, words[index].gap?.width || 0);
46
+ let natural = prefix[words.length] - prefix[0];
47
+ natural -= (words.at(-1).gap?.width || 0) + words.at(-1).endTrim;
48
+ natural += words[0].startExtra;
49
+ const opening = Math.min(Math.max(0, left), words[0].opening);
50
+ const closing = Math.min(Math.max(0, right), words.at(-1).closing);
51
+ const target = width - (segmentIndex === 0 ? indent : 0) + opening + closing;
52
+ const count = words.length - 1;
53
+ const delta = !count ? 0 : Math.max(-smallest * .4, Math.min(0, (target - natural) / count));
54
+ const overflow = natural + delta * count - target;
55
+ const cost = 1 + 100 * Math.max(0, overflow / target) ** 2;
56
+ // Every edge costs at least one and every transition penalty is nonnegative.
57
+ // This is the original start-to-final arithmetic, so a valid single edge below
58
+ // two is the exact global optimum. Keep legal cuts for the overflow exception.
59
+ if (Number.isFinite(natural) && target > 0 && cost < 2 && minimum[words.length - 1] - minimum[Math.min(words.length - 1, 1)] <= ceiling + .02 &&
60
+ !(overflow > .02 && (count || words.at(-1).breaks.length))) {
61
+ lines.push({start: points[0].next, end: points[lastIndex].end, next: points[lastIndex].next, kind: 'end', glyph: '',
62
+ wordSpacing: delta, opening, closing, firstWord: 0, lastWord: words.length - 1, segment: segmentIndex});
63
+ totalCost += cost; work++; continue;
64
+ }
65
+
66
+ // A point's predecessors are final before that point is visited. Store its
67
+ // winning cost and backpointer directly; later points cannot observe an old
68
+ // replaced state. Build source line records only for the final chosen path.
69
+ const costs = new Float64Array(points.length * 12);
70
+ const previous = new Float64Array(costs.length), spacing = new Float64Array(costs.length);
71
+ const prefixes = [], suffixes = [], reachable = new Uint16Array(points.length);
72
+ reachable[0] = 1 << 3;
73
+ // The final line rejects a discretionary start. In its last word, automatic
74
+ // points cannot lead to any later cut either. Authored soft points need a later
75
+ // ordinary cut to reset that state. Retain breaks.length for overflow policy.
76
+ let lastPlain = lastIndex - 1;
77
+ while (lastPlain > 0 && (points[lastPlain].kind === 'soft' || points[lastPlain].kind === 'auto')) lastPlain--;
78
+ for (let endIndex = 1; endIndex < points.length; endIndex++) {
79
+ const end = points[endIndex], final = end.kind === 'end', lastWord = end.cut ? end.word : end.word - 1;
80
+ const discretionary = end.kind === 'soft' || end.kind === 'auto';
81
+ if (end.cut && end.word === words.length - 1 && (end.kind === 'auto' || endIndex > lastPlain)) continue;
82
+ let smallest = Infinity, minWord = lastWord;
83
+ for (let startIndex = endIndex - 1; startIndex >= 0; startIndex--) {
84
+ if (++visited % 1024 === 0) yield null;
85
+ const start = points[startIndex];
86
+ if (start.word > lastWord) continue;
87
+ if (minimum[lastWord] - minimum[Math.min(lastWord, start.word + 1)] > ceiling + .02) break;
88
+ while (minWord > start.word) smallest = Math.min(smallest, words[--minWord].gap?.width || 0);
89
+ if (final && (start.kind === 'soft' || start.kind === 'auto')) continue;
90
+ if (start.cut && end.cut && start.word === end.word && (start.kind === 'auto' || end.kind === 'auto')) continue;
91
+ // Metric work is useful only if a prior state can actually reach this edge.
92
+ const available = reachable[startIndex] & (discretionary ? 0x6db : 0xfff);
93
+ if (!available) continue;
94
+ let natural;
95
+ if (start.cut && end.cut && start.word === end.word) {
96
+ natural = yield {segment: segmentIndex, word: start.word, from: start.cutIndex, to: end.cutIndex};
97
+ } else {
98
+ natural = prefix[end.word] - prefix[start.word];
99
+ if (end.cut) {
100
+ if (prefixes[endIndex] === undefined) {
101
+ const query = {segment: segmentIndex, word: end.word, from: -1, to: end.cutIndex};
102
+ prefixes[endIndex] = known?.(query);
103
+ if (prefixes[endIndex] === undefined) prefixes[endIndex] = yield query;
104
+ }
105
+ natural += prefixes[endIndex];
106
+ } else natural -= (words[lastWord].gap?.width || 0) + words[lastWord].endTrim;
107
+ if (start.cut) {
108
+ if (suffixes[startIndex] === undefined) {
109
+ const query = {segment: segmentIndex, word: start.word, from: start.cutIndex, to: -1};
110
+ suffixes[startIndex] = known?.(query);
111
+ if (suffixes[startIndex] === undefined) suffixes[startIndex] = yield query;
112
+ }
113
+ natural -= words[start.word].width - suffixes[startIndex];
114
+ } else natural += words[start.word].startExtra;
115
+ }
116
+ if (!Number.isFinite(natural)) continue;
117
+ const count = lastWord - start.word, gapWidth = gaps[lastWord] - gaps[start.word];
118
+ const opening = start.cut ? 0 : Math.min(Math.max(0, left), words[start.word].opening);
119
+ const closing = end.cut ? 0 : Math.min(Math.max(0, right), words[lastWord].closing);
120
+ const target = width - (segmentIndex === 0 && startIndex === 0 ? indent : 0) + opening + closing;
121
+ if (target <= 0) continue;
122
+ const remaining = target - natural;
123
+ const delta = !count ? 0 : Math.max(-smallest * .4, Math.min(final ? 0 : smallest * .8, remaining / count));
124
+ const overflow = natural + delta * count - target;
125
+ // A truly unbreakable word/atom can stand alone at its natural width. Never
126
+ // fabricate an emergency source cut or eject the surrounding paragraph.
127
+ if (overflow > .02 && (count || start.cut || end.cut || words[lastWord].breaks.length)) continue;
128
+ const residual = Math.max(0, remaining - delta * count);
129
+ const ratio = gapWidth > 0 ? delta * count / gapWidth : 0;
130
+ const fitness = final ? 1 : ratio < -.075 ? 0 : ratio <= .2 ? 1 : ratio <= .5 ? 2 : 3;
131
+ let local = 1 + 100 * Math.max(0, overflow / target) ** 2;
132
+ if (final && startIndex) local += 100 * Math.max(0, .45 - natural / target) ** 2 + (count === 0 ? 25 : 0);
133
+ else if (!final) local += (ratio < 0 ? 100 : 12.5) * ratio ** 2 + 2400 * (residual / target) ** 2;
134
+ const priorDiscretionary = start.kind === 'soft' || start.kind === 'auto';
135
+ if (discretionary) local += 12 + (priorDiscretionary ? 12 : 0);
136
+ if (final && priorDiscretionary) continue;
137
+ // The mask visits exactly the occupied slots, in the original ascending
138
+ // slot order, retaining both the objective and its deterministic ties.
139
+ for (let active = available; active; active &= active - 1) {
140
+ const slot = 31 - Math.clz32(active & -active), priorIndex = startIndex * 12 + slot;
141
+ const hyphens = discretionary ? slot % 3 + 1 : 0;
142
+ if (hyphens > 2) continue;
143
+ work++;
144
+ const cost = costs[priorIndex] + local + (!final && startIndex ? .6 * (fitness - Math.floor(slot / 3)) ** 2 : 0);
145
+ const stateIndex = fitness * 3 + hyphens;
146
+ const index = endIndex * 12 + stateIndex;
147
+ if (reachable[endIndex] & 1 << stateIndex && costs[index] <= cost) continue;
148
+ costs[index] = cost; previous[index] = priorIndex; spacing[index] = delta;
149
+ reachable[endIndex] |= 1 << stateIndex;
150
+ }
151
+ }
152
+ }
153
+ let best = -1;
154
+ for (let slot = 0; slot < 12; slot++) if (reachable[lastIndex] & 1 << slot) {
155
+ const index = lastIndex * 12 + slot;
156
+ if (best < 0 || costs[index] < costs[best]) best = index;
157
+ }
158
+ if (best < 0) return null;
159
+ totalCost += costs[best];
160
+ const chosen = [];
161
+ for (let endIndex = lastIndex; endIndex;) {
162
+ const priorIndex = previous[best], startIndex = Math.floor(priorIndex / 12);
163
+ const start = points[startIndex], end = points[endIndex], lastWord = end.cut ? end.word : end.word - 1;
164
+ chosen.push({start: start.next, end: end.end, next: end.next, kind: end.kind, glyph: end.cut?.glyph || '',
165
+ wordSpacing: spacing[best], opening: start.cut ? 0 : Math.min(Math.max(0, left), words[start.word].opening),
166
+ closing: end.cut ? 0 : Math.min(Math.max(0, right), words[lastWord].closing),
167
+ firstWord: start.word, lastWord, segment: segmentIndex});
168
+ best = priorIndex; endIndex = startIndex;
169
+ }
170
+ lines.push(...chosen.reverse());
171
+ }
172
+ return {lines, work, cost: totalCost, approximate: false};
173
+ }
174
+
175
+ function solve(prepared, width, options, observe) {
176
+ const work = solveSteps(prepared, width, options);
177
+ try {
178
+ let step = work.next();
179
+ while (!step.done) {
180
+ if (step.value && !observe) return null;
181
+ step = work.next(step.value ? observe(step.value) : undefined);
182
+ }
183
+ return step.value;
184
+ } finally { work.return(); }
185
+ }
186
+ return {prepare, solve, solveSteps};
187
+ }
188
+
189
+ const fitter = createParagraphFitter();
190
+ export const prepareParagraph = fitter.prepare;
191
+ export const fitParagraph = fitter.solve;
@@ -0,0 +1,373 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {clearCache, setLocale} from '../agent/vendor/pretext/layout.js';
3
+ import {prepareRichInline} from '../agent/vendor/pretext/rich-inline.js';
4
+ import {prepareParagraphCandidateSteps, createParagraphObserver} from './prepared-paragraph.mjs';
5
+
6
+ // One metric authority for this loaded vendor graph. Document/face/paragraph identities are
7
+ // weak; a width is never part of source preparation. A separate captured fitting operation
8
+ // completes its requested interval facts before its immutable plan is ready. A font-string
9
+ // cache must be retired when the actual face changes, even if a replacement kept its family
10
+ // and all its CSS descriptors.
11
+ const documents = new WeakMap(), faces = new WeakMap(), paragraphs = new WeakMap();
12
+ let faceId = 0, generation = 0, language;
13
+ const descriptors = ['family', 'style', 'weight', 'stretch', 'unicodeRange', 'status',
14
+ 'featureSettings', 'variationSettings', 'display', 'sizeAdjust', 'ascentOverride',
15
+ 'descentOverride', 'lineGapOverride'];
16
+ const finite = value => typeof value === 'number' && Number.isFinite(value);
17
+
18
+ function clearMetrics() { clearCache(); return ++generation; }
19
+
20
+ export function captureFontAuthority(document = globalThis.document) {
21
+ if (!document || typeof document !== 'object') return generation;
22
+ const previous = documents.get(document), declared = previous?.declared || new Set();
23
+ const entries = [];
24
+ try {
25
+ for (const face of document.fonts || []) {
26
+ if (!faces.has(face)) faces.set(face, ++faceId);
27
+ declared.add(familyName(face.family));
28
+ entries.push([faces.get(face), ...descriptors.map(name => String(face[name] ?? ''))]);
29
+ }
30
+ } catch (_) { entries.push(['unavailable']); }
31
+ const ratio = document.defaultView?.devicePixelRatio ?? 1;
32
+ const signature = JSON.stringify([document.fonts?.status ?? '', ratio, entries]);
33
+ if (previous?.signature !== signature) {
34
+ documents.set(document, {signature, declared});
35
+ clearMetrics();
36
+ }
37
+ return generation;
38
+ }
39
+
40
+ // Several hosts can observe the same FontFaceSet event. Its observed identity retires metrics
41
+ // once. A non-DOM host has no observable face set and explicitly announces its own replacement.
42
+ export function invalidateRichMetrics(document = globalThis.document) {
43
+ return document ? captureFontAuthority(document) : clearMetrics();
44
+ }
45
+
46
+ function familyName(value) {
47
+ const name = String(value).trim();
48
+ return (/^(["']).*\1$/s.test(name) ? name.slice(1, -1) : name)
49
+ .replace(/\\([\da-f]{1,6})[ \t\r\n\f]?|\\([^\r\n\f])/gi,
50
+ (_, hex, char) => hex ? String.fromCodePoint(Math.min(0x10ffff, parseInt(hex, 16)) || 0xfffd) : char)
51
+ .toLowerCase();
52
+ }
53
+
54
+ // A CSS family with no declared FontFace can intentionally resolve through its fallback list.
55
+ // For declared custom faces, a FontFaceSet.check() alone is insufficient: it can report true
56
+ // after a failed face has fallen back. Keep that failure and same-name replacement observable.
57
+ export function fontReady(document, font, text = ' ') {
58
+ if (!document) return true;
59
+ if (!document.fonts || document.fonts.status === 'loading') return false;
60
+ try {
61
+ if (document.fonts.check(font, text || ' ') !== true) return false;
62
+ const family = /(?:\d*\.)?\d+px(?:\s*\/\s*[^\s]+)?\s+(.+)$/.exec(font)?.[1];
63
+ if (!family) return false;
64
+ const names = new Set((family.match(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^,]+/g) || []).map(familyName));
65
+ const present = new Set();
66
+ for (const face of document.fonts) {
67
+ const family = familyName(face.family); present.add(family);
68
+ if (names.has(family) && face.status !== 'loaded') return false;
69
+ }
70
+ // A deleted declared face is unavailable even when check() now succeeds with a fallback.
71
+ // Its expected name belongs to this document lifetime, not an individual projection root.
72
+ for (const family of documents.get(document)?.declared || []) if (names.has(family) && !present.has(family)) return false;
73
+ return true;
74
+ } catch (_) { return false; }
75
+ }
76
+
77
+ function validLanguage(value) {
78
+ if (typeof value !== 'string') return false;
79
+ if (value === '') return true;
80
+ try { return Intl.getCanonicalLocales(value).length === 1; } catch (_) { return false; }
81
+ }
82
+
83
+ function supportedDocument(document) {
84
+ if (!document) return true; // A declared non-DOM Canvas host owns its chosen fonts/profile.
85
+ const agent = document.defaultView?.navigator?.userAgent ?? globalThis.navigator?.userAgent ?? '';
86
+ return /(?:^|\s)(?:HeadlessChrome|Chrome|Chromium)\/\d+/.test(agent) && !/\bCriOS\//.test(agent);
87
+ }
88
+
89
+ // The pinned API accepts font shorthand, letter spacing, content language and two break modes.
90
+ // Canvas supplies its default shaping. It has no public feature/variation/kerning controls or
91
+ // paragraph-direction input; these styles retain the native selectable paragraph instead.
92
+ // `nowrap` here is for an atomic item; the paragraph itself still needs normal or pre-wrap.
93
+ // The app's inherited `pretty` is its existing greedy picture-flow policy, not an implementation
94
+ // of native pretty optimization. A host's explicit request for that optimization stays native.
95
+ export function supportsRichStyle(style, contentLanguage = '') {
96
+ if (!style || !validLanguage(contentLanguage)) return false;
97
+ const oneOf = (key, values) => style[key] == null || style[key] === '' || values.includes(style[key]);
98
+ if (!oneOf('writingMode', ['horizontal-tb']) || !oneOf('direction', ['ltr']) ||
99
+ !oneOf('unicodeBidi', ['normal', 'isolate']) || !oneOf('textTransform', ['none']) ||
100
+ !oneOf('fontKerning', ['auto']) || !oneOf('fontOpticalSizing', ['auto']) ||
101
+ !oneOf('fontStretch', ['normal', '100%']) || !oneOf('fontSizeAdjust', ['none']) ||
102
+ !oneOf('textRendering', ['auto']) || !oneOf('verticalAlign', ['baseline']) ||
103
+ !oneOf('whiteSpace', ['normal', 'pre-wrap', 'nowrap']) || !oneOf('wordBreak', ['normal', 'keep-all']) ||
104
+ !oneOf('hyphens', ['manual']) || !oneOf('lineBreak', ['auto']) || !oneOf('hangingPunctuation', ['none']) ||
105
+ !oneOf('tabSize', ['8']) || !oneOf('textWrapStyle', ['auto', 'pretty'])) return false;
106
+ for (const name of ['fontFeatureSettings', 'fontVariationSettings', 'fontVariant', 'fontVariantLigatures',
107
+ 'fontVariantCaps', 'fontVariantNumeric', 'fontVariantEastAsian', 'fontVariantPosition', 'fontVariantAlternates']) {
108
+ if (!oneOf(name, ['normal'])) return false;
109
+ }
110
+ if (!oneOf('fontSynthesis', ['auto', 'weight style small-caps']) ||
111
+ !oneOf('fontSynthesisWeight', ['auto']) || !oneOf('fontSynthesisStyle', ['auto']) ||
112
+ !oneOf('fontSynthesisSmallCaps', ['auto'])) return false;
113
+ const spacing = value => value == null || value === '' || value === 'normal' || /^[-+]?(?:\d*\.)?\d+px$/.test(value);
114
+ if (!spacing(style.letterSpacing) || !spacing(style.wordSpacing)) return false;
115
+ return !(parseFloat(style.wordSpacing) || 0);
116
+ }
117
+
118
+ function optionsFor(options, document) {
119
+ const value = {whiteSpace: options?.whiteSpace ?? 'normal', wordBreak: options?.wordBreak ?? 'normal',
120
+ language: options?.language ?? document?.documentElement?.lang ?? '', policy: options?.policy ?? 'wrap',
121
+ hanging: options?.hanging === true, paragraphBreaks: options?.paragraphBreaks ? Object.freeze([...options.paragraphBreaks]) : null};
122
+ if (!['wrap', 'paragraph'].includes(value.policy)) return null;
123
+ return (value.whiteSpace === 'normal' || value.whiteSpace === 'pre-wrap') &&
124
+ (value.wordBreak === 'normal' || value.wordBreak === 'keep-all') && validLanguage(value.language) ? value : null;
125
+ }
126
+
127
+ function copyItems(items) {
128
+ if (!Array.isArray(items)) return null;
129
+ const result = [];
130
+ for (const item of items) {
131
+ if (!item || typeof item !== 'object') return null;
132
+ if (item.text === undefined) {
133
+ if (!finite(item.width) || item.width < 0) return null;
134
+ result.push(Object.freeze({width: item.width}));
135
+ } else {
136
+ if (typeof item.text !== 'string' || typeof item.font !== 'string' || !item.font.trim() ||
137
+ !finite(item.letterSpacing ?? 0) || !finite(item.extraWidth ?? 0) ||
138
+ item.break != null && item.break !== 'normal' && item.break !== 'never') return null;
139
+ result.push(Object.freeze({text: item.text, font: item.font, letterSpacing: item.letterSpacing ?? 0,
140
+ extraWidth: item.extraWidth ?? 0, break: item.break ?? 'normal', hyphenate: item.hyphenate !== false}));
141
+ }
142
+ }
143
+ return Object.freeze(result);
144
+ }
145
+
146
+ function sameItems(a, b) {
147
+ return a.length === b.length && a.every((item, index) => {
148
+ const other = b[index];
149
+ return item.text === other.text && item.font === other.font && item.width === other.width &&
150
+ item.letterSpacing === other.letterSpacing && item.extraWidth === other.extraWidth && item.break === other.break && item.hyphenate === other.hyphenate;
151
+ });
152
+ }
153
+
154
+ function finish(work) {
155
+ let step;
156
+ do { step = work.next(); } while (!step.done);
157
+ return step.value;
158
+ }
159
+
160
+ function* prepareSteps(items, options, document) {
161
+ // FontFaceSet.check depends on the font and the used character set. Check each shaped
162
+ // font once across its runs; the scope is the sole font-readiness authority.
163
+ const usedFonts = new Map();
164
+ for (const item of items) if (item.text !== undefined) usedFonts.set(item.font, (usedFonts.get(item.font) || '') + item.text);
165
+ for (const [font, text] of usedFonts) {
166
+ if (!fontReady(document, font, text)) return null;
167
+ yield;
168
+ }
169
+ try {
170
+ // Locale selects content language for this preparation only. Existing handles already own
171
+ // their measured language; switching paragraphs must not invalidate those handles.
172
+ if (language !== options.language) { setLocale(options.language); language = options.language; }
173
+ if (options.paragraphBreaks && (options.paragraphBreaks[0] !== 0 || options.paragraphBreaks.at(-1) !== items.length ||
174
+ options.paragraphBreaks.some((value, index, all) => !Number.isInteger(value) || value < 0 || value > items.length || index > 0 && value < all[index - 1]))) return null;
175
+ let handle;
176
+ if (options.policy === 'paragraph') {
177
+ const work = prepareParagraphCandidateSteps(items, {...options, document});
178
+ try {
179
+ for (;;) {
180
+ // Another consumer may have selected a different content language between
181
+ // frames. Every resumed step uses this request's original language again.
182
+ if (language !== options.language) { setLocale(options.language); language = options.language; }
183
+ const step = work.next();
184
+ if (step.done) { handle = step.value; break; }
185
+ yield;
186
+ }
187
+ } finally { work.return(); }
188
+ } else handle = prepareRichInline(items, {whiteSpace: options.whiteSpace, wordBreak: options.wordBreak});
189
+ if (!handle) return null;
190
+ paragraphs.set(handle, {items, options, generation});
191
+ return handle;
192
+ } catch (_) { return null; }
193
+ }
194
+
195
+ export function prepareRichParagraph(items, options = {}) {
196
+ const document = globalThis.document;
197
+ if (!supportedDocument(document)) return null;
198
+ captureFontAuthority(document);
199
+ const source = copyItems(items), settings = optionsFor(options, document);
200
+ return source && settings ? finish(prepareSteps(source, settings, document)) : null;
201
+ }
202
+
203
+ // This metadata is Rapier's original input snapshot, never a reconstruction of opaque vendor
204
+ // fields. It exists only while its handle does and is used for exact source containment.
205
+ export function paragraphItems(handle) { return paragraphs.get(handle)?.items ?? null; }
206
+
207
+ export function createPreparationScope(document = globalThis.document) {
208
+ let owners = new WeakMap(), tickets = new WeakMap();
209
+ const handles = new WeakMap();
210
+ const current = handle => {
211
+ if (!supportedDocument(document)) return false;
212
+ captureFontAuthority(document);
213
+ const owner = handles.get(handle);
214
+ return !!owner && owners.get(owner) === handle && paragraphs.get(handle)?.generation === generation;
215
+ };
216
+ function* steps(owner, items, options = {}) {
217
+ if (!supportedDocument(document) || !owner || typeof owner !== 'object' && typeof owner !== 'function') return null;
218
+ captureFontAuthority(document);
219
+ tickets.delete(owner);
220
+ const source = copyItems(items), settings = optionsFor(options, document);
221
+ if (!source || !settings) { owners.delete(owner); return null; }
222
+ const previous = owners.get(owner), prior = paragraphs.get(previous);
223
+ if (prior?.generation === generation && prior.options.whiteSpace === settings.whiteSpace &&
224
+ prior.options.wordBreak === settings.wordBreak && prior.options.language === settings.language &&
225
+ prior.options.policy === settings.policy && prior.options.hanging === settings.hanging &&
226
+ JSON.stringify(prior.options.paragraphBreaks) === JSON.stringify(settings.paragraphBreaks) &&
227
+ sameItems(prior.items, source)) return previous;
228
+ owners.delete(owner);
229
+ const ticket = {}, birth = generation, work = prepareSteps(source, settings, document);
230
+ tickets.set(owner, ticket);
231
+ try {
232
+ for (;;) {
233
+ // A forgotten/replaced source request or changed face cannot publish a result
234
+ // after suspension. Until completion, no ready handle is installed for its owner.
235
+ if (tickets.get(owner) !== ticket || captureFontAuthority(document) !== birth) return null;
236
+ const step = work.next();
237
+ if (step.done) {
238
+ if (captureFontAuthority(document) !== birth) return null;
239
+ const handle = step.value;
240
+ if (handle) { owners.set(owner, handle); handles.set(handle, owner); }
241
+ return handle;
242
+ }
243
+ yield;
244
+ }
245
+ } finally { work.return(); if (tickets.get(owner) === ticket) tickets.delete(owner); }
246
+ }
247
+ function* fitSteps(owner, handle, createWork) {
248
+ let ticket = null, work = null, observe = null, facts = null;
249
+ let slotFor = null, readKnown = null, staged = false, active = false;
250
+ try {
251
+ if (!supportedDocument(document) || !owner || typeof owner !== 'object' && typeof owner !== 'function') return null;
252
+ // Preparation, fitting and forgetting share one outstanding request for this owner.
253
+ // Replacing a fit keeps its immutable source geometry, but retires the old plan work.
254
+ tickets.delete(owner);
255
+ const birth = captureFontAuthority(document), source = paragraphs.get(handle);
256
+ if (owners.get(owner) !== handle || !source || source.generation !== birth ||
257
+ source.options.policy !== 'paragraph' || typeof createWork !== 'function' ||
258
+ !Number.isInteger(handle.cutCount) || handle.cutCount < 0) return null;
259
+ ticket = {};
260
+ tickets.set(owner, ticket);
261
+ facts = source.edges;
262
+ active = true;
263
+ const currentFit = () => tickets.get(owner) === ticket && owners.get(owner) === handle &&
264
+ source.generation === birth && captureFontAuthority(document) === birth;
265
+ const selectLanguage = () => {
266
+ if (language !== source.options.language) { setLocale(source.options.language); language = source.options.language; }
267
+ };
268
+ slotFor = (segment, word, from, to) => {
269
+ if (!Number.isInteger(segment) || segment < 0 || !Number.isInteger(word) || word < 0 ||
270
+ !Number.isInteger(from) || from < -1 || !Number.isInteger(to) || to < -1) return -2;
271
+ const cuts = handle.segments[segment]?.words[word]?.breaks;
272
+ if (!cuts || from >= cuts.length || to >= cuts.length || from < 0 && to < 0 || from >= 0 && to >= 0 && from >= to) return -2;
273
+ if (from >= 0 && to >= 0) return -1; // Exact interior facts remain transient.
274
+ const cut = cuts[from < 0 ? to : from];
275
+ if (!Number.isInteger(cut.slot) || cut.slot < 0 || cut.slot >= handle.cutCount) return -2;
276
+ // Slots come only from the captured geometry's final legal cuts. A request
277
+ // can select coordinates, but cannot supply a cache key or a metric buffer.
278
+ return cut.slot * 2 + (from < 0 ? 0 : 1);
279
+ };
280
+ readKnown = request => {
281
+ // This callback belongs only to active numerical work. A warm edge reads an
282
+ // exact scalar without a generator crossing, FontFaceSet scan or observation.
283
+ // Current source/font authority is checked at each actual resume and finish.
284
+ if (!active || tickets.get(owner) !== ticket || generation !== birth) return undefined;
285
+ const slot = slotFor(request?.segment, request?.word, request?.from, request?.to);
286
+ if (slot < 0 || tickets.get(owner) !== ticket || generation !== birth) return undefined;
287
+ const value = facts?.[slot];
288
+ return Number.isNaN(value) ? undefined : value;
289
+ };
290
+ selectLanguage();
291
+ work = createWork(readKnown);
292
+ createWork = null;
293
+ if (typeof work?.next !== 'function') return null;
294
+ let answer, requests = 0;
295
+ for (;;) {
296
+ if (!currentFit()) return null;
297
+ selectLanguage();
298
+ const step = work.next(answer);
299
+ answer = undefined;
300
+ if (step.done) {
301
+ // A host measurement or another synchronous caller may replace this owner too.
302
+ if (!currentFit()) return null;
303
+ const plan = step.value;
304
+ if (!plan || !Array.isArray(plan.lines)) return null;
305
+ for (const line of plan.lines) Object.freeze(line);
306
+ Object.freeze(plan.lines);
307
+ Object.freeze(plan);
308
+ if (!currentFit()) return null;
309
+ // The private buffer holds at most two exact numbers per legal cut. It
310
+ // never escapes and is never written after publication. A later fit must
311
+ // copy it before staging a miss; cancellation cannot poison this snapshot.
312
+ if (staged) source.edges = facts;
313
+ return plan;
314
+ }
315
+ if (step.value === null) { yield; continue; }
316
+ const request = step.value;
317
+ const segment = request?.segment, word = request?.word, from = request?.from, to = request?.to;
318
+ const slot = slotFor(segment, word, from, to);
319
+ if (slot === -2) return null;
320
+ if (slot >= 0) {
321
+ answer = facts?.[slot];
322
+ if (answer !== undefined && !Number.isNaN(answer)) continue;
323
+ }
324
+ selectLanguage();
325
+ // No observer exists for a plan proved from whole-word facts. Its temporary
326
+ // vendor metric objects are owned only by this operation, never by the handle.
327
+ observe ??= createParagraphObserver(source.items, handle, source.options.language);
328
+ answer = observe({segment, word, from, to});
329
+ if (!finite(answer) || answer < 0) return null;
330
+ if (slot >= 0) {
331
+ if (!staged) {
332
+ facts = facts ? facts.slice() : new Float64Array(handle.cutCount * 2).fill(NaN);
333
+ staged = true;
334
+ }
335
+ facts[slot] = answer;
336
+ }
337
+ if (++requests % 8 === 0) yield;
338
+ }
339
+ } catch (_) { return null; }
340
+ finally {
341
+ active = false;
342
+ observe = null;
343
+ facts = null;
344
+ slotFor = null;
345
+ readKnown = null;
346
+ createWork = null;
347
+ try { work?.return?.(); } catch (_) {}
348
+ work = null;
349
+ if (ticket && tickets.get(owner) === ticket) tickets.delete(owner);
350
+ }
351
+ }
352
+ return {
353
+ prepare: (owner, items, options) => finish(steps(owner, items, options)),
354
+ prepareSteps: steps,
355
+ fit: (owner, handle, createWork) => finish(fitSteps(owner, handle, createWork)),
356
+ fitSteps,
357
+ current,
358
+ invalidateFonts: () => invalidateRichMetrics(document),
359
+ forget: owner => { owners.delete(owner); tickets.delete(owner); },
360
+ // The existing document/style owner calls this when replacing or retiring its content,
361
+ // not for width reflow. Upstream caches outlive handles, so dropping a WeakMap is not enough.
362
+ retire({documentChanged = false} = {}) {
363
+ owners = new WeakMap();
364
+ tickets = new WeakMap();
365
+ // Disposing a projection must not erase a still-live peer's missing custom-family
366
+ // authority. Only the host's whole-document identity transition resets that history.
367
+ if (documentChanged && document) documents.delete(document);
368
+ const before = generation;
369
+ captureFontAuthority(document);
370
+ if (generation === before) clearMetrics();
371
+ },
372
+ };
373
+ }