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.
- package/LICENSE +2 -2
- package/README.md +213 -53
- package/dist/agent/vendor/hyphen-en-us/LICENSE +14 -0
- package/dist/agent/vendor/hyphen-en-us/SOURCE.json +33 -0
- package/dist/agent/vendor/hyphen-en-us/hyph-en-us.hyp.txt +14 -0
- package/dist/agent/vendor/hyphen-en-us/hyph-en-us.pat.txt +4938 -0
- package/dist/agent/vendor/hyphen-en-us/patterns.js +25 -0
- package/dist/agent/vendor/pretext/LICENSE +208 -0
- package/dist/agent/vendor/pretext/NOTICE +26 -13
- package/dist/agent/vendor/pretext/SOURCE.json +213 -45
- package/dist/agent/vendor/pretext/analysis.js +675 -1224
- package/dist/agent/vendor/pretext/entry-geometry.js +183 -0
- package/dist/agent/vendor/pretext/gecko-line-breaks.js +688 -0
- package/dist/agent/vendor/pretext/generated/engine-break-data.js +57 -0
- package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +80 -0
- package/dist/agent/vendor/pretext/graphemes.js +98 -0
- package/dist/agent/vendor/pretext/han-kerning.js +253 -0
- package/dist/agent/vendor/pretext/layout.js +352 -290
- package/dist/agent/vendor/pretext/line-break.js +1154 -588
- package/dist/agent/vendor/pretext/line-breaks.js +892 -0
- package/dist/agent/vendor/pretext/line-text.js +81 -39
- package/dist/agent/vendor/pretext/measurement.js +1105 -176
- package/dist/agent/vendor/pretext/prepare.js +807 -0
- package/dist/agent/vendor/pretext/rich-inline.js +1242 -273
- package/dist/agent/will.mjs +2 -0
- package/dist/kit/assets.mjs +5 -1
- package/dist/kit/conformance/run.mjs +4 -4
- package/dist/kit/export-fonts.mjs +26 -11
- package/dist/kit/index.mjs +1 -0
- package/dist/kit/ledger/carried.mjs +9 -7
- package/dist/kit/ledger/data.mjs +73 -13
- package/dist/kit/ledger/format.mjs +166 -53
- package/dist/kit/ledger/hash.mjs +75 -18
- package/dist/kit/ledger/history.mjs +143 -115
- package/dist/kit/ledger/inverse-source.mjs +158 -19
- package/dist/kit/ledger/journal-records.mjs +292 -67
- package/dist/kit/model.mjs +1 -1
- package/dist/kit/pdf-glyphs.mjs +4 -4
- package/dist/kit/render-markdown.mjs +259 -122
- package/dist/kit/render-sanitize.mjs +24 -4
- package/dist/kit/render-work.mjs +99 -20
- package/dist/kit/render.mjs +101 -65
- package/dist/kit/typography.mjs +2 -0
- package/dist/layout/hyphenation.mjs +127 -0
- package/dist/layout/line-plan.mjs +88 -96
- package/dist/layout/model.mjs +61 -80
- package/dist/layout/paragraph-fit.mjs +191 -0
- package/dist/layout/preparation.mjs +373 -0
- package/dist/layout/prepared-paragraph.mjs +223 -0
- package/dist/layout/typography.mjs +345 -111
- package/dist/packages/rapier-diff/index.mjs +288 -196
- package/dist/spec/document-fonts.mjs +13 -4
- package/dist/spec/md-assets.mjs +149 -30
- package/dist/spec/md-layout.mjs +5 -2
- package/dist/spec/md-marks.mjs +218 -3
- package/dist/spec/md-source.mjs +122 -0
- package/package.json +2 -1
- package/style.css +9 -0
- package/dist/agent/vendor/pretext/bidi.js +0 -151
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
27
|
+
return null;
|
|
63
28
|
}
|
|
64
29
|
|
|
65
|
-
// A
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
100
|
-
if (range.width > width + .5
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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({...
|
|
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
|
-
//
|
|
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 =
|
|
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};
|
package/dist/layout/model.mjs
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
106
|
-
if (
|
|
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
|
|
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);
|