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
|
@@ -1,291 +1,1260 @@
|
|
|
1
|
-
/*! Pretext
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
1
|
+
/*! Pretext Unreleased (bac1774c0) | MIT; BSD/Unicode/ICU notices in LICENSE | Copyright (c) 2026 Pretext contributors */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
alignToSource,
|
|
5
|
+
analyzeText,
|
|
6
|
+
isCollapsibleSpaceCode,
|
|
7
|
+
HARD_BREAK,
|
|
8
|
+
KIND_BITS,
|
|
9
|
+
OBJECT,
|
|
10
|
+
PRESERVED_SPACE,
|
|
11
|
+
RETURNABLE,
|
|
12
|
+
SOFT_HYPHEN,
|
|
13
|
+
SPACE,
|
|
14
|
+
SPACED,
|
|
15
|
+
TAB,
|
|
16
|
+
TEXT,
|
|
17
|
+
UNBROKEN,
|
|
18
|
+
ZERO_WIDTH_BREAK,
|
|
19
|
+
ZERO_WIDTH_GLUE,
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
} from './analysis.js'
|
|
23
|
+
import { getSegmentEntryWidth, } from './entry-geometry.js'
|
|
24
|
+
import { buildLineTextFromRange, buildRangeText, getGraphemeEnds, } from './line-text.js'
|
|
25
|
+
import {
|
|
26
|
+
getItemTabAdvance,
|
|
27
|
+
normalizeMaxWidth,
|
|
28
|
+
normalizePreparedLineStart,
|
|
29
|
+
stepPreparedLineGeometryFromStart,
|
|
30
|
+
walkPreparedLinesRaw,
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
} from './line-break.js'
|
|
34
|
+
import { getEngineProfile, getFontMeasurement, getPreparationLanguage, getSpaceWidth, readLetterSpacing, zeros, } from './measurement.js'
|
|
35
|
+
import { measureAnalysis, } from './prepare.js'
|
|
36
|
+
|
|
37
|
+
// Helper for rich-text inline flow under `white-space: normal` or `pre-wrap`: one paragraph's text
|
|
38
|
+
// broken across its items, as a browser lays out the text of an inline formatting context across
|
|
39
|
+
// its spans. The items' texts join into the paragraph's text, an atomic item or a box as one
|
|
40
|
+
// U+FFFC, which is analyzed once, as prepare() analyzes a text, with a segment starting wherever an
|
|
41
|
+
// item does. Each item's segments are measured in its font, and the text walkers lay the paragraph
|
|
42
|
+
// out. So:
|
|
43
|
+
// - white space collapses across item boundaries, and under pre-wrap preserved spaces hang
|
|
44
|
+
// across them, tab stops count from the line's start, and hard breaks end lines
|
|
45
|
+
// - break opportunities across item boundaries are the engine's: from the text the items join in
|
|
46
|
+
// Blink and Gecko, and in WebKit from each item's own text and the last two characters before
|
|
47
|
+
// it, under the paragraph's word-break; a run that continues across items wraps together
|
|
48
|
+
// - atomic inline boxes like pills, and boxes the app sizes and paints, such as images, are one
|
|
49
|
+
// segment each, which a line can break before and after
|
|
50
|
+
// - an item's extra horizontal chrome, such as padding and borders, is part of its width on
|
|
51
|
+
// every line the item reaches
|
|
52
|
+
// A line's fragments are its segments cut where the item changes.
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
const PRESERVED_WHITE_SPACE = 1 << PRESERVED_SPACE | 1 << TAB
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
// An object inside a line that the app sizes and paints, such as an image, a custom emoji, a formula
|
|
68
|
+
// or a badge: an atomic inline with no text, as an <img> or an empty inline-block is, which a line
|
|
69
|
+
// can break before and after. An item without text is one.
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
// How the paragraph the items make wraps, one setting for all of them, as CSS on the
|
|
76
|
+
// element that holds the spans sets it.
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
// A place in the paragraph: an item, and a segment and grapheme of that item's part of the
|
|
87
|
+
// paragraph's text, whose segments no app sees. What a line or a fragment range gives is for
|
|
88
|
+
// passing back. A materialized fragment's type has no cursors (RESEARCH.md, Decisions Log,
|
|
89
|
+
// 2026-10-10): its place in its item's text is its sourceStart and sourceEnd, and the line to
|
|
90
|
+
// pass back is the range it was materialized from.
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
// A materialized fragment as materializeRichInlineLineRange() builds it: with its range's
|
|
117
|
+
// cursors, which are off its type only (RESEARCH.md, Decisions Log, 2026-10-10).
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
const RICH_INLINE_START_CURSOR = {
|
|
177
|
+
itemIndex: 0,
|
|
178
|
+
segmentIndex: 0,
|
|
179
|
+
graphemeIndex: 0,
|
|
17
180
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
graphemeIndex: cursor.graphemeIndex
|
|
22
|
-
};
|
|
181
|
+
|
|
182
|
+
function getInternalPreparedRichInline(prepared ) {
|
|
183
|
+
return prepared
|
|
23
184
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
let breakAfterPreviousItem = false;
|
|
50
|
-
for(let index = 0; index < items.length; index++){
|
|
51
|
-
const item = items[index];
|
|
52
|
-
const letterSpacing = item.letterSpacing ?? 0;
|
|
53
|
-
let start = 0;
|
|
54
|
-
while(start < item.text.length && isCollapsibleBoundaryWhitespace(item.text.charCodeAt(start)))start++;
|
|
55
|
-
if (start === item.text.length) {
|
|
56
|
-
if (start > 0 && pendingGapWidth === null) {
|
|
57
|
-
pendingGapWidth = getCollapsedSpaceWidth(item.font, letterSpacing);
|
|
58
|
-
}
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
let end = item.text.length;
|
|
62
|
-
while(end > start && isCollapsibleBoundaryWhitespace(item.text.charCodeAt(end - 1)))end--;
|
|
63
|
-
const hasLeadingWhitespace = start > 0;
|
|
64
|
-
const hasTrailingWhitespace = end < item.text.length;
|
|
65
|
-
const trimmedText = item.text.slice(start, end);
|
|
66
|
-
const gapBefore = pendingGapWidth ?? (hasLeadingWhitespace ? getCollapsedSpaceWidth(item.font, letterSpacing) : 0);
|
|
67
|
-
const prepared = prepareWithSegments(trimmedText, item.font, letterSpacing === 0 ? undefined : {
|
|
68
|
-
letterSpacing
|
|
69
|
-
});
|
|
70
|
-
const wholeWidth = measureWholeItem(prepared);
|
|
71
|
-
const establishesLine = wholeWidth !== null || prepared.kinds.includes('zero-width-break');
|
|
72
|
-
const preparedItem = {
|
|
73
|
-
break: item.break ?? 'normal',
|
|
74
|
-
breakBefore: pendingGapWidth !== null || hasLeadingWhitespace || breakAfterPreviousItem,
|
|
75
|
-
establishesLine,
|
|
76
|
-
extraWidth: item.extraWidth ?? 0,
|
|
77
|
-
gapBefore,
|
|
78
|
-
naturalWidth: wholeWidth ?? 0,
|
|
79
|
-
prepared
|
|
80
|
-
};
|
|
81
|
-
preparedItems[index] = preparedItem;
|
|
82
|
-
if (establishesLine) breakAfterPreviousItem = prepared.kinds.at(-1) === 'zero-width-break';
|
|
83
|
-
pendingGapWidth = hasTrailingWhitespace ? getCollapsedSpaceWidth(item.font, letterSpacing) : null;
|
|
185
|
+
|
|
186
|
+
export function prepareRichInline(items , options ) {
|
|
187
|
+
const paragraphOnly = options?.rapierParagraph === true
|
|
188
|
+
const whiteSpace = options?.whiteSpace ?? 'normal'
|
|
189
|
+
const wordBreak = options?.wordBreak ?? 'normal'
|
|
190
|
+
const preserve = whiteSpace === 'pre-wrap'
|
|
191
|
+
// One language read for the paragraph's analysis and every item's measurement.
|
|
192
|
+
const profile = getEngineProfile()
|
|
193
|
+
const language = getPreparationLanguage(profile)
|
|
194
|
+
// Whether a line can end before preserved white space that starts an item: WebKit finds a soft
|
|
195
|
+
// wrap opportunity next to every white-space item (isAtSoftWrapOpportunity,
|
|
196
|
+
// InlineFormattingUtils.cpp:406-418), which is part of how it finds breaks between inline items,
|
|
197
|
+
// so the profile's scan names it.
|
|
198
|
+
const whiteSpaceItemBreaks = profile.lineBreakScan === 'webkit'
|
|
199
|
+
|
|
200
|
+
// A paragraph of one styled run that isn't atomic and has no extraWidth is the most common one,
|
|
201
|
+
// and is that run's text: its handle is the one prepareWithSegments() makes, laid out as
|
|
202
|
+
// layout()'s walkers lay it out. So is a paragraph whose other items are empty, which are
|
|
203
|
+
// dropped (below).
|
|
204
|
+
let onlyIndex = -1
|
|
205
|
+
for (let index = 0; index < items.length; index++) {
|
|
206
|
+
if (items[index] .text === '') continue
|
|
207
|
+
if (onlyIndex >= 0) {
|
|
208
|
+
onlyIndex = -1
|
|
209
|
+
break
|
|
84
210
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
let
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
211
|
+
onlyIndex = index
|
|
212
|
+
}
|
|
213
|
+
const only = onlyIndex < 0 ? undefined : items[onlyIndex]
|
|
214
|
+
if (only !== undefined && only.text !== undefined && only.break !== 'never' && (only.extraWidth ?? 0) === 0) {
|
|
215
|
+
const analysis = analyzeText(only.text, profile, whiteSpace, wordBreak, language)
|
|
216
|
+
const data = measureAnalysis(analysis, 0, analysis.flags.length, only.font, true, readLetterSpacing(only.letterSpacing, profile), profile, language, !paragraphOnly, null, paragraphOnly ? '-' : null)
|
|
217
|
+
const itemSegments = []
|
|
218
|
+
for (let index = 0; index <= items.length; index++) itemSegments.push(index <= onlyIndex ? 0 : data.segmentFlags.length)
|
|
219
|
+
return (paragraphOnly ? paragraphSegments : findWholeLine)({
|
|
220
|
+
data, onlyItem: onlyIndex, wholeWidth: null, wholeStart: 0, wholeEnd: 0, itemSegments, segmentItems: null, gapSegments: null, plainWidths: false,
|
|
221
|
+
sourceStarts: null, sourceEnds: null, sourceUnits: null, text: only.text,
|
|
222
|
+
} )
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The paragraph's text: the items' texts joined, an atomic item or a box as one U+FFFC. An atomic
|
|
226
|
+
// item's own white space is none of the paragraph's: its inline-block lays its text out as a
|
|
227
|
+
// paragraph of its own, whose lines drop white space at their start and end. Blink puts the box in
|
|
228
|
+
// the outer paragraph as one U+FFFC (inline_node.cc:408-422) and removes its own paragraph's
|
|
229
|
+
// leading and trailing spaces (inline_items_builder.cc:869-871, ExitBlock at 1622-1629); WebKit's
|
|
230
|
+
// atomic inline box is one item of the outer line (InlineItemsBuilder.cpp:1073-1074), and its own
|
|
231
|
+
// lines collapse leading white space (Line::appendText, InlineLine.cpp:348-373) and remove
|
|
232
|
+
// trailing (InlineLineBuilder.cpp:646); Gecko's text run stops at the box
|
|
233
|
+
// (BuildTextRunsScanner::ScanFrame, nsTextFrame.cpp:2248-2254), and its own lines skip leading
|
|
234
|
+
// white space (nsTextFrame.cpp:10935-10944) and trim trailing (nsBlockFrame.cpp:5844). So an
|
|
235
|
+
// atomic item of only white space is still an object, as wide as its extraWidth. An item whose
|
|
236
|
+
// text is empty, atomic or not, is dropped, with no fragment and no width: apps empty a run to
|
|
237
|
+
// hide it (RESEARCH.md, Objects Inside A Line).
|
|
238
|
+
let source = ''
|
|
239
|
+
const starts = []
|
|
240
|
+
const atomic = []
|
|
241
|
+
// Whether an item is an object or has extraWidth, so that a line of it alone isn't its text's.
|
|
242
|
+
let paddedOrObject = false
|
|
243
|
+
// How many text items have extraWidth: each may get one segment more than the analysis gives it,
|
|
244
|
+
// the start edge of its opening (below).
|
|
245
|
+
let padded = 0
|
|
246
|
+
// The letter spacing the text items that aren't atomic share, and whether two of them differ: the
|
|
247
|
+
// walkers take one for the handle, so where items differ, each segment's width holds its own.
|
|
248
|
+
let sharedSpacing = null
|
|
249
|
+
let spacingsDiffer = false
|
|
250
|
+
// Whether an item may make a line narrower: under a negative letter spacing or extraWidth.
|
|
251
|
+
let mayNarrow = false
|
|
252
|
+
for (let index = 0; index < items.length; index++) {
|
|
253
|
+
const item = items[index]
|
|
254
|
+
starts.push(source.length)
|
|
255
|
+
if (item.text === undefined) {
|
|
256
|
+
// A width that isn't finite would give lines of width NaN or Infinity, and Chrome breaks lines
|
|
257
|
+
// around a negative one otherwise than a negative extraWidth does (RESEARCH.md, Objects Inside
|
|
258
|
+
// A Line), so Pretext refuses both.
|
|
259
|
+
if (!Number.isFinite(item.width) || item.width < 0) throw new RangeError(`Item ${index} has no text, so it's a box, whose width must be a finite number of CSS px, at least 0, not ${item.width}`)
|
|
260
|
+
atomic.push(true)
|
|
261
|
+
source += ''
|
|
262
|
+
paddedOrObject = true
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
const isAtomic = item.break === 'never' && item.text !== ''
|
|
266
|
+
atomic.push(isAtomic)
|
|
267
|
+
if (isAtomic) {
|
|
268
|
+
source += ''
|
|
269
|
+
} else {
|
|
270
|
+
source += item.text
|
|
271
|
+
if (item.text !== '') {
|
|
272
|
+
const letterSpacing = readLetterSpacing(item.letterSpacing, profile)
|
|
273
|
+
if (sharedSpacing !== null && sharedSpacing !== letterSpacing) spacingsDiffer = true
|
|
274
|
+
sharedSpacing ??= letterSpacing
|
|
275
|
+
if (letterSpacing < 0) mayNarrow = true
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (isAtomic || (item.extraWidth ?? 0) !== 0) paddedOrObject = true
|
|
279
|
+
if (!isAtomic && (item.extraWidth ?? 0) !== 0) padded++
|
|
280
|
+
if ((item.extraWidth ?? 0) < 0) mayNarrow = true
|
|
281
|
+
}
|
|
282
|
+
const paragraph = { items, starts, atomic, ownSegmentBreaks: !profile.transformsSegmentBreaksAcrossItems, sourceOffsets: null }
|
|
283
|
+
const analysis = analyzeText(source, profile, whiteSpace, wordBreak, language, paragraph)
|
|
284
|
+
const offsets = paragraph.sourceOffsets
|
|
285
|
+
const count = analysis.flags.length
|
|
286
|
+
|
|
287
|
+
// The paragraph's lists, which each item's measurement adds its segments to (measureAnalysis).
|
|
288
|
+
const widths = []
|
|
289
|
+
const flags = new Uint8Array(count + padded)
|
|
290
|
+
const breakableFitAdvances = []
|
|
291
|
+
const lists = { widths, segmentFlags: flags, breakableFitAdvances }
|
|
292
|
+
const segments = []
|
|
293
|
+
const sourceStarts = []
|
|
294
|
+
const sourceEnds = []
|
|
295
|
+
let sourceUnits = null
|
|
296
|
+
const itemSegments = []
|
|
297
|
+
// What only some segments have, each made whole at the first one that does: a zero or a null for
|
|
298
|
+
// every segment the paragraph can have, `flags.length` of them, which is more than it has where
|
|
299
|
+
// a padded item gets no start edge, and the segment's value stored at its index. Chrome 154,
|
|
300
|
+
// Firefox 156 and Safari 27 prepare CJK styled paragraphs again up to 8% faster with the lists
|
|
301
|
+
// made whole than with each filled by a call and a push a segment (RESEARCH.md, Keeping Work
|
|
302
|
+
// Bounded, JavaScript Engines, under A paragraph's sparse lists made whole).
|
|
303
|
+
let entryGeometry = null
|
|
304
|
+
let lineStartProhibitions = null
|
|
305
|
+
let breakableLineStartExtras = null
|
|
306
|
+
let lineStartExtras = null
|
|
307
|
+
let lineEndTrims = null
|
|
308
|
+
let overflowLineEndTrims = null
|
|
309
|
+
let itemEndHalts = null
|
|
310
|
+
// A line returns from a soft hyphen whose hyphen doesn't fit on a handle with soft-hyphen
|
|
311
|
+
// contexts, which an item's measurement makes for text with one.
|
|
312
|
+
let discretionaryHyphenContexts = null
|
|
313
|
+
let insideExtras = null
|
|
314
|
+
let fillExtras = null
|
|
315
|
+
let openingEdges = null
|
|
316
|
+
// Each text item's hyphen width, tab stop advance and least tab advance, and whether two items
|
|
317
|
+
// differ in one, in a paragraph whose text has what reads them: a soft hyphen or, preserved, a
|
|
318
|
+
// tab (ParagraphSegmentData). Another paragraph keeps none, and its handle's own are 0.
|
|
319
|
+
const readsItemFonts = source.includes('\u00AD') || (preserve && source.includes('\t'))
|
|
320
|
+
const hyphenWidths = zeros(readsItemFonts ? items.length : 0)
|
|
321
|
+
const tabStopAdvances = zeros(readsItemFonts ? items.length : 0)
|
|
322
|
+
const minimumTabAdvances = zeros(readsItemFonts ? items.length : 0)
|
|
323
|
+
let firstTextItem = -1
|
|
324
|
+
let fontsDiffer = false
|
|
325
|
+
let simple = !analysis.hasUnbroken
|
|
326
|
+
// Whether a segment got no break before it here (below), an item's start edge or white space that
|
|
327
|
+
// goes on from the item before, in a paragraph whose analysis found none, so its other segments
|
|
328
|
+
// aren't marked as breaks to return to.
|
|
329
|
+
let marksReturnable = false
|
|
330
|
+
// Where the item at hand starts among the paragraph's segments.
|
|
331
|
+
let itemStart = 0
|
|
332
|
+
// Whether the paragraph holds an object of width 0.
|
|
333
|
+
let hasEmptyObject = false
|
|
334
|
+
|
|
335
|
+
for (let from = 0; from < count;) {
|
|
336
|
+
// The item this segment starts in, and the segments that start in it.
|
|
337
|
+
const offset = offsets[analysis.starts[from] ]
|
|
338
|
+
let index = itemSegments.length === 0 ? 0 : itemSegments.length - 1
|
|
339
|
+
while (index + 1 < starts.length && starts[index + 1] <= offset) index++
|
|
340
|
+
while (itemSegments.length <= index) itemSegments.push(widths.length)
|
|
341
|
+
const itemEnd = index + 1 < starts.length ? starts[index + 1] : source.length
|
|
342
|
+
let to = from + 1
|
|
343
|
+
while (to < count && offsets[analysis.starts[to] ] < itemEnd) to++
|
|
344
|
+
const item = items[index]
|
|
345
|
+
const previousItemStart = itemStart
|
|
346
|
+
itemStart = widths.length
|
|
347
|
+
|
|
348
|
+
if (item.text === undefined || atomic[index]) {
|
|
349
|
+
// One object: a box's width, or the width of the item's text on a line of its own, laid out
|
|
350
|
+
// in normal white space, as a chip's box with `white-space: nowrap` collapses its own, plus
|
|
351
|
+
// its extraWidth: all three browsers lay out a pre-wrap paragraph of 15px Helvetica Neue with
|
|
352
|
+
// the 12px chip ` @bob ` 6.6px narrower than the chip's text with its spaces. It is prepared
|
|
353
|
+
// without emergency breaks, as it is only laid out whole.
|
|
354
|
+
let width = 0
|
|
355
|
+
let text = ''
|
|
356
|
+
let textStart = 0
|
|
357
|
+
let textEnd = 0
|
|
358
|
+
if (item.text === undefined) {
|
|
359
|
+
width = item.width
|
|
360
|
+
} else {
|
|
361
|
+
const ownAnalysis = analyzeText(item.text, profile, 'normal', wordBreak, language)
|
|
362
|
+
const own = measureAnalysis(ownAnalysis, 0, ownAnalysis.flags.length, item.font, false, readLetterSpacing(item.letterSpacing, profile), profile, language, false, null, paragraphOnly ? '-' : null)
|
|
363
|
+
const start = { segmentIndex: 0, graphemeIndex: 0 }
|
|
364
|
+
if (normalizePreparedLineStart(own, start)) width = stepPreparedLineGeometryFromStart(own, start, Number.POSITIVE_INFINITY)
|
|
365
|
+
width += item.extraWidth ?? 0
|
|
366
|
+
// The chip's text as a line of it paints it (buildLineTextFromRange).
|
|
367
|
+
for (let i = 0; i < ownAnalysis.texts.length; i++) {
|
|
368
|
+
const kind = own.segmentFlags[i] & KIND_BITS
|
|
369
|
+
const ownText = ownAnalysis.texts[i]
|
|
370
|
+
if (kind !== SOFT_HYPHEN && kind !== HARD_BREAK && !((kind === ZERO_WIDTH_GLUE || kind === ZERO_WIDTH_BREAK) && ownText.charCodeAt(0) === 0x00AD)) text += ownText
|
|
161
371
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
372
|
+
while (isCollapsibleSpaceCode(item.text.charCodeAt(textStart))) textStart++
|
|
373
|
+
textEnd = item.text.length
|
|
374
|
+
while (textEnd > textStart && isCollapsibleSpaceCode(item.text.charCodeAt(textEnd - 1))) textEnd--
|
|
375
|
+
}
|
|
376
|
+
// Gecko places an object of width 0 otherwise than the simple walker does (walkPreparedComplexLines).
|
|
377
|
+
if (width === 0) {
|
|
378
|
+
simple = false
|
|
379
|
+
hasEmptyObject = true
|
|
380
|
+
}
|
|
381
|
+
flags[widths.length] = OBJECT
|
|
382
|
+
widths.push(width)
|
|
383
|
+
segments.push(text)
|
|
384
|
+
breakableFitAdvances.push(null)
|
|
385
|
+
sourceStarts.push(textStart)
|
|
386
|
+
sourceEnds.push(textEnd)
|
|
387
|
+
from = to
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const letterSpacing = readLetterSpacing(item.letterSpacing, profile)
|
|
392
|
+
if (to === from + 1 && (analysis.flags[from] & KIND_BITS) === SPACE && analysis.texts[from] === ' ') {
|
|
393
|
+
// An item of only collapsible white space, as between two styled words: its one space, as
|
|
394
|
+
// measureAnalysis() measures one.
|
|
395
|
+
if (letterSpacing !== 0) simple = false
|
|
396
|
+
flags[widths.length] = SPACE | (analysis.flags[from] & (UNBROKEN | RETURNABLE)) | (letterSpacing !== 0 ? SPACED : 0)
|
|
397
|
+
widths.push(getSpaceWidth(getFontMeasurement(item.font, language, letterSpacing !== 0)) + (spacingsDiffer ? letterSpacing : 0))
|
|
398
|
+
segments.push(' ')
|
|
399
|
+
breakableFitAdvances.push(null)
|
|
400
|
+
sourceStarts.push(offset - starts[index] )
|
|
401
|
+
sourceEnds.push(offset + 1 - starts[index] )
|
|
402
|
+
from = to
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
// Every line the item reaches pays its extraWidth, as its fragment on that line paints it
|
|
406
|
+
// (box-decoration-break: clone). The first of its segments whose width a line counts, after
|
|
407
|
+
// the collapsed space before its text, holds it for a line that comes into the item or starts
|
|
408
|
+
// with it, and a line that starts later in the item pays it there (ParagraphSegmentData). An
|
|
409
|
+
// item that opens with preserved white space, a hard break or a zero-width space, which a line
|
|
410
|
+
// can end with after content that fills it, gets a start edge of its own before them: an empty
|
|
411
|
+
// segment as wide as the extraWidth, which fits by the edges the engine fits where a line takes
|
|
412
|
+
// the item's opening and no more of it (getOpeningFit), and no break comes before it, as none
|
|
413
|
+
// comes before white space, a hard break or a zero-width space in the text (UAX #14 LB6, LB7),
|
|
414
|
+
// but after an object in Blink and Gecko, and before a zero-width space after an object or
|
|
415
|
+
// white space, where the browsers break.
|
|
416
|
+
const extraWidth = item.extraWidth ?? 0
|
|
417
|
+
let first = to
|
|
418
|
+
if (extraWidth !== 0) {
|
|
419
|
+
first = from
|
|
420
|
+
while (first < to && ((analysis.flags[first] & KIND_BITS) === SOFT_HYPHEN || (first === from && (analysis.flags[from] & KIND_BITS) === SPACE))) first++
|
|
421
|
+
const firstKind = first < to ? analysis.flags[first] & KIND_BITS : TEXT
|
|
422
|
+
if (firstKind === PRESERVED_SPACE || firstKind === TAB || firstKind === HARD_BREAK || (firstKind === ZERO_WIDTH_BREAK && first === from)) {
|
|
423
|
+
const at = widths.length
|
|
424
|
+
const afterObject = at > 0 && (flags[at - 1] & KIND_BITS) === OBJECT
|
|
425
|
+
// Whether the item follows preserved spaces that follow text in their own text item. Blink
|
|
426
|
+
// ends a text item at each character it makes a control item (IsControlItemCharacter,
|
|
427
|
+
// inline_items_builder.cc:177-184; AppendPreserveWhitespace, :1082-1126): a run of preserved
|
|
428
|
+
// tabs (:1098-1110), a line feed, whose forced break the spaces after it follow as a text item
|
|
429
|
+
// of their own (:1084-1097, 1023-1033), and a lone CR or FF (:1119-1125), which the analysis
|
|
430
|
+
// takes as a line feed. So spaces after a tab or a hard break, which is a line feed in the
|
|
431
|
+
// one profile that reads this, follow no text. A ZWNJ, the other such character, starts a
|
|
432
|
+
// text item and is text in it (:1112-1118), as it is here. An item of only spaces after its
|
|
433
|
+
// own start edge follows none either. Blink also ends a text item where a bidi run ends
|
|
434
|
+
// inside it (InlineItem::SetBidiLevel, inline_item.cc:207-236), which no profile resolves:
|
|
435
|
+
// the premise is that the spaces are at the level of the text before them, and its gap is
|
|
436
|
+
// spaces after right-to-left text (ENGINE_FOLLOWUPS.md, Rich-inline item edges).
|
|
437
|
+
const afterTextSpaces = at - 1 > previousItemStart && (flags[at - 1] & KIND_BITS) === PRESERVED_SPACE && (1 << (flags[at - 2] & KIND_BITS) & (1 << TAB | 1 << HARD_BREAK)) === 0 &&
|
|
438
|
+
(openingEdges === null || openingEdges[at - 2] === 0)
|
|
439
|
+
const fit = getOpeningFit(analysis.flags, from, to, extraWidth, afterTextSpaces, profile)
|
|
440
|
+
// In WebKit a break comes before white space that starts an item (whiteSpaceItemBreaks), and
|
|
441
|
+
// none before a hard break, after an object too (nextWrapOpportunity,
|
|
442
|
+
// InlineFormattingUtils.cpp:469-475).
|
|
443
|
+
const breaksBefore = at === 0 || (firstKind === ZERO_WIDTH_BREAK ? afterObject || (1 << (flags[at - 1] & KIND_BITS) & (1 << SPACE | PRESERVED_WHITE_SPACE)) !== 0
|
|
444
|
+
: whiteSpaceItemBreaks ? firstKind !== HARD_BREAK : afterObject)
|
|
445
|
+
// A line that takes the opening paints the whole edge, whatever of it the line fitted
|
|
446
|
+
// (ParagraphSegmentData, openingEdges). An opening no edge of which is fitted takes no room
|
|
447
|
+
// among the white space around it, which hangs past it. Else it is an object, whose line-end
|
|
448
|
+
// trim is what the line doesn't fit: the white space after it stays on its line, as the line
|
|
449
|
+
// keeps the opening it took.
|
|
450
|
+
flags[at] = fit === 0 ? PRESERVED_SPACE : breaksBefore ? OBJECT : OBJECT | UNBROKEN
|
|
451
|
+
widths.push(extraWidth)
|
|
452
|
+
;(openingEdges ??= zeroList(flags.length))[at] = extraWidth
|
|
453
|
+
if (!breaksBefore && !analysis.hasUnbroken) marksReturnable = true
|
|
454
|
+
// Where the engine ends a line inside the preserved spaces before a padded hard break that
|
|
455
|
+
// doesn't fit (hardBreakItemRetreat), those spaces get an advance each, as text a line can
|
|
456
|
+
// end inside has.
|
|
457
|
+
if (firstKind === HARD_BREAK && !breaksBefore && profile.hardBreakItemRetreat !== 'item' && (flags[at - 1] & KIND_BITS) === PRESERVED_SPACE && segments[at - 1] !== '') {
|
|
458
|
+
const spaces = segments[at - 1] .length
|
|
459
|
+
const advances = []
|
|
460
|
+
for (let g = 0; g < spaces; g++) advances.push(widths[at - 1] / spaces)
|
|
461
|
+
breakableFitAdvances[at - 1] = advances
|
|
173
462
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
463
|
+
// The simple walkers take every boundary for a break and a line-end trim out of the line's width.
|
|
464
|
+
simple = false
|
|
465
|
+
segments.push('')
|
|
466
|
+
breakableFitAdvances.push(null)
|
|
467
|
+
// The edge comes before the item's segments, so before the space or soft hyphens that lead them.
|
|
468
|
+
sourceStarts.push(offset - starts[index] )
|
|
469
|
+
sourceEnds.push(offset - starts[index] )
|
|
470
|
+
if (fit !== 0 && fit !== extraWidth) (lineEndTrims ??= zeroList(flags.length))[at] = extraWidth - fit
|
|
471
|
+
first = from - 1
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// An item that is one segment holding all of its text, as a styled word, is measured by the
|
|
475
|
+
// item's own string, not the equal slice of the paragraph's text: a font's widths are kept by
|
|
476
|
+
// text, and a string the caller hands in again keeps its hash, where the slice is hashed anew
|
|
477
|
+
// at every preparation.
|
|
478
|
+
if (to === from + 1 && analysis.texts[from] === item.text) analysis.texts[from] = item.text
|
|
479
|
+
// The item's segments, measured in its font onto the end of the paragraph's lists; `sub` holds
|
|
480
|
+
// what else measurement gives them, from the item's first segment.
|
|
481
|
+
const itemAt = widths.length
|
|
482
|
+
const sub = measureAnalysis(analysis, from, to, item.font, false, letterSpacing, profile, language, !paragraphOnly, lists, paragraphOnly ? '-' : null)
|
|
483
|
+
simple &&= sub.simpleLineCountFastPath
|
|
484
|
+
if (readsItemFonts) {
|
|
485
|
+
// The gap before the hyphen is the letter spacing after the grapheme before it.
|
|
486
|
+
hyphenWidths[index] = spacingsDiffer ? sub.discretionaryHyphenWidth - letterSpacing : sub.discretionaryHyphenWidth
|
|
487
|
+
tabStopAdvances[index] = sub.tabStopAdvance
|
|
488
|
+
minimumTabAdvances[index] = sub.minimumTabAdvance
|
|
489
|
+
if (firstTextItem < 0) firstTextItem = index
|
|
490
|
+
else if (hyphenWidths[index] !== hyphenWidths[firstTextItem] || tabStopAdvances[index] !== tabStopAdvances[firstTextItem] || minimumTabAdvances[index] !== minimumTabAdvances[firstTextItem]) fontsDiffer = true
|
|
491
|
+
}
|
|
492
|
+
for (let i = from; i < to; i++) {
|
|
493
|
+
const s = i - from
|
|
494
|
+
const at = itemAt + s
|
|
495
|
+
const spaced = spacingsDiffer && (flags[at] & SPACED) !== 0
|
|
496
|
+
const holdsExtra = i === first
|
|
497
|
+
if (spaced || holdsExtra) {
|
|
498
|
+
let advances = breakableFitAdvances[at] ?? null
|
|
499
|
+
if (advances !== null) {
|
|
500
|
+
// The cached advances are shared by every occurrence of this text.
|
|
501
|
+
advances = breakableFitAdvances[at] = advances.slice()
|
|
502
|
+
if (spaced) for (let g = 0; g < advances.length; g++) advances[g] = advances[g] + letterSpacing
|
|
503
|
+
if (holdsExtra) advances[0] = advances[0] + extraWidth
|
|
179
504
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
505
|
+
if (spaced) widths[at] = widths[at] + letterSpacing
|
|
506
|
+
if (holdsExtra) widths[at] = widths[at] + extraWidth
|
|
507
|
+
}
|
|
508
|
+
// No break comes before white space (UAX #14 LB7), so none inside a run of preserved spaces
|
|
509
|
+
// and tabs that goes on from the item before, but in WebKit (whiteSpaceItemBreaks).
|
|
510
|
+
if (s === 0 && at > 0 && !whiteSpaceItemBreaks && (1 << (flags[at] & KIND_BITS) & PRESERVED_WHITE_SPACE) !== 0 && (1 << (flags[at - 1] & KIND_BITS) & PRESERVED_WHITE_SPACE) !== 0) {
|
|
511
|
+
flags[at] = flags[at] | UNBROKEN
|
|
512
|
+
if (!analysis.hasUnbroken) marksReturnable = true
|
|
513
|
+
}
|
|
514
|
+
const normalizedStart = analysis.starts[i]
|
|
515
|
+
const normalizedEnd = i + 1 < count ? analysis.starts[i + 1] : analysis.normalized.length
|
|
516
|
+
const sourceEnd = offsets[normalizedEnd - 1] + 1
|
|
517
|
+
// Where the segment's text ends in the normalized text.
|
|
518
|
+
let textEnd = normalizedEnd
|
|
519
|
+
sourceStarts.push(offsets[normalizedStart] - starts[index] )
|
|
520
|
+
if (sourceEnd > itemEnd) {
|
|
521
|
+
// The Gecko analysis keeps the soft hyphens and bidi controls Firefox's text run drops with
|
|
522
|
+
// the segment before them, across items: the segment's text here is its own item's part, and
|
|
523
|
+
// the rest, which paints nothing, is in no fragment.
|
|
524
|
+
while (offsets[textEnd - 1] >= itemEnd) textEnd--
|
|
525
|
+
segments.push(analysis.normalized.slice(normalizedStart, textEnd))
|
|
526
|
+
sourceEnds.push(itemEnd - starts[index] )
|
|
527
|
+
} else {
|
|
528
|
+
segments.push(analysis.texts[i] )
|
|
529
|
+
// CRLF is one line feed, which ends with the item where the next item holds the line feed.
|
|
530
|
+
sourceEnds.push((preserve && sourceEnd < itemEnd && source.charCodeAt(sourceEnd - 1) === 0x0D && source.charCodeAt(sourceEnd) === 0x0A ? sourceEnd + 1 : sourceEnd) - starts[index] )
|
|
531
|
+
}
|
|
532
|
+
const units = getSourceUnits(offsets, normalizedStart, textEnd, starts[index] )
|
|
533
|
+
if (units !== null) (sourceUnits ??= new Array (flags.length).fill(null))[at] = units
|
|
534
|
+
if (sub.entryGeometry !== null && sub.entryGeometry[s] !== null) (entryGeometry ??= new Array (flags.length).fill(null))[at] = sub.entryGeometry[s]
|
|
535
|
+
if (sub.lineStartProhibitions !== null && sub.lineStartProhibitions[s] !== null) (lineStartProhibitions ??= new Array (flags.length).fill(null))[at] = sub.lineStartProhibitions[s]
|
|
536
|
+
if (sub.breakableLineStartExtras !== null && sub.breakableLineStartExtras[s] !== null) (breakableLineStartExtras ??= new Array (flags.length).fill(null))[at] = sub.breakableLineStartExtras[s]
|
|
537
|
+
const startExtra = (sub.lineStartExtras === null ? 0 : sub.lineStartExtras[s] ) + (i > first ? extraWidth : 0)
|
|
538
|
+
if (startExtra !== 0) (lineStartExtras ??= zeroList(flags.length))[at] = startExtra
|
|
539
|
+
if (sub.lineEndTrims !== null) {
|
|
540
|
+
const endTrim = sub.lineEndTrims[s]
|
|
541
|
+
if (endTrim !== 0) (lineEndTrims ??= zeroList(flags.length))[at] = endTrim
|
|
542
|
+
// A closing mark that ends its item, halted at a line's end, stays halted where the line
|
|
543
|
+
// goes on. Blink breaks an item's text that doesn't fit its line, and where the text fits
|
|
544
|
+
// with its last mark halted, which it tries where a break comes right after the mark, that
|
|
545
|
+
// is the item's result, and the line takes what follows after it
|
|
546
|
+
// (ShapingLineBreaker::ShapeLine, shaping_line_breaker.cc:342-363; LineBreaker::BreakText,
|
|
547
|
+
// line_breaker.cc:1736-1758): in 16px Hiragino Sans, Chrome 154 lays out `文字」` and a span
|
|
548
|
+
// `i` as one 43.81px line at 44-47px. A run of U+3000 that ends the item has a line-end
|
|
549
|
+
// trim too, its hang, which is no halt. An item with extraWidth keeps the halt at a line's
|
|
550
|
+
// end only. Blink fits its text before its end edge: where the text fits there the mark
|
|
551
|
+
// stays whole though the edge overflows, so Chrome leaves the mark of a span `文字」` with
|
|
552
|
+
// 4px of padding on each side whole at 52-55px and wraps the `i` after it, and where it
|
|
553
|
+
// doesn't the mark is halted and the line goes on, as at 51.81-51.94px, where the `i`
|
|
554
|
+
// fits after it. The extraWidth doesn't split the end edge off, so the line can't tell
|
|
555
|
+
// the two and takes the first for both. Its gap is what follows such a mark and is
|
|
556
|
+
// narrower than the halt less that edge, which wraps where Chrome keeps it
|
|
557
|
+
// (ENGINE_FOLLOWUPS.md, Rich-inline item edges). The walkers that take every boundary for
|
|
558
|
+
// a break end the line after a trimmed segment, so the paragraph isn't theirs.
|
|
559
|
+
if (endTrim !== 0 && i === to - 1 && to < count && extraWidth === 0 && !analysis.texts[i] .endsWith('\u3000')) {
|
|
560
|
+
;(itemEndHalts ??= zeroList(flags.length))[at] = endTrim
|
|
561
|
+
simple = false
|
|
185
562
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
563
|
+
}
|
|
564
|
+
if (sub.overflowLineEndTrims !== null) {
|
|
565
|
+
const retryTrim = sub.overflowLineEndTrims[s]
|
|
566
|
+
if (retryTrim !== 0) (overflowLineEndTrims ??= zeroList(flags.length))[at] = retryTrim
|
|
567
|
+
// Where no break comes after the mark, as before a period or a no-break space that starts
|
|
568
|
+
// the next item, Blink halts it only on a line it lays out again with a break after every
|
|
569
|
+
// grapheme (HandleOverflow, line_breaker.cc:4259-4264), and there too the halted text is the
|
|
570
|
+
// item's result and the line goes on: Chrome 154 lays out `文字」`, a span `.` and `字` as
|
|
571
|
+
// `文` / `字」.` / `字` at 28.5-31.75px, where their text in one node ends the second line
|
|
572
|
+
// after the mark.
|
|
573
|
+
if (retryTrim !== 0 && i === to - 1 && to < count && extraWidth === 0) {
|
|
574
|
+
;(itemEndHalts ??= zeroList(flags.length))[at] = retryTrim
|
|
575
|
+
simple = false
|
|
198
576
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
577
|
+
}
|
|
578
|
+
if (sub.discretionaryHyphenContexts !== null) (discretionaryHyphenContexts ??= zeroList(flags.length))[at] = sub.discretionaryHyphenContexts[s]
|
|
579
|
+
if (i >= first && first < to) (insideExtras ??= zeroList(flags.length))[at] = extraWidth
|
|
580
|
+
if (i > first && first < to) (fillExtras ??= zeroList(flags.length))[at] = extraWidth
|
|
202
581
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
582
|
+
from = to
|
|
583
|
+
}
|
|
584
|
+
const segmentCount = widths.length
|
|
585
|
+
while (itemSegments.length <= items.length) itemSegments.push(segmentCount)
|
|
586
|
+
const segmentFlags = segmentCount === flags.length ? flags : flags.subarray(0, segmentCount)
|
|
587
|
+
// A paragraph whose lines return from an unfit hyphen marks every break the scan gives before
|
|
588
|
+
// text, so its walk records each as the line's latest break (pendingBreakSegmentIndex in
|
|
589
|
+
// walkPreparedComplexLines). The return itself reads no mark, as a text's doesn't; what the marks
|
|
590
|
+
// still decide is where the Gecko profile ends a line that an object of width 0 sticks out of
|
|
591
|
+
// (ENGINE_FOLLOWUPS.md, Rich-inline item edges).
|
|
592
|
+
if (marksReturnable || (discretionaryHyphenContexts !== null && !analysis.hasUnbroken)) for (let i = 0; i < segmentCount; i++) if ((segmentFlags[i] & UNBROKEN) === 0) segmentFlags[i] = segmentFlags[i] | RETURNABLE
|
|
593
|
+
|
|
594
|
+
const segmentItems = new Int32Array(segmentCount)
|
|
595
|
+
const gapSegments = new Uint8Array(segmentCount)
|
|
596
|
+
for (let index = 0; index < items.length; index++) {
|
|
597
|
+
const firstSegment = itemSegments[index]
|
|
598
|
+
const end = itemSegments[index + 1]
|
|
599
|
+
for (let i = firstSegment; i < end; i++) {
|
|
600
|
+
segmentItems[i] = index
|
|
601
|
+
if ((i === firstSegment || i === end - 1) && (segmentFlags[i] & KIND_BITS) === SPACE && segments[i] .length === 1) gapSegments[i] = 1
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
let segmentHyphenWidths = null
|
|
606
|
+
let segmentTabStopAdvances = null
|
|
607
|
+
let segmentMinimumTabAdvances = null
|
|
608
|
+
if (fontsDiffer) {
|
|
609
|
+
segmentHyphenWidths = []
|
|
610
|
+
segmentTabStopAdvances = []
|
|
611
|
+
segmentMinimumTabAdvances = []
|
|
612
|
+
for (let i = 0; i < segmentCount; i++) {
|
|
613
|
+
const index = segmentItems[i]
|
|
614
|
+
segmentHyphenWidths.push(hyphenWidths[index] )
|
|
615
|
+
segmentTabStopAdvances.push(tabStopAdvances[index] )
|
|
616
|
+
segmentMinimumTabAdvances.push(minimumTabAdvances[index] )
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const segmentData = {
|
|
620
|
+
hyphenWidths: segmentHyphenWidths, tabStopAdvances: segmentTabStopAdvances, minimumTabAdvances: segmentMinimumTabAdvances,
|
|
621
|
+
itemEndHalts,
|
|
622
|
+
hyphenRooms: discretionaryHyphenContexts !== null && profile.unfitHyphenRetreat === 'reduced-width' ? getHyphenRooms(hyphenWidths, itemSegments, segmentItems) : null,
|
|
623
|
+
insideExtras, fillExtras, openingEdges,
|
|
624
|
+
emptyObjectSpaces: null, emptyObjectReturns: null,
|
|
625
|
+
}
|
|
626
|
+
// The properties measureAnalysis() gives a text's handle, in its order, then the paragraph's own.
|
|
627
|
+
const data = {
|
|
628
|
+
widths,
|
|
629
|
+
segmentFlags,
|
|
630
|
+
simpleLineWalkFastPath: simple,
|
|
631
|
+
simpleLineCountFastPath: false,
|
|
632
|
+
breakableFitAdvances,
|
|
633
|
+
entryGeometry,
|
|
634
|
+
letterSpacing: spacingsDiffer || sharedSpacing === null ? 0 : sharedSpacing,
|
|
635
|
+
discretionaryHyphenWidth: firstTextItem < 0 ? 0 : hyphenWidths[firstTextItem] ,
|
|
636
|
+
discretionaryHyphenContexts,
|
|
637
|
+
lineStartProhibitions,
|
|
638
|
+
breakableLineStartExtras,
|
|
639
|
+
lineStartExtras,
|
|
640
|
+
lineEndTrims,
|
|
641
|
+
overflowLineEndTrims,
|
|
642
|
+
tabStopAdvance: firstTextItem < 0 ? 0 : tabStopAdvances[firstTextItem] ,
|
|
643
|
+
minimumTabAdvance: firstTextItem < 0 ? 0 : minimumTabAdvances[firstTextItem] ,
|
|
644
|
+
items: segmentData,
|
|
645
|
+
}
|
|
646
|
+
data.segments = segments
|
|
647
|
+
if (hasEmptyObject && profile.emptyAtomicAlwaysFits) setEmptyObjectFacts(segmentData, data, items, itemSegments, segmentItems, source, starts)
|
|
648
|
+
|
|
649
|
+
let onlyItem = -1
|
|
650
|
+
for (let index = 0; index < items.length && !paddedOrObject; index++) {
|
|
651
|
+
if (itemSegments[index] === itemSegments[index + 1]) continue
|
|
652
|
+
if (onlyItem >= 0) {
|
|
653
|
+
onlyItem = -1
|
|
654
|
+
break
|
|
655
|
+
}
|
|
656
|
+
onlyItem = index
|
|
657
|
+
}
|
|
658
|
+
const flow = {
|
|
659
|
+
data, onlyItem, wholeWidth: null, wholeStart: 0, wholeEnd: 0, itemSegments, segmentItems, gapSegments,
|
|
660
|
+
plainWidths: data.letterSpacing === 0 && itemEndHalts === null && !(preserve && source.includes('\t')),
|
|
661
|
+
sourceStarts, sourceEnds, sourceUnits, text: '',
|
|
662
|
+
}
|
|
663
|
+
return paragraphOnly ? paragraphSegments(flow) : mayNarrow && onlyItem < 0 && narrowsLine(data, itemSegments) ? flow : findWholeLine(flow)
|
|
206
664
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
width,
|
|
228
|
-
end
|
|
229
|
-
};
|
|
665
|
+
|
|
666
|
+
// Per segment, the room a line that ends at the break before it leaves for a hyphen, where the line
|
|
667
|
+
// returns there from a soft hyphen whose hyphen doesn't fit, with a last entry for the break at the
|
|
668
|
+
// paragraph's end. Blink breaks a text item again against the width less the item's own hyphen
|
|
669
|
+
// where the hyphen of the soft hyphen it ends at overflows (LineBreaker::BreakText,
|
|
670
|
+
// line_breaker.cc:1705-1719), so a break inside the item that holds the soft hyphen leaves room
|
|
671
|
+
// for that item's hyphen. Where none in the item does, the line goes back over the items before it
|
|
672
|
+
// to the latest break that fits, with no room (HandleOverflow, :4105-4112), so the break before an
|
|
673
|
+
// item's first segment leaves none. The premise is that a break inside an item is returned to from
|
|
674
|
+
// a soft hyphen of that item: for one inside an earlier item Blink asks only that the line fit
|
|
675
|
+
// (:4138-4158), and this leaves room for that item's hyphen (ENGINE_FOLLOWUPS.md, Rich-inline item
|
|
676
|
+
// edges).
|
|
677
|
+
function getHyphenRooms(hyphenWidths , itemSegments , segmentItems ) {
|
|
678
|
+
const rooms = []
|
|
679
|
+
for (let i = 0; i < segmentItems.length; i++) {
|
|
680
|
+
const index = segmentItems[i]
|
|
681
|
+
rooms.push(i === itemSegments[index] ? 0 : hyphenWidths[index] )
|
|
682
|
+
}
|
|
683
|
+
rooms.push(0)
|
|
684
|
+
return rooms
|
|
230
685
|
}
|
|
231
|
-
|
|
232
|
-
|
|
686
|
+
|
|
687
|
+
// A soft hyphen, as a segment of any kind: Gecko drops soft hyphens from a text frame's text
|
|
688
|
+
// (IsDiscardable, nsTextFrameUtils.cpp:32-49).
|
|
689
|
+
function isSoftHyphenSegment(data , i ) {
|
|
690
|
+
const kind = data.segmentFlags[i] & KIND_BITS
|
|
691
|
+
return kind === SOFT_HYPHEN || ((kind === ZERO_WIDTH_BREAK || kind === ZERO_WIDTH_GLUE) && data.segments[i] .charCodeAt(0) === 0x00AD)
|
|
233
692
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
693
|
+
|
|
694
|
+
// What a line reads of the text around each object of width 0 where the engine places one though
|
|
695
|
+
// it sticks out of the line, as Gecko does an empty frame (ParagraphSegmentData;
|
|
696
|
+
// walkPreparedComplexLines has when it sticks out). Each item is a text frame.
|
|
697
|
+
// - The white space that ends the text before the object, read past the soft hyphens Gecko
|
|
698
|
+
// drops. Gecko's line breaker leaves a break at the end of a text run that ends in a space or a
|
|
699
|
+
// tab, whatever its advance (nsLineBreaker::Reset, nsLineBreaker.cpp:710-719; IsSegmentSpace,
|
|
700
|
+
// nsLineBreaker.h:260-264), and the run's last frame breaks the line there where it ends past the
|
|
701
|
+
// line's end without its own trailing spaces (nsTextFrame.cpp:11443-11456), which a tab isn't
|
|
702
|
+
// among (gfxTextRun.cpp:1152-1160). So a collapsed space counts as that frame's own where it is
|
|
703
|
+
// in the item right before the object; one in an item before that, as before an item of soft
|
|
704
|
+
// hyphens alone, an empty frame, is inside the line's width. Preserved spaces that end their
|
|
705
|
+
// item hang, which the line's width leaves out already. Preserved spaces before the soft hyphens
|
|
706
|
+
// that end their item hang in Firefox and not here, so they aren't read as white space
|
|
707
|
+
// (ENGINE_FOLLOWUPS.md).
|
|
708
|
+
// - Whether a line with a break before the object goes back to it. The break comes after white
|
|
709
|
+
// space, after an atomic item (nsLineLayout.cpp:1057-1069) and after a soft hyphen that ends the
|
|
710
|
+
// frame before the object, whatever the hyphen's width (HasSoftHyphenBefore,
|
|
711
|
+
// nsTextFrame.cpp:11432-11439); other text leaves no break at its end, so the line's first break
|
|
712
|
+
// is the one after the object, which stays. The break after a frame that sticks out doesn't count
|
|
713
|
+
// as one that fits (nsLineLayout.cpp:1260), and the line remembers its last break that fits
|
|
714
|
+
// (NotifyOptionalBreakPosition, :1506-1513), so a text frame or a span with a width that comes
|
|
715
|
+
// next sticks out too and sends the line back to before the object (:1323-1334;
|
|
716
|
+
// nsBlockFrame.cpp:5361-5379). The object stays where the line ends without going back: at the
|
|
717
|
+
// paragraph's end, before an atomic item with a width, which moves down whole (:1337-1341), and
|
|
718
|
+
// before unpadded text whose first piece has no width. A text frame that doesn't fit ends at its
|
|
719
|
+
// first break, whether or not that fits (gfxTextRun::BreakAndMeasureText, gfxTextRun.cpp:1091-1099,
|
|
720
|
+
// 1177-1185), so its first piece is all of it on this line: a ZWSP, a hard break, preserved
|
|
721
|
+
// spaces, which hang, or the collapsed space that starts the text's own node, trimmed where the
|
|
722
|
+
// frame breaks after it (nsTextFrame.cpp:11202-11213). Padding is its span's width whatever the
|
|
723
|
+
// text starts with. White space in a node of its own has no break inside, so its frame is whole
|
|
724
|
+
// and keeps its width, as is a node of white space and soft hyphens. An item of soft hyphens
|
|
725
|
+
// alone takes no room and is passed over, as is each object of width 0, which stays for the same
|
|
726
|
+
// reason as the first: one answer serves a run of them, found from the paragraph's end back.
|
|
727
|
+
// White space that ends the paragraph in a node of its own gets no frame
|
|
728
|
+
// (nsCSSFrameConstructor.cpp:5278-5286), and after other text of its item, as after soft
|
|
729
|
+
// hyphens, it is that frame's width.
|
|
730
|
+
function setEmptyObjectFacts(
|
|
731
|
+
segmentData ,
|
|
732
|
+
data ,
|
|
733
|
+
items ,
|
|
734
|
+
itemSegments ,
|
|
735
|
+
segmentItems ,
|
|
736
|
+
source ,
|
|
737
|
+
starts ,
|
|
738
|
+
) {
|
|
739
|
+
const { segmentFlags, widths, letterSpacing } = data
|
|
740
|
+
const count = segmentFlags.length
|
|
741
|
+
const spaces = zeros(count)
|
|
742
|
+
const returns = new Uint8Array(count)
|
|
743
|
+
// The item whose white space ends the paragraph's text after other text of its own, or -1.
|
|
744
|
+
let runStart = source.length
|
|
745
|
+
while (runStart > 0 && isCollapsibleSpaceCode(source.charCodeAt(runStart - 1))) runStart--
|
|
746
|
+
let endSpaceItem = -1
|
|
747
|
+
if (runStart < source.length) {
|
|
748
|
+
let index = starts.length - 1
|
|
749
|
+
while (index > 0 && starts[index] > runStart) index--
|
|
750
|
+
if (itemSegments[index] < itemSegments[index + 1] ) endSpaceItem = index
|
|
751
|
+
}
|
|
752
|
+
// Whether what follows an object keeps it on its line, for an object right before the item at
|
|
753
|
+
// hand, from the paragraph's end back.
|
|
754
|
+
let keeps = true
|
|
755
|
+
for (let index = items.length - 1; index >= 0; index--) {
|
|
756
|
+
const first = itemSegments[index]
|
|
757
|
+
const end = itemSegments[index + 1]
|
|
758
|
+
if (first === end) continue
|
|
759
|
+
const kind = segmentFlags[first] & KIND_BITS
|
|
760
|
+
const item = items[index]
|
|
761
|
+
if (item.text === undefined || item.break === 'never') {
|
|
762
|
+
if (widths[first] !== 0) {
|
|
763
|
+
keeps = true
|
|
764
|
+
continue
|
|
765
|
+
}
|
|
766
|
+
let before = first - 1
|
|
767
|
+
while (before >= 0 && isSoftHyphenSegment(data, before)) before--
|
|
768
|
+
const beforeKind = before < 0 ? TEXT : segmentFlags[before] & KIND_BITS
|
|
769
|
+
let space = -1
|
|
770
|
+
if (beforeKind === SPACE) space = segmentItems[before] === segmentItems[first - 1] ? widths[before] + ((segmentFlags[before] & SPACED) !== 0 ? letterSpacing : 0) : 0
|
|
771
|
+
else if (beforeKind === TAB || (beforeKind === PRESERVED_SPACE && segmentItems[before] !== segmentItems[before + 1])) space = 0
|
|
772
|
+
spaces[first] = space
|
|
773
|
+
const lastKind = first === 0 ? TEXT : segmentFlags[first - 1] & KIND_BITS
|
|
774
|
+
if (!keeps && (space >= 0 || lastKind === OBJECT || lastKind === SOFT_HYPHEN)) returns[first] = 1
|
|
775
|
+
continue
|
|
249
776
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
...RICH_INLINE_START_CURSOR
|
|
260
|
-
};
|
|
261
|
-
while(true){
|
|
262
|
-
const line = layoutNextRichInlineLineRange(prepared, maxWidth, cursor);
|
|
263
|
-
if (line === null) return lineCount;
|
|
264
|
-
cursor.itemIndex = line.end.itemIndex;
|
|
265
|
-
cursor.segmentIndex = line.end.segmentIndex;
|
|
266
|
-
cursor.graphemeIndex = line.end.graphemeIndex;
|
|
267
|
-
onLine(line);
|
|
268
|
-
lineCount++;
|
|
777
|
+
if (endSpaceItem === index) keeps = false
|
|
778
|
+
let softHyphens = true
|
|
779
|
+
let holdsSpace = false
|
|
780
|
+
let establishesLine = false
|
|
781
|
+
for (let i = first; i < end; i++) {
|
|
782
|
+
if (isSoftHyphenSegment(data, i)) continue
|
|
783
|
+
softHyphens = false
|
|
784
|
+
if ((segmentFlags[i] & KIND_BITS) === SPACE) holdsSpace = true
|
|
785
|
+
else establishesLine = true
|
|
269
786
|
}
|
|
787
|
+
if (softHyphens) continue
|
|
788
|
+
if ((item.extraWidth ?? 0) > 0 || (holdsSpace && !establishesLine)) keeps = false
|
|
789
|
+
else if (kind === SPACE) keeps = true
|
|
790
|
+
else keeps = (kind === ZERO_WIDTH_BREAK && !isSoftHyphenSegment(data, first)) || kind === HARD_BREAK || kind === PRESERVED_SPACE
|
|
791
|
+
}
|
|
792
|
+
segmentData.emptyObjectSpaces = spaces
|
|
793
|
+
segmentData.emptyObjectReturns = returns
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Finds the paragraph's line where nothing wraps it, which is taken without a walk at any width it
|
|
797
|
+
// fits: most paragraphs of a chat are one line. That is a premise. Blink, Gecko and WebKit each fit
|
|
798
|
+
// a line at prefixes of it, after each item, at each break opportunity and frame end, and after each
|
|
799
|
+
// run between two wrap opportunities (line_breaker.cc:1009-1147; gfxTextRun.cpp:1085-1109;
|
|
800
|
+
// InlineLineBuilder.cpp:538-604), and none lets text make a line narrower (ClampNegativeToZero,
|
|
801
|
+
// line_breaker.cc:1703; nsTextFrame.cpp:11272-11273; InlineLine.cpp:440), so where no item makes
|
|
802
|
+
// the line narrower its width only grows from item to item and the walkers take the same line. A
|
|
803
|
+
// paragraph with such an item, one of no advance under negative letter spacing or one with a
|
|
804
|
+
// negative extraWidth, has no such line and is walked (narrowsLine). Inside an item the premise's
|
|
805
|
+
// gap is left: under negative letter spacing a line can overflow at a break and come back under
|
|
806
|
+
// the line's width after it, where the text walkers end the line at the break and the paragraph
|
|
807
|
+
// takes it whole, so a paragraph of one item can take fewer lines than layoutWithLines() gives its
|
|
808
|
+
// text. Blink takes a text item whole where its shaped width fits (ShapingLineBreaker::ShapeLine,
|
|
809
|
+
// shaping_line_breaker.cc:281-297), which is that line; ENGINE_FOLLOWUPS.md, Negative letter
|
|
810
|
+
// spacing and hanging spaces, has the counts. The walk hands walkPreparedLinesRaw() a visitor of
|
|
811
|
+
// its own, which costs later walks of one-item CJK paragraphs 4-6% in Firefox 156 and a stream
|
|
812
|
+
// over a page of mostly such paragraphs 8% in Safari 27; the line stepped with no visitor was
|
|
813
|
+
// measured and left out (RESEARCH.md, Decisions Log, 2026-10-09, a loop of its own for the count
|
|
814
|
+
// of lines).
|
|
815
|
+
function findWholeLine(flow ) {
|
|
816
|
+
const lineCount = walkPreparedLinesRaw(flow.data, Number.POSITIVE_INFINITY, (width, startSegmentIndex, _startGraphemeIndex, endSegmentIndex) => {
|
|
817
|
+
flow.wholeWidth = width
|
|
818
|
+
flow.wholeStart = startSegmentIndex
|
|
819
|
+
flow.wholeEnd = endSegmentIndex
|
|
820
|
+
})
|
|
821
|
+
if (lineCount !== 1) flow.wholeWidth = null
|
|
822
|
+
return flow
|
|
270
823
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
824
|
+
|
|
825
|
+
// Whether an item of the paragraph takes less than no room on a line: its segments' widths, with
|
|
826
|
+
// the letter spacing after each one that takes it, add up to less than 0.
|
|
827
|
+
function narrowsLine(data , itemSegments ) {
|
|
828
|
+
const { widths, segmentFlags, letterSpacing } = data
|
|
829
|
+
for (let index = 0; index + 1 < itemSegments.length; index++) {
|
|
830
|
+
let width = 0
|
|
831
|
+
for (let i = itemSegments[index] ; i < itemSegments[index + 1] ; i++) width += widths[i] + ((segmentFlags[i] & SPACED) !== 0 ? letterSpacing : 0)
|
|
832
|
+
if (width < 0) return true
|
|
833
|
+
}
|
|
834
|
+
return false
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Whether the paragraph's whole line fits `maxWidth`, as the walkers fit a line. The line functions
|
|
838
|
+
// hand it their width clamped at 0, as the walkers clamp the one they are handed. The line's width
|
|
839
|
+
// here is signed, and under 0 where letter spacing is more negative than the letters are wide:
|
|
840
|
+
// fitted against a width under 0 as given, such a paragraph would be one line down to its own
|
|
841
|
+
// width and walked under it, where the walkers can end a line that the whole fit takes
|
|
842
|
+
// (findWholeLine; RESEARCH.md, Decisions Log, 2026-10-09, rich inline's width).
|
|
843
|
+
function fitsWhole(flow , maxWidth ) {
|
|
844
|
+
return flow.wholeWidth !== null && flow.wholeWidth <= maxWidth + getEngineProfile().lineFitEpsilon
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// `count` zeros, where a paragraph's list of numbers starts: a copy of one list of zeros, which
|
|
848
|
+
// grows to the longest asked for, up to 1024; a longer list is pushed. A copy is one allocation,
|
|
849
|
+
// where zeros pushed one at a time regrow the list as it fills: a list of 127 costs 18 ns copied
|
|
850
|
+
// and 141 pushed in V8's shell, 44 and 293 in SpiderMonkey's, 59 and 204 in JavaScriptCore's.
|
|
851
|
+
const ZEROS = []
|
|
852
|
+
function zeroList(count ) {
|
|
853
|
+
if (count > 1024) return zeros(count)
|
|
854
|
+
while (ZEROS.length < count) ZEROS.push(0)
|
|
855
|
+
return ZEROS.slice(0, count)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// How much of a padded item's extraWidth a line fits where it takes the item's opening, the
|
|
859
|
+
// white space, hard break or zero-width space that starts it, and no more of it; a line that goes
|
|
860
|
+
// on into the item fits all of it. Blink adds a span's start edge to the line when it opens
|
|
861
|
+
// (HandleOpenTag, line_breaker.cc:3957-3976), and only a test-only flag narrows the line for its
|
|
862
|
+
// cloned end edge (BoxDecorationBreakCloneLineBreaking, :454-461), so it fits half: the extraWidth
|
|
863
|
+
// isn't split by side (ENGINE_FOLLOWUPS.md). The items a line takes after the break it returns to
|
|
864
|
+
// stay on it where they are all trailable, white space with the tags of spans that open and close
|
|
865
|
+
// among it (RewindOverflow, :4332-4424), so Blink fits no edge of a span of only white space. Nor
|
|
866
|
+
// does it after preserved spaces that follow text in one item, which its return breaks before
|
|
867
|
+
// (HandleOverflow, :4163-4185, at the run's start that ShapingLineBreaker::ShapeLine breaks at,
|
|
868
|
+
// shaping_line_breaker.cc:490-495): the line then trails them, taking the open tag and white
|
|
869
|
+
// space or a forced break after them with no fit (HandleTrailingSpaces, :2426-2534). WebKit fits
|
|
870
|
+
// a box that opens in the content it places without its cloned end edge
|
|
871
|
+
// (placedClonedDecorationWidth, InlineLineBuilder.cpp:1501-1523), where a hard break or white
|
|
872
|
+
// space starts it, after the white space before it, which the line counts (the walker's line
|
|
873
|
+
// width holds it), but that content runs on past the inline box ends after a line break or white
|
|
874
|
+
// space (nextWrapOpportunity, InlineFormattingUtils.cpp:470-475, 530-538), so it fits the end
|
|
875
|
+
// edge too of an item of white space that ends there. Gecko fits a frame's whole width, its
|
|
876
|
+
// cloned end edge too (CanPlaceFrame, nsLineLayout.cpp:1217-1270).
|
|
877
|
+
function getOpeningFit(flags , from , to , extraWidth , afterTextSpaces , profile ) {
|
|
878
|
+
const fit = profile.paddedOpeningFit
|
|
879
|
+
const firstKind = flags[from] & KIND_BITS
|
|
880
|
+
const opensWithWhiteSpace = firstKind === PRESERVED_SPACE || (firstKind === TAB && profile.hangTabs)
|
|
881
|
+
const opensWithBreak = firstKind === HARD_BREAK || firstKind === ZERO_WIDTH_BREAK
|
|
882
|
+
let whiteSpaceEnd = from
|
|
883
|
+
while (whiteSpaceEnd < to && ((flags[whiteSpaceEnd] & KIND_BITS) === PRESERVED_SPACE || (flags[whiteSpaceEnd] & KIND_BITS) === TAB)) whiteSpaceEnd++
|
|
884
|
+
if (fit === 'start' && (opensWithWhiteSpace || opensWithBreak)) return afterTextSpaces || whiteSpaceEnd === to ? 0 : extraWidth / 2
|
|
885
|
+
if (fit === 'placed' && (opensWithWhiteSpace || opensWithBreak)) {
|
|
886
|
+
const onlyOpening = whiteSpaceEnd === to || (whiteSpaceEnd === to - 1 && (flags[whiteSpaceEnd] & KIND_BITS) === HARD_BREAK)
|
|
887
|
+
return onlyOpening ? extraWidth : extraWidth / 2
|
|
888
|
+
}
|
|
889
|
+
return extraWidth
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// The width on a line of graphemes [from, to) of segment i, to its end where `to` is -1: a line
|
|
893
|
+
// starts or ends inside its first and its last segment only. Where they start the line
|
|
894
|
+
// (`startsLine`), the first of them adds what a line that starts with it does
|
|
895
|
+
// (breakableLineStartExtras), as the walker's line does.
|
|
896
|
+
function getPartWidth(data , i , from , to , startsLine ) {
|
|
897
|
+
const { breakableFitAdvances, breakableLineStartExtras, entryGeometry, letterSpacing, items } = data
|
|
898
|
+
const advances = breakableFitAdvances[i]
|
|
899
|
+
if (to < 0) to = advances.length
|
|
900
|
+
const entry = from > 0 && entryGeometry !== null ? entryGeometry[i] : null
|
|
901
|
+
const entryWidth = entry === null ? null : getSegmentEntryWidth(entry, from, to)
|
|
902
|
+
let w = 0
|
|
903
|
+
if (entryWidth !== null) {
|
|
904
|
+
w = entryWidth
|
|
905
|
+
} else {
|
|
906
|
+
for (let g = from; g < to; g++) w += advances[g] + letterSpacing
|
|
907
|
+
const startExtras = startsLine && breakableLineStartExtras !== null ? breakableLineStartExtras[i] : null
|
|
908
|
+
if (startExtras !== null) w += startExtras[from]
|
|
909
|
+
}
|
|
910
|
+
if (items !== undefined && (from > 0 ? items.insideExtras !== null : items.fillExtras !== null)) w += from > 0 ? items.insideExtras [i] : items.fillExtras [i]
|
|
911
|
+
return w
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// A line of a paragraph whose only item with segments has no extraWidth (onlyItem), as most of a
|
|
915
|
+
// chat's are: one fragment of that item, as wide as the line. Every segment is that item's, so a
|
|
916
|
+
// place in the paragraph is the same place in the item. A function of its own, which the walk and
|
|
917
|
+
// the stream call for such a paragraph: with it Safari 27 walked and streamed the chat demo's
|
|
918
|
+
// paragraphs 8.0% and 8.6% faster than with that line as a branch of createLine() and Chrome 154
|
|
919
|
+
// walked them 4.2% faster, and it cost Chrome's walk of items that are a word or a space each 5.3%
|
|
920
|
+
// and Firefox 156's walk of the demo's styled paragraphs 2.2% (RESEARCH.md, Rich Inline As One
|
|
921
|
+
// Paragraph; Decisions Log, 2026-10-08).
|
|
922
|
+
function createOnlyItemLine(
|
|
923
|
+
flow ,
|
|
924
|
+
width ,
|
|
925
|
+
startSegmentIndex ,
|
|
926
|
+
startGraphemeIndex ,
|
|
927
|
+
endSegmentIndex ,
|
|
928
|
+
endGraphemeIndex ,
|
|
929
|
+
) {
|
|
930
|
+
const { onlyItem } = flow
|
|
931
|
+
const ended = endSegmentIndex >= flow.data.segmentFlags.length
|
|
932
|
+
return {
|
|
933
|
+
fragments: [{
|
|
934
|
+
itemIndex: onlyItem,
|
|
935
|
+
gapBefore: 0,
|
|
936
|
+
gapItemIndex: -1,
|
|
937
|
+
occupiedWidth: width,
|
|
938
|
+
start: { segmentIndex: startSegmentIndex, graphemeIndex: startGraphemeIndex },
|
|
939
|
+
end: { segmentIndex: endSegmentIndex, graphemeIndex: endGraphemeIndex },
|
|
940
|
+
}],
|
|
941
|
+
width: Math.max(0, width),
|
|
942
|
+
end: { itemIndex: ended ? flow.itemSegments.length - 1 : onlyItem, segmentIndex: ended ? 0 : endSegmentIndex, graphemeIndex: endGraphemeIndex },
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// A line of the paragraph from the text walkers' line: its segments cut into fragments where the
|
|
947
|
+
// item changes. A collapsed space at an item's start or end is no fragment's text but the gap
|
|
948
|
+
// before the next fragment on the line, as that item's white space made it. Each fragment's width
|
|
949
|
+
// is its segments' on this line, and the fragments add up to the line's width: what the line's
|
|
950
|
+
// width leaves out at its end comes out of the last fragments and the gaps before them, from the
|
|
951
|
+
// line's end back, as white space that hangs there, but never out of the edge of a padded item's
|
|
952
|
+
// opening, which the line paints whole (getOpeningFit), and what it adds, the hyphen of a soft
|
|
953
|
+
// hyphen the line ends at, goes to the last one. One loop over the line's segments: where a
|
|
954
|
+
// fragment or a gap starts, the paragraph's lists give where that fragment ends, so every other
|
|
955
|
+
// segment only adds its width. A line where no segment's width depends on the line (`bare`), as
|
|
956
|
+
// nearly every line of styled prose is, has a loop of its own that adds bare widths: in one loop
|
|
957
|
+
// with the widths that tabs, letter spacing, halts and a line's edges need, Chrome walked the chat
|
|
958
|
+
// demo's styled paragraphs about 19% slower and Safari 8% (two runs against one build). The widths
|
|
959
|
+
// of the two segments a line can start or end inside are found before the loops, and neither holds
|
|
960
|
+
// another loop or a search: a search for the item, or a call or a loop inside one for a width it
|
|
961
|
+
// rarely needs, cost Firefox a third to a half of a walk's time, and a loop inside for each
|
|
962
|
+
// fragment's widths 9% on items of one segment (RESEARCH.md, Rich Inline As One Paragraph).
|
|
963
|
+
function createLine(
|
|
964
|
+
flow ,
|
|
965
|
+
width ,
|
|
966
|
+
startSegmentIndex ,
|
|
967
|
+
startGraphemeIndex ,
|
|
968
|
+
endSegmentIndex ,
|
|
969
|
+
endGraphemeIndex ,
|
|
970
|
+
) {
|
|
971
|
+
const { data, itemSegments } = flow
|
|
972
|
+
const { widths, segmentFlags, lineStartExtras, letterSpacing } = data
|
|
973
|
+
const items = data.items
|
|
974
|
+
const segmentItems = flow.segmentItems
|
|
975
|
+
const gapSegments = flow.gapSegments
|
|
976
|
+
const itemCount = itemSegments.length - 1
|
|
977
|
+
const endItemIndex = endSegmentIndex >= segmentItems.length ? itemCount : segmentItems[endSegmentIndex]
|
|
978
|
+
const end = { itemIndex: endItemIndex, segmentIndex: endSegmentIndex - itemSegments[endItemIndex] , graphemeIndex: endGraphemeIndex }
|
|
979
|
+
const tabsInAppUnits = getEngineProfile().tabsInAppUnits
|
|
980
|
+
const itemEndHalts = items.itemEndHalts
|
|
981
|
+
const fragments = []
|
|
982
|
+
const lastSegmentIndex = endGraphemeIndex > 0 ? endSegmentIndex : endSegmentIndex - 1
|
|
983
|
+
const firstPartWidth = startSegmentIndex <= lastSegmentIndex && (startGraphemeIndex > 0 || startSegmentIndex === endSegmentIndex)
|
|
984
|
+
? getPartWidth(data, startSegmentIndex, startGraphemeIndex, startSegmentIndex < endSegmentIndex ? -1 : endGraphemeIndex, true) : 0
|
|
985
|
+
const lastPartWidth = endGraphemeIndex > 0 && endSegmentIndex > startSegmentIndex ? getPartWidth(data, endSegmentIndex, 0, endGraphemeIndex, false) : 0
|
|
986
|
+
// Whether each of the line's segments is as wide on it as its width: in a paragraph of plain
|
|
987
|
+
// widths, a line that starts and ends between segments and adds nothing at its start.
|
|
988
|
+
const bare = flow.plainWidths && startGraphemeIndex === 0 && endGraphemeIndex === 0 && (lineStartExtras === null || lineStartExtras[startSegmentIndex] === 0)
|
|
989
|
+
// The line's width so far, and the gap that waits for a fragment.
|
|
990
|
+
let lineW = 0
|
|
991
|
+
let gap = 0
|
|
992
|
+
let gapItemIndex = -1
|
|
993
|
+
// The last fragment made, its width so far, and the segment after its last.
|
|
994
|
+
let fragment = null
|
|
995
|
+
let occupiedWidth = 0
|
|
996
|
+
let to = startSegmentIndex
|
|
997
|
+
if (bare) {
|
|
998
|
+
for (let i = startSegmentIndex; i <= lastSegmentIndex; i++) {
|
|
999
|
+
if (i === to) {
|
|
1000
|
+
if (fragment !== null) fragment.occupiedWidth = occupiedWidth
|
|
1001
|
+
const itemIndex = segmentItems[i]
|
|
1002
|
+
if (gapSegments[i] !== 0) {
|
|
1003
|
+
gap = widths[i]
|
|
1004
|
+
gapItemIndex = itemIndex
|
|
1005
|
+
to = i + 1
|
|
1006
|
+
continue
|
|
287
1007
|
}
|
|
288
|
-
|
|
289
|
-
|
|
1008
|
+
const first = itemSegments[itemIndex]
|
|
1009
|
+
to = Math.min(itemSegments[itemIndex + 1] , lastSegmentIndex + 1)
|
|
1010
|
+
if (gapSegments[to - 1] !== 0) to--
|
|
1011
|
+
fragment = { itemIndex, gapBefore: gap, gapItemIndex, occupiedWidth: 0, start: { segmentIndex: i - first, graphemeIndex: 0 }, end: { segmentIndex: to - first, graphemeIndex: 0 } }
|
|
1012
|
+
fragments.push(fragment)
|
|
1013
|
+
lineW += gap
|
|
1014
|
+
gap = 0
|
|
1015
|
+
gapItemIndex = -1
|
|
1016
|
+
occupiedWidth = 0
|
|
1017
|
+
}
|
|
1018
|
+
occupiedWidth += widths[i]
|
|
1019
|
+
lineW += widths[i]
|
|
1020
|
+
}
|
|
1021
|
+
} else {
|
|
1022
|
+
for (let i = startSegmentIndex; i <= lastSegmentIndex; i++) {
|
|
1023
|
+
if (i === to) {
|
|
1024
|
+
if (fragment !== null) fragment.occupiedWidth = occupiedWidth
|
|
1025
|
+
const itemIndex = segmentItems[i]
|
|
1026
|
+
if (gapSegments[i] !== 0) {
|
|
1027
|
+
// A collapsed space is no tab and no halted mark, and no line starts with one or ends inside one.
|
|
1028
|
+
gap = widths[i] + (letterSpacing !== 0 && (segmentFlags[i] & SPACED) !== 0 ? letterSpacing : 0)
|
|
1029
|
+
gapItemIndex = itemIndex
|
|
1030
|
+
to = i + 1
|
|
1031
|
+
continue
|
|
1032
|
+
}
|
|
1033
|
+
// The fragment's segments end at `to`: the item's on the line, less the collapsed space that ends it.
|
|
1034
|
+
const first = itemSegments[itemIndex]
|
|
1035
|
+
to = Math.min(itemSegments[itemIndex + 1] , lastSegmentIndex + 1)
|
|
1036
|
+
if (gapSegments[to - 1] !== 0) to--
|
|
1037
|
+
const endsInside = to > endSegmentIndex
|
|
1038
|
+
fragment = {
|
|
1039
|
+
itemIndex,
|
|
1040
|
+
gapBefore: gap,
|
|
1041
|
+
gapItemIndex,
|
|
1042
|
+
occupiedWidth: 0,
|
|
1043
|
+
start: { segmentIndex: i - first, graphemeIndex: i === startSegmentIndex ? startGraphemeIndex : 0 },
|
|
1044
|
+
end: { segmentIndex: (endsInside ? endSegmentIndex : to) - first, graphemeIndex: endsInside ? endGraphemeIndex : 0 },
|
|
1045
|
+
}
|
|
1046
|
+
fragments.push(fragment)
|
|
1047
|
+
lineW += gap
|
|
1048
|
+
gap = 0
|
|
1049
|
+
gapItemIndex = -1
|
|
1050
|
+
occupiedWidth = 0
|
|
1051
|
+
}
|
|
1052
|
+
// A segment's width on the line, with the letter spacing after each of its graphemes, as a
|
|
1053
|
+
// fragment's width counts it.
|
|
1054
|
+
let w
|
|
1055
|
+
if ((i > startSegmentIndex || startGraphemeIndex === 0) && i < endSegmentIndex) {
|
|
1056
|
+
const startExtra = i === startSegmentIndex && lineStartExtras !== null ? lineStartExtras[i] : 0
|
|
1057
|
+
w = startExtra + ((segmentFlags[i] & KIND_BITS) !== TAB ? widths[i] : getItemTabAdvance(data, items, i, lineW + startExtra, tabsInAppUnits))
|
|
1058
|
+
if (letterSpacing !== 0 && (segmentFlags[i] & SPACED) !== 0) w += letterSpacing
|
|
1059
|
+
} else {
|
|
1060
|
+
w = i === startSegmentIndex ? firstPartWidth : lastPartWidth
|
|
1061
|
+
}
|
|
1062
|
+
// A mark that ends its item was halted where the line went on after it: the line is then
|
|
1063
|
+
// narrower than its segments up to the mark (ParagraphSegmentData, itemEndHalts), since what
|
|
1064
|
+
// follows a halted mark fits in less than the halt. Premise: what follows a mark on its line
|
|
1065
|
+
// takes no less than no room. Its gap is an item after the mark whose letter spacing is more
|
|
1066
|
+
// negative than its letters are wide, where a mark that wasn't halted gives its halt to the
|
|
1067
|
+
// line's last fragment; the line's width and breaks are the walker's either way. The 1e-6px
|
|
1068
|
+
// is room for float error: the walker adds the same advances in another order, so where
|
|
1069
|
+
// nothing after a mark that wasn't halted takes room, the segments up to it can add up to
|
|
1070
|
+
// more than the line by a few units in the last place of the line's width (one is 6e-14px on
|
|
1071
|
+
// a line of 300px, 6e-11px on one of 280,000px), and the mark would read as halted, its halt
|
|
1072
|
+
// going to the line's last fragment.
|
|
1073
|
+
if (itemEndHalts !== null && itemEndHalts[i] !== 0 && lineW + w - width > 1e-6) w -= itemEndHalts[i]
|
|
1074
|
+
occupiedWidth += w
|
|
1075
|
+
lineW += w
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
if (fragment !== null) fragment.occupiedWidth = occupiedWidth
|
|
1079
|
+
|
|
1080
|
+
let rest = lineW - width
|
|
1081
|
+
if (rest < 0 && fragments.length > 0) fragments[fragments.length - 1] .occupiedWidth -= rest
|
|
1082
|
+
const openingEdges = items.openingEdges
|
|
1083
|
+
for (let k = fragments.length - 1; k >= 0 && rest > 0; k--) {
|
|
1084
|
+
const last = fragments[k]
|
|
1085
|
+
// The edge of a padded opening stays painted: it is the first segment of its item, so of a fragment.
|
|
1086
|
+
const edge = openingEdges === null || last.start.graphemeIndex > 0 ? 0 : openingEdges[itemSegments[last.itemIndex] + last.start.segmentIndex]
|
|
1087
|
+
if (last.occupiedWidth > edge) {
|
|
1088
|
+
const part = Math.min(rest, last.occupiedWidth - edge)
|
|
1089
|
+
last.occupiedWidth -= part
|
|
1090
|
+
rest -= part
|
|
1091
|
+
}
|
|
1092
|
+
if (rest > 0 && last.gapBefore > 0) {
|
|
1093
|
+
const part = Math.min(rest, last.gapBefore)
|
|
1094
|
+
last.gapBefore -= part
|
|
1095
|
+
rest -= part
|
|
1096
|
+
// A gap taken whole is none, and so is one with under 1e-9px left, which could only be float
|
|
1097
|
+
// error. That needs a width the line leaves out, such as a mark's halt, to be as wide as the
|
|
1098
|
+
// gap to the last bits, which no rule makes it.
|
|
1099
|
+
if (last.gapBefore < 1e-9) {
|
|
1100
|
+
last.gapBefore = 0
|
|
1101
|
+
last.gapItemIndex = -1
|
|
1102
|
+
}
|
|
290
1103
|
}
|
|
1104
|
+
}
|
|
1105
|
+
return { fragments, width: Math.max(0, width), end }
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// The paragraph's segment a cursor names, or the segment count for one past the last item.
|
|
1109
|
+
function getSegmentIndex(flow , cursor ) {
|
|
1110
|
+
const { itemSegments } = flow
|
|
1111
|
+
return cursor.itemIndex < itemSegments.length ? itemSegments[cursor.itemIndex] + cursor.segmentIndex : flow.data.segmentFlags.length
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
export function layoutNextRichInlineLineRange(
|
|
1115
|
+
prepared ,
|
|
1116
|
+
maxWidth ,
|
|
1117
|
+
start = RICH_INLINE_START_CURSOR,
|
|
1118
|
+
) {
|
|
1119
|
+
const flow = getInternalPreparedRichInline(prepared)
|
|
1120
|
+
const safeWidth = Math.max(0, maxWidth)
|
|
1121
|
+
if (start.itemIndex === 0 && start.segmentIndex === 0 && start.graphemeIndex === 0 && fitsWhole(flow, safeWidth)) {
|
|
1122
|
+
return flow.onlyItem >= 0 ? createOnlyItemLine(flow, flow.wholeWidth , flow.wholeStart, 0, flow.wholeEnd, 0) : createLine(flow, flow.wholeWidth , flow.wholeStart, 0, flow.wholeEnd, 0)
|
|
1123
|
+
}
|
|
1124
|
+
const lineEnd = { segmentIndex: getSegmentIndex(flow, start), graphemeIndex: start.graphemeIndex }
|
|
1125
|
+
if (!normalizePreparedLineStart(flow.data, lineEnd)) return null
|
|
1126
|
+
const startSegmentIndex = lineEnd.segmentIndex
|
|
1127
|
+
const startGraphemeIndex = lineEnd.graphemeIndex
|
|
1128
|
+
const width = stepPreparedLineGeometryFromStart(flow.data, lineEnd, safeWidth)
|
|
1129
|
+
if (width === null) return null
|
|
1130
|
+
return flow.onlyItem >= 0 ? createOnlyItemLine(flow, width, startSegmentIndex, startGraphemeIndex, lineEnd.segmentIndex, lineEnd.graphemeIndex)
|
|
1131
|
+
: createLine(flow, width, startSegmentIndex, startGraphemeIndex, lineEnd.segmentIndex, lineEnd.graphemeIndex)
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// Where units [from, to) of a paragraph's normalized text are in the text of the item that starts
|
|
1135
|
+
// at `itemStart`, from the offset in the paragraph's text each unit comes from, where they aren't
|
|
1136
|
+
// one stretch of it: Gecko removes a line feed between two ideographs, and white space after a bidi
|
|
1137
|
+
// control that follows white space. Else null.
|
|
1138
|
+
function getSourceUnits(offsets , from , to , itemStart ) {
|
|
1139
|
+
if (to <= from || offsets[to - 1] - offsets[from] === to - 1 - from) return null
|
|
1140
|
+
const units = []
|
|
1141
|
+
for (let unit = from; unit < to; unit++) units.push(offsets[unit] - itemStart)
|
|
1142
|
+
return units
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Per segment of a paragraph that is one text's handle, where its text starts and ends in the
|
|
1146
|
+
// item's.
|
|
1147
|
+
function findSegmentSources(flow ) {
|
|
1148
|
+
const { segments } = flow.data
|
|
1149
|
+
const offsets = alignToSource(flow.text, segments.join(''), getEngineProfile().lineBreakScan)
|
|
1150
|
+
const sourceStarts = []
|
|
1151
|
+
const sourceEnds = []
|
|
1152
|
+
const sourceUnits = []
|
|
1153
|
+
for (let i = 0, at = 0; i < segments.length; at += segments[i++] .length) {
|
|
1154
|
+
const end = offsets[at + segments[i] .length - 1] + 1
|
|
1155
|
+
sourceStarts.push(offsets[at] )
|
|
1156
|
+
sourceEnds.push(flow.text.charCodeAt(end - 1) === 0x0D && flow.text.charCodeAt(end) === 0x0A && segments[i] === '\n' ? end + 1 : end)
|
|
1157
|
+
sourceUnits.push(getSourceUnits(offsets, at, at + segments[i] .length, 0))
|
|
1158
|
+
}
|
|
1159
|
+
flow.sourceStarts = sourceStarts
|
|
1160
|
+
flow.sourceEnds = sourceEnds
|
|
1161
|
+
flow.sourceUnits = sourceUnits
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Where unit `unit` of a segment's text is in its item's text.
|
|
1165
|
+
function getSourceOffset(flow , segmentIndex , unit ) {
|
|
1166
|
+
const units = flow.sourceUnits === null ? null : flow.sourceUnits[segmentIndex]
|
|
1167
|
+
return units === null ? flow.sourceStarts [segmentIndex] + unit : units[unit]
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// Bridge from cheap range walking to full fragment text. Lets callers do
|
|
1171
|
+
// shrinkwrap/virtualization/probing work first, then only pay for text on the
|
|
1172
|
+
// lines they actually render.
|
|
1173
|
+
export function materializeRichInlineLineRange(
|
|
1174
|
+
prepared ,
|
|
1175
|
+
line ,
|
|
1176
|
+
) {
|
|
1177
|
+
const flow = getInternalPreparedRichInline(prepared)
|
|
1178
|
+
const { data, itemSegments } = flow
|
|
1179
|
+
if (flow.sourceStarts === null) findSegmentSources(flow)
|
|
1180
|
+
const sourceStarts = flow.sourceStarts
|
|
1181
|
+
const sourceEnds = flow.sourceEnds
|
|
1182
|
+
const lineEndSegmentIndex = getSegmentIndex(flow, line.end)
|
|
1183
|
+
const fragments = []
|
|
1184
|
+
|
|
1185
|
+
for (let i = 0; i < line.fragments.length; i++) {
|
|
1186
|
+
const fragment = line.fragments[i]
|
|
1187
|
+
const first = itemSegments[fragment.itemIndex]
|
|
1188
|
+
if (first === undefined || fragment.itemIndex + 1 >= itemSegments.length) throw new Error('Missing rich-text inline item for fragment')
|
|
1189
|
+
// A range kept from a longer paragraph can end past its item in this one: its text stops at the item's end.
|
|
1190
|
+
const itemEnd = itemSegments[fragment.itemIndex + 1]
|
|
1191
|
+
const startSegmentIndex = Math.min(first + fragment.start.segmentIndex, itemEnd)
|
|
1192
|
+
const startGraphemeIndex = fragment.start.graphemeIndex
|
|
1193
|
+
const endSegmentIndex = Math.min(first + fragment.end.segmentIndex, itemEnd)
|
|
1194
|
+
const endGraphemeIndex = endSegmentIndex < itemEnd ? fragment.end.graphemeIndex : 0
|
|
1195
|
+
// Only the fragment a line ends with shows the hyphen of a soft hyphen it ends at.
|
|
1196
|
+
const endsLine = i === line.fragments.length - 1 && endSegmentIndex === lineEndSegmentIndex && endGraphemeIndex === line.end.graphemeIndex
|
|
1197
|
+
let sourceStart = startSegmentIndex < itemEnd ? sourceStarts[startSegmentIndex] : startSegmentIndex > first ? sourceEnds[startSegmentIndex - 1] : 0
|
|
1198
|
+
if (startGraphemeIndex > 0 && startSegmentIndex < itemEnd) sourceStart = getSourceOffset(flow, startSegmentIndex, getGraphemeEnds(data, startSegmentIndex)[startGraphemeIndex - 1] )
|
|
1199
|
+
let sourceEnd = sourceStart
|
|
1200
|
+
if (endGraphemeIndex > 0) sourceEnd = getSourceOffset(flow, endSegmentIndex, getGraphemeEnds(data, endSegmentIndex)[endGraphemeIndex - 1] - 1) + 1
|
|
1201
|
+
else if (endSegmentIndex > startSegmentIndex) sourceEnd = sourceEnds[endSegmentIndex - 1]
|
|
1202
|
+
fragments.push({
|
|
1203
|
+
itemIndex: fragment.itemIndex,
|
|
1204
|
+
text: endsLine ? buildLineTextFromRange(data, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex)
|
|
1205
|
+
: buildRangeText(data, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex),
|
|
1206
|
+
gapBefore: fragment.gapBefore,
|
|
1207
|
+
gapItemIndex: fragment.gapItemIndex,
|
|
1208
|
+
occupiedWidth: fragment.occupiedWidth,
|
|
1209
|
+
start: fragment.start,
|
|
1210
|
+
end: fragment.end,
|
|
1211
|
+
sourceStart,
|
|
1212
|
+
sourceEnd,
|
|
1213
|
+
})
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
return {
|
|
1217
|
+
fragments,
|
|
1218
|
+
width: line.width,
|
|
1219
|
+
end: line.end,
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
export function walkRichInlineLineRanges(
|
|
1224
|
+
prepared ,
|
|
1225
|
+
maxWidth ,
|
|
1226
|
+
onLine ,
|
|
1227
|
+
) {
|
|
1228
|
+
const flow = getInternalPreparedRichInline(prepared)
|
|
1229
|
+
const safeWidth = Math.max(0, normalizeMaxWidth(maxWidth))
|
|
1230
|
+
if (fitsWhole(flow, safeWidth)) {
|
|
1231
|
+
onLine(flow.onlyItem >= 0 ? createOnlyItemLine(flow, flow.wholeWidth , flow.wholeStart, 0, flow.wholeEnd, 0) : createLine(flow, flow.wholeWidth , flow.wholeStart, 0, flow.wholeEnd, 0))
|
|
1232
|
+
return 1
|
|
1233
|
+
}
|
|
1234
|
+
return walkPreparedLinesRaw(flow.data, safeWidth, (width, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex) => {
|
|
1235
|
+
onLine(flow.onlyItem >= 0 ? createOnlyItemLine(flow, width, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex)
|
|
1236
|
+
: createLine(flow, width, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex))
|
|
1237
|
+
})
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
export function measureRichInlineStats(
|
|
1241
|
+
prepared ,
|
|
1242
|
+
maxWidth ,
|
|
1243
|
+
) {
|
|
1244
|
+
const flow = getInternalPreparedRichInline(prepared)
|
|
1245
|
+
const safeWidth = Math.max(0, normalizeMaxWidth(maxWidth))
|
|
1246
|
+
if (fitsWhole(flow, safeWidth)) return { lineCount: 1, maxLineWidth: Math.max(0, flow.wholeWidth ) }
|
|
1247
|
+
const stats = { lineCount: 0, maxLineWidth: 0 }
|
|
1248
|
+
walkPreparedLinesRaw(flow.data, safeWidth, undefined, stats)
|
|
1249
|
+
return stats
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Rapier local extension: exact segment/source facts for paragraph-wide fitting.
|
|
1253
|
+
// Shares analysis, shaping and source alignment with the wrapping entry point.
|
|
1254
|
+
function paragraphSegments(flow) {
|
|
1255
|
+
if (flow.sourceStarts === null) findSegmentSources(flow)
|
|
1256
|
+
return flow
|
|
1257
|
+
}
|
|
1258
|
+
export function prepareRichInlineSegments(items, options) {
|
|
1259
|
+
return prepareRichInline(items, {...options, rapierParagraph: true})
|
|
291
1260
|
}
|