rapier-markdown-kit 1.1.102 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +5 -2
  2. package/README.md +245 -54
  3. package/dist/agent/vendor/hyphen-en-us/LICENSE +14 -0
  4. package/dist/agent/vendor/hyphen-en-us/SOURCE.json +33 -0
  5. package/dist/agent/vendor/hyphen-en-us/hyph-en-us.hyp.txt +14 -0
  6. package/dist/agent/vendor/hyphen-en-us/hyph-en-us.pat.txt +4938 -0
  7. package/dist/agent/vendor/hyphen-en-us/patterns.js +25 -0
  8. package/dist/agent/vendor/pretext/LICENSE +208 -0
  9. package/dist/agent/vendor/pretext/NOTICE +26 -13
  10. package/dist/agent/vendor/pretext/SOURCE.json +213 -45
  11. package/dist/agent/vendor/pretext/analysis.js +675 -1224
  12. package/dist/agent/vendor/pretext/entry-geometry.js +183 -0
  13. package/dist/agent/vendor/pretext/gecko-line-breaks.js +688 -0
  14. package/dist/agent/vendor/pretext/generated/engine-break-data.js +57 -0
  15. package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +80 -0
  16. package/dist/agent/vendor/pretext/graphemes.js +98 -0
  17. package/dist/agent/vendor/pretext/han-kerning.js +253 -0
  18. package/dist/agent/vendor/pretext/layout.js +352 -290
  19. package/dist/agent/vendor/pretext/line-break.js +1154 -588
  20. package/dist/agent/vendor/pretext/line-breaks.js +892 -0
  21. package/dist/agent/vendor/pretext/line-text.js +81 -39
  22. package/dist/agent/vendor/pretext/measurement.js +1105 -176
  23. package/dist/agent/vendor/pretext/prepare.js +807 -0
  24. package/dist/agent/vendor/pretext/rich-inline.js +1242 -273
  25. package/dist/agent/will.mjs +2 -0
  26. package/dist/images/gif.mjs +47 -0
  27. package/dist/images/playback.mjs +101 -0
  28. package/dist/kit/assets.mjs +5 -1
  29. package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
  30. package/dist/kit/conformance/04-wrap-box.md +1 -1
  31. package/dist/kit/conformance/05-behind.md +1 -1
  32. package/dist/kit/conformance/06-front.md +1 -1
  33. package/dist/kit/conformance/07-rotate-raster.md +1 -1
  34. package/dist/kit/conformance/08-drawing-rotation.md +1 -1
  35. package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
  36. package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
  37. package/dist/kit/conformance/10-both-sides.md +1 -1
  38. package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
  39. package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
  40. package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
  41. package/dist/kit/conformance/README.md +19 -28
  42. package/dist/kit/conformance/run.mjs +13 -11
  43. package/dist/kit/export-fonts.mjs +26 -11
  44. package/dist/kit/index.mjs +1 -0
  45. package/dist/kit/ledger/carried.mjs +17 -10
  46. package/dist/kit/ledger/data.mjs +73 -13
  47. package/dist/kit/ledger/format.mjs +176 -54
  48. package/dist/kit/ledger/hash.mjs +91 -18
  49. package/dist/kit/ledger/history.mjs +156 -116
  50. package/dist/kit/ledger/inverse-source.mjs +158 -19
  51. package/dist/kit/ledger/journal-records.mjs +292 -67
  52. package/dist/kit/ledger/merge.mjs +4 -3
  53. package/dist/kit/model.mjs +1 -1
  54. package/dist/kit/pdf-glyphs.mjs +4 -4
  55. package/dist/kit/pdf-vectors.mjs +4 -1
  56. package/dist/kit/rapier-ledger.mjs +2 -2
  57. package/dist/kit/render-ink.mjs +1 -1
  58. package/dist/kit/render-markdown.mjs +400 -148
  59. package/dist/kit/render-print.mjs +131 -55
  60. package/dist/kit/render-sanitize.mjs +30 -7
  61. package/dist/kit/render-work.mjs +99 -20
  62. package/dist/kit/render.mjs +133 -140
  63. package/dist/kit/typography.mjs +2 -0
  64. package/dist/layout/hyphenation.mjs +127 -0
  65. package/dist/layout/line-plan.mjs +88 -96
  66. package/dist/layout/model.mjs +71 -88
  67. package/dist/layout/paragraph-fit.mjs +191 -0
  68. package/dist/layout/preparation.mjs +373 -0
  69. package/dist/layout/prepared-paragraph.mjs +223 -0
  70. package/dist/layout/typography.mjs +345 -111
  71. package/dist/packages/rapier-diff/index.mjs +359 -224
  72. package/dist/spec/document-fonts.mjs +14 -5
  73. package/dist/spec/document-settings.mjs +11 -164
  74. package/dist/spec/html-blocks.mjs +618 -79
  75. package/dist/spec/md-assets.mjs +178 -36
  76. package/dist/spec/md-layout.mjs +13 -2
  77. package/dist/spec/md-marks.mjs +220 -127
  78. package/dist/spec/md-source.mjs +157 -0
  79. package/package.json +2 -1
  80. package/style.css +15 -50
  81. package/dist/agent/vendor/pretext/bidi.js +0 -151
  82. package/dist/agent/vendor/pretext/generated/bidi-data.js +0 -3831
@@ -1,636 +1,1202 @@
1
- /*! Pretext 0.0.9 | MIT | Copyright (c) 2026 Pretext contributors */
2
- import { getEngineProfile } from './measurement.js';
3
- export function isDiscretionaryLineEnd(kinds, endSegmentIndex, endGraphemeIndex) {
4
- return endGraphemeIndex === 0 && endSegmentIndex > 0 && endSegmentIndex < kinds.length && kinds[endSegmentIndex - 1] === 'soft-hyphen';
1
+ /*! Pretext Unreleased (bac1774c0) | MIT; BSD/Unicode/ICU notices in LICENSE | Copyright (c) 2026 Pretext contributors */
2
+ import {
3
+ CONTROL,
4
+ HARD_BREAK,
5
+ KIND_BITS,
6
+ OBJECT,
7
+ PRESERVED_SPACE,
8
+ RETURNABLE,
9
+ SOFT_HYPHEN,
10
+ SPACE,
11
+ SPACED,
12
+ TAB,
13
+ TEXT,
14
+ UNBROKEN,
15
+ ZERO_WIDTH_BREAK,
16
+ ZERO_WIDTH_GLUE,
17
+ } from './analysis.js'
18
+
19
+ import { getEngineProfile } from './measurement.js'
20
+ import { getFreshLineEnd, getSegmentEntryWidth, } from './entry-geometry.js'
21
+
22
+ const BREAK_AFTER_KINDS = 1 << SPACE | 1 << ZERO_WIDTH_BREAK | 1 << SOFT_HYPHEN | 1 << PRESERVED_SPACE | 1 << TAB
23
+
24
+ // The prepared handle's line-break data: parallel arrays per segment.
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
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+
66
+
67
+ // What the line walkers lay out: a text's handle, or the handle of a rich-inline paragraph that
68
+ // isn't one text's handle (src/rich-inline.ts), which also holds what its segments have of their
69
+ // own. A text's handle has no `items`, so its type, PreparedLineBreakData, doesn't name it.
70
+
71
+
72
+ // What a rich-inline paragraph's items give its segments, per segment, each list null where no
73
+ // segment has a value of its own:
74
+ // - `hyphenWidths`, `tabStopAdvances`, `minimumTabAdvances`: the hyphen a soft hyphen paints, the
75
+ // advance between a tab's stops and the least a tab advances, in the item's font, where two
76
+ // items differ in one. A walk reads them, and the handle's own three, at a soft hyphen, under
77
+ // soft-hyphen contexts and at a tab, so a paragraph whose text holds no soft hyphen and no
78
+ // preserved tab has none, and its handle's own three are 0;
79
+ // - `hyphenRooms`: the room a line that ends at the break before the segment leaves for a hyphen,
80
+ // where it returns there from a soft hyphen whose hyphen doesn't fit, in a paragraph with a soft
81
+ // hyphen under a profile that leaves such room (getHyphenRooms in src/rich-inline.ts), with one
82
+ // more entry for the break at the paragraph's end;
83
+ // - `itemEndHalts`: the line-end trim of a segment that ends its item with a closing mark Blink
84
+ // halts at a line's end, or the trim its retry between graphemes gives one (overflowLineEndTrims),
85
+ // which the mark keeps where the line goes on after it (prepareRichInline() in
86
+ // src/rich-inline.ts);
87
+ // - `insideExtras`, `fillExtras`: the item's extraWidth where a line that starts inside the
88
+ // segment pays it, or starts at it and fills it grapheme by grapheme, as a line that starts
89
+ // with the whole segment pays lineStartExtras;
90
+ // - `openingEdges`: the width of a segment that is the start edge of a padded item's opening
91
+ // (getOpeningFit in src/rich-inline.ts), which a line that takes it paints whole, whatever of
92
+ // it the line fitted: an object's line-end trim is the part its line doesn't fit, and an edge
93
+ // the engine fits none of is a preserved space, which takes no room in the run of preserved
94
+ // spaces and tabs it is in;
95
+ // - `emptyObjectSpaces`, `emptyObjectReturns`: where the engine places an object of width 0
96
+ // though it sticks out of the line, for each such object what a line reads of the text around
97
+ // it (setEmptyObjectFacts in src/rich-inline.ts): the width of the white space that ends the
98
+ // text before it as its last text frame's own, 0 where that text ends in other white space
99
+ // and -1 where it ends in none, and 1 where a line with a break before the object goes back
100
+ // to it, as what follows the object would stick out too.
101
+
102
+
103
+
104
+
105
+
106
+
107
+
108
+
109
+
110
+
111
+
112
+
113
+
114
+
115
+
116
+
117
+
118
+
119
+
120
+
121
+
122
+ function breaksAfterKind(kind ) {
123
+ return (1 << kind & BREAK_AFTER_KINDS) !== 0
5
124
  }
6
- function consumesAtLineStart(kind) {
7
- return kind === 'space' || kind === 'zero-width-break' || kind === 'soft-hyphen';
125
+
126
+ // A line ends at a chosen soft hyphen, and paints its hyphen, where the line's end
127
+ // cursor is the start of the segment after one. A soft hyphen that ends the text is
128
+ // consumed with its line and chosen by no wrap, so the cursor at the text's end
129
+ // paints none.
130
+ export function isDiscretionaryLineEnd(
131
+ segmentFlags ,
132
+ endSegmentIndex ,
133
+ endGraphemeIndex ,
134
+ ) {
135
+ return endGraphemeIndex === 0 && endSegmentIndex > 0 && endSegmentIndex < segmentFlags.length &&
136
+ (segmentFlags[endSegmentIndex - 1] & KIND_BITS) === SOFT_HYPHEN
8
137
  }
9
- function breaksAfter(kind) {
10
- return kind === 'space' || kind === 'preserved-space' || kind === 'tab' || kind === 'zero-width-break' || kind === 'soft-hyphen';
138
+
139
+ // At a paragraph or hard-break start, ZWSP is real source: it establishes the
140
+ // line and offers a break after it. UAX #14 forbids an ordinary break before
141
+ // ZWSP. After a forced overflow break browsers can still give ZWSP its own line;
142
+ // that start is consumed here.
143
+ function consumesAtLineStart(kind , atChunkStart ) {
144
+ return kind === SPACE || kind === SOFT_HYPHEN || (kind === ZERO_WIDTH_BREAK && !atChunkStart)
11
145
  }
12
- function normalizeLineStartSegmentIndex(prepared, segmentIndex, endSegmentIndex = prepared.widths.length) {
13
- while(segmentIndex < endSegmentIndex){
14
- const kind = prepared.kinds[segmentIndex];
15
- if (!consumesAtLineStart(kind)) break;
16
- segmentIndex++;
17
- }
18
- return segmentIndex;
19
- }
20
- function getTabAdvance(lineWidth, tabStopAdvance) {
21
- if (tabStopAdvance <= 0) return 0;
22
- const remainder = lineWidth % tabStopAdvance;
23
- if (Math.abs(remainder) <= 1e-6) return tabStopAdvance;
24
- return tabStopAdvance - remainder;
25
- }
26
- function getLeadingLetterSpacing(prepared, hasContent, segmentIndex) {
27
- return prepared.letterSpacing !== 0 && hasContent && prepared.spacingGraphemeCounts[segmentIndex] > 0 ? prepared.letterSpacing : 0;
28
- }
29
- function getLineEndContribution(leadingSpacing, segmentContribution) {
30
- return segmentContribution === 0 ? 0 : leadingSpacing + segmentContribution;
31
- }
32
- function getTabTrailingLetterSpacing(prepared, segmentIndex) {
33
- return prepared.letterSpacing !== 0 && prepared.spacingGraphemeCounts[segmentIndex] > 0 ? prepared.letterSpacing : 0;
146
+
147
+ // A tab's advance from `position` on the line: to the next stop, or to the one after where
148
+ // the next is under the minimum away. A tab on a stop is a whole stop from the next, so each
149
+ // tab of a run takes one. Stops no wider than 0, under a letter spacing of minus a space or
150
+ // less, leave a tab no advance: Gecko gives tabs no width there (GetSpacingInternal,
151
+ // nsTextFrame.cpp:4262-4264), and Blink's, negative by Font::TabWidth, is clamped to 0 where
152
+ // the tab's item is placed (ClampNegativeToZero, line_breaker.cc:1486, 1703). Gecko counts in
153
+ // whole app units (EngineProfile's tabsInAppUnits), so a tab exactly the minimum from its
154
+ // stop takes it, as many do: in Arial and Helvetica a space is half a `0`. Canvas sums come
155
+ // within float error of such a tie.
156
+ function getTabAdvance(position , tabStopAdvance , minimumAdvance , appUnits ) {
157
+ if (appUnits) {
158
+ const stop = Math.round(tabStopAdvance * 60)
159
+ if (stop <= 0) return 0
160
+ const x = Math.round(position * 60)
161
+ return (Math.ceil((x + Math.round(minimumAdvance * 120) / 2) / stop) * stop - x) / 60
162
+ }
163
+ if (tabStopAdvance <= 0) return 0
164
+
165
+ // How far the tab is past the stop before it, by a division and a floor, which counts back
166
+ // from a negative position too. `%` gives the same, within float error, but on numbers that
167
+ // aren't whole it is a call to the C library's fmod, which was nearly all a tab's arithmetic
168
+ // cost (RESEARCH.md, JavaScript Engines).
169
+ const pastStop = position - Math.floor(position / tabStopAdvance) * tabStopAdvance
170
+ const advance = tabStopAdvance - pastStop
171
+ return advance < minimumAdvance ? advance + tabStopAdvance : advance
34
172
  }
35
- function getWholeSegmentFitContribution(prepared, kind, segmentIndex, leadingSpacing, segmentWidth) {
36
- const segmentContribution = kind === 'tab' ? segmentWidth + getTabTrailingLetterSpacing(prepared, segmentIndex) : prepared.lineEndFitAdvances[segmentIndex];
37
- return getLineEndContribution(leadingSpacing, segmentContribution);
173
+
174
+ // The advance of tab segment `index` of a rich-inline paragraph, which the line reaches at
175
+ // `lineWidth`: to the next stop of its item's font. Tab stops count from the line's start in
176
+ // every engine, never from an item's (Blink's line_breaker.cc:2963-2971, WebKit's pen position,
177
+ // Gecko's CalcTabWidths, nsTextFrame.cpp:4298-4378). A padded item's text starts after its start
178
+ // edge, half its extraWidth, all of which `lineWidth` counts, for a tab that starts the line too
179
+ // (lineStartExtras): the other half is the end edge, which follows the item's text on the line.
180
+ // Where the paragraph's items differ in letter spacing, a tab's width holds the gap after it, as
181
+ // every segment's holds its own (src/rich-inline.ts); else it is 0.
182
+ export function getItemTabAdvance(prepared , items , index , lineWidth , appUnits ) {
183
+ const stopAdvance = items.tabStopAdvances === null ? prepared.tabStopAdvance : items.tabStopAdvances[index]
184
+ const minimumAdvance = items.minimumTabAdvances === null ? prepared.minimumTabAdvance : items.minimumTabAdvances[index]
185
+ const endEdge = items.insideExtras === null ? 0 : items.insideExtras[index] / 2
186
+ return getTabAdvance(lineWidth - endEdge, stopAdvance, minimumAdvance, appUnits) + prepared.widths[index]
38
187
  }
39
- function getBreakOpportunityFitContribution(prepared, kind, segmentIndex, leadingSpacing) {
40
- const segmentContribution = kind === 'tab' ? 0 : prepared.lineEndFitAdvances[segmentIndex];
41
- return getLineEndContribution(leadingSpacing, segmentContribution);
188
+
189
+ // Where a line that holds only an overflowing grapheme ends: after that grapheme and
190
+ // the graphemes after it that can't start a line, up to `endGraphemeIndex`.
191
+ function getOverflowingFirstGraphemeEnd(
192
+ prepared ,
193
+ segmentIndex ,
194
+ graphemeIndex ,
195
+ endGraphemeIndex ,
196
+ ) {
197
+ const prohibitions = prepared.lineStartProhibitions?.[segmentIndex] ?? null
198
+ let end = graphemeIndex + 1
199
+ while (prohibitions !== null && end < endGraphemeIndex && prohibitions[end] === 1) end++
200
+ return end
42
201
  }
43
- function getLineEndPaintContribution(prepared, kind, segmentIndex, leadingSpacing, segmentWidth) {
44
- const segmentContribution = kind === 'tab' ? segmentWidth : prepared.lineEndPaintAdvances[segmentIndex];
45
- return getLineEndContribution(leadingSpacing, segmentContribution);
202
+
203
+ function getTerminalLetterSpacing(
204
+ prepared ,
205
+ openingEdges ,
206
+ hangingKinds ,
207
+ startSegmentIndex ,
208
+ startGraphemeIndex ,
209
+ endSegmentIndex ,
210
+ endGraphemeIndex ,
211
+ ) {
212
+ const { letterSpacing, segmentFlags } = prepared
213
+ if (letterSpacing === 0) return 0
214
+
215
+ if (endGraphemeIndex > 0) return (segmentFlags[endSegmentIndex] & SPACED) !== 0 ? letterSpacing : 0
216
+
217
+ if (isDiscretionaryLineEnd(segmentFlags, endSegmentIndex, endGraphemeIndex)) return 0
218
+ // A run of preserved spaces and tabs that hangs where the line wraps already
219
+ // charged the gap after the glyph before it. Gecko doesn't hang tabs.
220
+ if (endSegmentIndex > startSegmentIndex && endSegmentIndex < segmentFlags.length && (segmentFlags[endSegmentIndex] & KIND_BITS) !== HARD_BREAK &&
221
+ (1 << (segmentFlags[endSegmentIndex - 1] & KIND_BITS) & hangingKinds) !== 0) return 0
222
+
223
+ for (let i = endSegmentIndex - 1; i >= startSegmentIndex; i--) {
224
+ const flags = segmentFlags[i]
225
+ const kind = flags & KIND_BITS
226
+ // Segments that take no letter spacing, such as zero-width glue or marks
227
+ // shaped on the grapheme before them, leave that grapheme's gap last. A tab
228
+ // that takes none follows that gap, which its stop counts from. An object
229
+ // leaves none after itself, nor does the edge of a padded opening.
230
+ if (kind === SPACE || (kind !== CONTROL && kind !== TAB && (flags & SPACED) === 0 && kind !== OBJECT && (openingEdges === null || openingEdges[i] === 0))) continue
231
+
232
+ if (i === startSegmentIndex && startGraphemeIndex > 0) return letterSpacing
233
+
234
+ return (flags & SPACED) !== 0 ? letterSpacing : 0
235
+ }
236
+
237
+ return 0
46
238
  }
47
- function getBreakableGraphemeAdvance(prepared, hasContent, baseAdvance) {
48
- return prepared.letterSpacing !== 0 && hasContent ? baseAdvance + prepared.letterSpacing : baseAdvance;
239
+
240
+ // The width a paragraph is laid out at: `NaN`, which a typed caller can pass, is unbounded.
241
+ // Every comparison fails at `NaN`, and the line loops ask some whether a segment fits and
242
+ // others whether it overflows, so the line APIs called once for a paragraph pass their width
243
+ // through here and the loops stay written for numbers. The streams, called once for each
244
+ // line, take their width as given (RESEARCH.md, Decisions Log, 2026-10-02).
245
+ export function normalizeMaxWidth(maxWidth ) {
246
+ return maxWidth <= Infinity ? maxWidth : Infinity
49
247
  }
50
- function getBreakableCandidateFitWidth(prepared, candidatePaintWidth) {
51
- return prepared.letterSpacing === 0 ? candidatePaintWidth : candidatePaintWidth + prepared.letterSpacing;
52
- }
53
- function getNextPreferredBreakIndex(preferredBreaks, preferredBreakIndex, graphemeEnd) {
54
- let lo = preferredBreakIndex;
55
- if (lo >= preferredBreaks.length || preferredBreaks[lo] >= graphemeEnd) return lo;
56
- let hi = preferredBreaks.length;
57
- lo++;
58
- while(lo < hi){
59
- const mid = Math.floor((lo + hi) / 2);
60
- if (preferredBreaks[mid] < graphemeEnd) lo = mid + 1;
61
- else hi = mid;
248
+
249
+ // Mutates `cursor` to the next renderable line start. False when no line remains.
250
+ // A chunk runs to a hard break or the end of the text, and the hard break ends a
251
+ // line however little the chunk holds: a chunk holding only its hard break, or only
252
+ // source a line start consumes before it, such as soft hyphens, is an empty line,
253
+ // which starts at its hard break. The rest of a chunk after a line that wrapped
254
+ // inside it is no line of its own when a line start consumes all of it.
255
+ export function normalizePreparedLineStart(
256
+ prepared ,
257
+ cursor ,
258
+ ) {
259
+ const { segmentFlags } = prepared
260
+ const segmentCount = segmentFlags.length
261
+ let segmentIndex = cursor.segmentIndex
262
+ if (segmentIndex >= segmentCount) return false
263
+ if (cursor.graphemeIndex > 0) return true
264
+
265
+ let atChunkStart = segmentIndex === 0 || (segmentFlags[segmentIndex - 1] & KIND_BITS) === HARD_BREAK
266
+ while (true) {
267
+ const kind = segmentFlags[segmentIndex] & KIND_BITS
268
+ if (kind === HARD_BREAK) {
269
+ if (atChunkStart) {
270
+ cursor.segmentIndex = segmentIndex
271
+ cursor.graphemeIndex = 0
272
+ return true
273
+ }
274
+ if (++segmentIndex >= segmentCount) return false
275
+ cursor.segmentIndex = segmentIndex
276
+ cursor.graphemeIndex = 0
277
+ atChunkStart = true
278
+ } else if (consumesAtLineStart(kind, atChunkStart)) {
279
+ if (++segmentIndex >= segmentCount) return false
280
+ } else {
281
+ cursor.segmentIndex = segmentIndex
282
+ cursor.graphemeIndex = 0
283
+ return true
62
284
  }
63
- return lo;
285
+ }
64
286
  }
65
- function getTerminalLetterSpacing(prepared, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex) {
66
- if (prepared.letterSpacing === 0) return 0;
67
- if (endGraphemeIndex > 0) {
68
- return prepared.spacingGraphemeCounts[endSegmentIndex] > 0 ? prepared.letterSpacing : 0;
69
- }
70
- if (isDiscretionaryLineEnd(prepared.kinds, endSegmentIndex, endGraphemeIndex)) return 0;
71
- for(let i = endSegmentIndex - 1; i >= startSegmentIndex; i--){
72
- const kind = prepared.kinds[i];
73
- if (kind === 'space' || kind === 'zero-width-break' || kind === 'hard-break' || kind === 'soft-hyphen') continue;
74
- if (i === startSegmentIndex && startGraphemeIndex > 0) {
75
- return prepared.letterSpacing;
76
- }
77
- return prepared.spacingGraphemeCounts[i] > 0 ? prepared.letterSpacing : 0;
287
+
288
+ // Walks every line of the text into `stats`, visiting each, and returns the line count.
289
+ export function walkPreparedLinesRaw(
290
+ prepared ,
291
+ maxWidth ,
292
+ onLine ,
293
+ stats = { lineCount: 0, maxLineWidth: 0 },
294
+ ) {
295
+ const cursor = { segmentIndex: 0, graphemeIndex: 0 }
296
+ if (!prepared.simpleLineWalkFastPath) {
297
+ if (normalizePreparedLineStart(prepared, cursor)) walkPreparedComplexLines(prepared, cursor, maxWidth, onLine, stats)
298
+ return stats.lineCount
299
+ }
300
+ // A fast-path handle is one chunk of text, spaces and ZWSPs, so each line steps
301
+ // from where the last one ended, past what a line can't start with. One loop for
302
+ // both walkers, normalizing each line start and stepping through
303
+ // stepPreparedLineGeometryFromStart(), walked chat 6 to 32% slower in Chrome and
304
+ // Firefox (RESEARCH.md, Keeping Work Bounded).
305
+ const { segmentFlags } = prepared
306
+ const segmentCount = segmentFlags.length
307
+ while (true) {
308
+ let startSegmentIndex = cursor.segmentIndex
309
+ const atTextStart = startSegmentIndex === 0
310
+ while (startSegmentIndex < segmentCount && consumesAtLineStart(segmentFlags[startSegmentIndex] & KIND_BITS, atTextStart)) {
311
+ startSegmentIndex++
78
312
  }
79
- return 0;
80
- }
81
- function finalizeLinePaintWidth(prepared, width, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex) {
82
- return width + getTerminalLetterSpacing(prepared, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex);
313
+ if (startSegmentIndex >= segmentCount) return stats.lineCount
314
+ const startGraphemeIndex = cursor.graphemeIndex
315
+ cursor.segmentIndex = startSegmentIndex
316
+ const width = stepPreparedSimpleLineGeometry(prepared, cursor, maxWidth)
317
+ stats.lineCount++
318
+ if (width > stats.maxLineWidth) stats.maxLineWidth = width
319
+ onLine?.(width, startSegmentIndex, startGraphemeIndex, cursor.segmentIndex, cursor.graphemeIndex)
320
+ }
83
321
  }
84
- function findChunkIndexForStart(prepared, segmentIndex) {
85
- let lo = 0;
86
- let hi = prepared.chunks.length;
87
- while(lo < hi){
88
- const mid = Math.floor((lo + hi) / 2);
89
- if (segmentIndex < prepared.chunks[mid].consumedEndSegmentIndex) {
90
- hi = mid;
91
- } else {
92
- lo = mid + 1;
93
- }
322
+
323
+ // layout()'s count: the simple stepper's lines as one numeric loop, with no
324
+ // cursor and no per-line call. Every segment boundary of a fast-path handle is
325
+ // a break, so an overflowing space or ZWSP ends its line and any other segment
326
+ // starts the next one. The full walker costs three to five times as much per
327
+ // segment, so one walker for all text was rejected (RESEARCH.md, Decisions Log,
328
+ // 2026-09-24).
329
+ export function countPreparedLines(prepared , maxWidth ) {
330
+ // The loop takes no overflow trims, which the stepper takes for a line's first segment.
331
+ if (!prepared.simpleLineWalkFastPath || prepared.overflowLineEndTrims !== null) {
332
+ return prepared.simpleLineCountFastPath ? countSteppedLines(prepared, maxWidth) : walkPreparedLinesRaw(prepared, maxWidth)
333
+ }
334
+ const { widths, segmentFlags, breakableFitAdvances, entryGeometry, lineStartProhibitions, breakableLineStartExtras, lineStartExtras, lineEndTrims } = prepared
335
+ const fitLimit = Math.max(0, maxWidth) + getEngineProfile().lineFitEpsilon
336
+ const segmentCount = widths.length
337
+ let count = 0
338
+ // Every line starts at 0 and adds its content's widths. Firefox runs this loop
339
+ // about 1.6 times as long when a line's width is set from a segment's width
340
+ // instead (RESEARCH.md, Keeping Work Bounded).
341
+ let lineW = 0
342
+ let hasContent = false
343
+
344
+ // A ZWSP at the start of the text starts the first line.
345
+ for (let i = 0; i < segmentCount; i++) {
346
+ const kind = segmentFlags[i] & KIND_BITS
347
+ const w = widths[i]
348
+ const endTrim = lineEndTrims === null ? 0 : lineEndTrims[i]
349
+ if (hasContent) {
350
+ // A segment that fits only by its line-end trim ends the line, as the full
351
+ // width it adds leaves no room after it.
352
+ if (lineW + w - endTrim <= fitLimit) {
353
+ lineW += w
354
+ continue
355
+ }
356
+ count++
357
+ lineW = 0
358
+ hasContent = false
359
+ if (kind !== TEXT) continue
360
+ } else if (kind === SPACE || (kind === ZERO_WIDTH_BREAK && i > 0)) {
361
+ continue
94
362
  }
95
- return lo < prepared.chunks.length ? lo : -1;
96
- }
97
- function normalizeLineStartInChunk(prepared, chunkIndex, cursor) {
98
- let segmentIndex = cursor.segmentIndex;
99
- if (cursor.graphemeIndex > 0) return chunkIndex;
100
- for(let currentChunkIndex = chunkIndex; currentChunkIndex < prepared.chunks.length; currentChunkIndex++){
101
- const chunk = prepared.chunks[currentChunkIndex];
102
- if (chunk.startSegmentIndex === chunk.endSegmentIndex && segmentIndex === chunk.startSegmentIndex) {
103
- cursor.segmentIndex = segmentIndex;
104
- cursor.graphemeIndex = 0;
105
- return currentChunkIndex;
363
+
364
+ const startW = lineStartExtras === null ? w : w + lineStartExtras[i]
365
+ const advances = breakableFitAdvances[i]
366
+ if (startW - endTrim <= fitLimit || advances === null) {
367
+ lineW += startW
368
+ hasContent = true
369
+ continue
370
+ }
371
+ // An overflowing breakable segment fills lines grapheme by grapheme. A line
372
+ // holding only an overflowing grapheme keeps the graphemes after it that
373
+ // can't start a line. A line that starts inside it where it has fresh-line
374
+ // geometry takes the tail or its fresh prefixes.
375
+ const prohibitions = lineStartProhibitions?.[i] ?? null
376
+ const entry = entryGeometry === null ? null : entryGeometry[i]
377
+ const startExtras = breakableLineStartExtras?.[i] ?? null
378
+ let g = 0
379
+ while (g < advances.length) {
380
+ if (g > 0 && entry !== null && entry.entries[g] !== null) {
381
+ const end = getFreshLineEnd(entry, g, advances.length, fitLimit)
382
+ if (end > advances.length) {
383
+ lineW += getSegmentEntryWidth(entry, g, advances.length)
384
+ hasContent = true
385
+ break
106
386
  }
107
- if (segmentIndex < chunk.startSegmentIndex) segmentIndex = chunk.startSegmentIndex;
108
- segmentIndex = normalizeLineStartSegmentIndex(prepared, segmentIndex, chunk.endSegmentIndex);
109
- if (segmentIndex < chunk.endSegmentIndex) {
110
- cursor.segmentIndex = segmentIndex;
111
- cursor.graphemeIndex = 0;
112
- return currentChunkIndex;
387
+ count++
388
+ g = end
389
+ continue
390
+ }
391
+ if (startExtras !== null) lineW += startExtras[g]
392
+ lineW += advances[g++]
393
+ if (prohibitions !== null && lineW > fitLimit) {
394
+ const kept = g
395
+ while (g < advances.length && prohibitions[g] === 1) lineW += advances[g++]
396
+ if (g > kept) {
397
+ count++
398
+ lineW = 0
399
+ continue
113
400
  }
114
- if (chunk.consumedEndSegmentIndex >= prepared.widths.length) return -1;
115
- segmentIndex = chunk.consumedEndSegmentIndex;
116
- cursor.segmentIndex = segmentIndex;
117
- cursor.graphemeIndex = 0;
401
+ }
402
+ while (g < advances.length && lineW + advances[g] <= fitLimit) lineW += advances[g++]
403
+ if (g < advances.length) {
404
+ count++
405
+ lineW = 0
406
+ } else {
407
+ hasContent = true
408
+ }
118
409
  }
119
- return -1;
410
+ }
411
+ return count + (hasContent ? 1 : 0)
120
412
  }
121
- export function normalizePreparedLineStart(prepared, cursor) {
122
- if (cursor.segmentIndex >= prepared.widths.length) return -1;
123
- const chunkIndex = findChunkIndexForStart(prepared, cursor.segmentIndex);
124
- if (chunkIndex < 0) return -1;
125
- return normalizeLineStartInChunk(prepared, chunkIndex, cursor);
126
- }
127
- function normalizeLineStartChunkIndexFromHint(prepared, chunkIndex, cursor) {
128
- if (cursor.segmentIndex >= prepared.widths.length) return -1;
129
- let nextChunkIndex = chunkIndex;
130
- while(nextChunkIndex < prepared.chunks.length && cursor.segmentIndex >= prepared.chunks[nextChunkIndex].consumedEndSegmentIndex){
131
- nextChunkIndex++;
413
+
414
+ // layout()'s count of text of the simple walkers' kinds where the scan gives no
415
+ // break at some segment boundary, as before NEL: the simple stepper's lines, except
416
+ // that the full walker steps a line again where the stepper ended it at such a
417
+ // boundary, before the segment or after the space before it, returning the line to
418
+ // its last break. Checking for that inside the counter's loop slowed Firefox's and
419
+ // Chrome's count of all other text once the check had ever held, and the line APIs
420
+ // keep the full walker for this text, since the stepper's widths can differ from
421
+ // its in the last bits (RESEARCH.md, Keeping Work Bounded).
422
+ function countSteppedLines(prepared , maxWidth ) {
423
+ const { segmentFlags } = prepared
424
+ const cursor = { segmentIndex: 0, graphemeIndex: 0 }
425
+ let count = 0
426
+ while (normalizePreparedLineStart(prepared, cursor)) {
427
+ const startSegmentIndex = cursor.segmentIndex
428
+ const startGraphemeIndex = cursor.graphemeIndex
429
+ stepPreparedSimpleLineGeometry(prepared, cursor, maxWidth)
430
+ if (cursor.graphemeIndex === 0 && cursor.segmentIndex < segmentFlags.length && (segmentFlags[cursor.segmentIndex] & UNBROKEN) !== 0) {
431
+ cursor.segmentIndex = startSegmentIndex
432
+ cursor.graphemeIndex = startGraphemeIndex
433
+ walkPreparedComplexLines(prepared, cursor, maxWidth, undefined, null, true)
132
434
  }
133
- if (nextChunkIndex >= prepared.chunks.length) return -1;
134
- return normalizeLineStartInChunk(prepared, nextChunkIndex, cursor);
435
+ count++
436
+ }
437
+ return count
135
438
  }
136
- export function countPreparedLines(prepared, maxWidth) {
137
- return walkPreparedLinesRaw(prepared, maxWidth);
439
+
440
+ // Whether the run of preserved spaces and tabs that hang, which starts at segment `start` right
441
+ // after an object on its line, an atomic rich-inline item or a box (OBJECT), stays there however
442
+ // far the line overflows, as no break comes before it in the text (UAX #14 LB7). Blink takes it
443
+ // as trailing items after the break after an atomic inline (HandleTrailingSpaces,
444
+ // line_breaker.cc:2426-2534), trailing on into the next item where an item's spaces reach its end
445
+ // (:2518-2533); WebKit gives a soft wrap opportunity after each white-space item
446
+ // (isAtSoftWrapOpportunity, InlineFormattingUtils.cpp:408-413) and keeps each as content that hangs
447
+ // (InlineContentBreaker.cpp:181-182); and Gecko lets an empty frame past the line's end
448
+ // (CanPlaceFrame, nsLineLayout.cpp:1217-1270). After text that overflows, the engine's retry
449
+ // between graphemes breaks before the white space instead. But Gecko breaks only after a run of
450
+ // spaces and tabs (nsLineBreaker.cpp:323, :586) and doesn't hang a tab, so where the white space
451
+ // runs into a tab, Firefox moves all of it to the next line with the tab. The walker asks once
452
+ // per run, so a long run costs one pass.
453
+ function staysAfterObject(segmentFlags , start , hangingKinds ) {
454
+ let after = start + 1
455
+ while (after < segmentFlags.length && (1 << (segmentFlags[after] & KIND_BITS) & hangingKinds) !== 0) after++
456
+ return after === segmentFlags.length || (segmentFlags[after] & KIND_BITS) !== TAB
138
457
  }
139
- function walkPreparedLinesSimple(prepared, maxWidth, onLine) {
140
- const { widths, kinds, breakableFitAdvances, breakablePreferredBreaks } = prepared;
141
- if (widths.length === 0) return 0;
142
- const engineProfile = getEngineProfile();
143
- const lineFitEpsilon = engineProfile.lineFitEpsilon;
144
- const fitLimit = maxWidth + lineFitEpsilon;
145
- let lineCount = 0;
146
- let lineW = 0;
147
- let hasContent = false;
148
- let lineStartSegmentIndex = 0;
149
- let lineStartGraphemeIndex = 0;
150
- let lineEndSegmentIndex = 0;
151
- let lineEndGraphemeIndex = 0;
152
- let pendingBreakSegmentIndex = -1;
153
- let pendingBreakPaintWidth = 0;
154
- function clearPendingBreak() {
155
- pendingBreakSegmentIndex = -1;
156
- pendingBreakPaintWidth = 0;
157
- }
158
- function emitCurrentLine(endSegmentIndex = lineEndSegmentIndex, endGraphemeIndex = lineEndGraphemeIndex, width = lineW) {
159
- lineCount++;
160
- onLine?.(width, lineStartSegmentIndex, lineStartGraphemeIndex, endSegmentIndex, endGraphemeIndex);
161
- lineW = 0;
162
- hasContent = false;
163
- clearPendingBreak();
164
- }
165
- function startLineAtSegment(segmentIndex, width) {
166
- hasContent = true;
167
- lineStartSegmentIndex = segmentIndex;
168
- lineStartGraphemeIndex = 0;
169
- lineEndSegmentIndex = segmentIndex + 1;
170
- lineEndGraphemeIndex = 0;
171
- lineW = width;
172
- }
173
- function startLineAtGrapheme(segmentIndex, graphemeIndex, width) {
174
- hasContent = true;
175
- lineStartSegmentIndex = segmentIndex;
176
- lineStartGraphemeIndex = graphemeIndex;
177
- lineEndSegmentIndex = segmentIndex;
178
- lineEndGraphemeIndex = graphemeIndex + 1;
179
- lineW = width;
180
- }
181
- function appendWholeSegment(segmentIndex, width) {
182
- if (!hasContent) {
183
- startLineAtSegment(segmentIndex, width);
184
- return;
185
- }
186
- lineW += width;
187
- lineEndSegmentIndex = segmentIndex + 1;
188
- lineEndGraphemeIndex = 0;
189
- }
190
- function appendBreakableSegmentFrom(segmentIndex, startGraphemeIndex) {
191
- const fitAdvances = breakableFitAdvances[segmentIndex];
192
- const preferredBreaks = breakablePreferredBreaks[segmentIndex] ?? null;
193
- let preferredBreakIndex = preferredBreaks === null ? -1 : getNextPreferredBreakIndex(preferredBreaks, 0, startGraphemeIndex + 1);
194
- let lastPreferredBreakEnd = -1;
195
- let lastPreferredBreakWidth = 0;
196
- let g = startGraphemeIndex;
197
- while(g < fitAdvances.length){
198
- const gw = fitAdvances[g];
199
- if (!hasContent) {
200
- startLineAtGrapheme(segmentIndex, g, gw);
201
- } else if (lineW + gw > fitLimit) {
202
- if (preferredBreaks !== null && lastPreferredBreakEnd > startGraphemeIndex) {
203
- emitCurrentLine(segmentIndex, lastPreferredBreakEnd, lastPreferredBreakWidth);
204
- g = lastPreferredBreakEnd;
205
- preferredBreakIndex = getNextPreferredBreakIndex(preferredBreaks, preferredBreakIndex, g + 1);
206
- lastPreferredBreakEnd = -1;
207
- lastPreferredBreakWidth = 0;
208
- continue;
458
+
459
+ // Whether a line that would end at the break before `breakSegmentIndex`, painting
460
+ // `breakWidth`, returns to an earlier opportunity: where it ends at a selected
461
+ // discretionary hyphen that doesn't fit. A return needs an overflow that isolated
462
+ // widths can show: the soft hyphens on the line may measure narrower joined than apart
463
+ // by less than the overflow.
464
+ function returnsFromUnfitHyphen(
465
+ prepared ,
466
+ lineStartSegmentIndex ,
467
+ breakSegmentIndex ,
468
+ breakWidth ,
469
+ fitLimit ,
470
+ ) {
471
+ const { discretionaryHyphenContexts, segmentFlags } = prepared
472
+ const softHyphenIndex = breakSegmentIndex - 1
473
+ if (softHyphenIndex < lineStartSegmentIndex || (segmentFlags[softHyphenIndex] & KIND_BITS) !== SOFT_HYPHEN || breakWidth <= fitLimit) return false
474
+ const overflow = breakWidth - fitLimit
475
+ let narrowing = 0
476
+ if (discretionaryHyphenContexts !== null) for (let i = lineStartSegmentIndex; i <= softHyphenIndex; i++) narrowing += discretionaryHyphenContexts[i]
477
+ return narrowing < overflow
478
+ }
479
+
480
+ // The full walker, for text the simple walkers don't cover: from a normalized line
481
+ // start, every line into `stats`, or with `singleLine` one line, whose width it
482
+ // returns (null without one). Every line state is a local of this one function,
483
+ // with no closure over it: V8 boxes a captured number, so each write to one cost
484
+ // 12-14ns there against about 1ns for a local. It keeps its own loop over lines:
485
+ // stepping one line per call, it read the handle and the profile and derived its
486
+ // limits again for every line, and short lines laid out 7 to 40% slower in all
487
+ // three browsers (RESEARCH.md, Keeping Work Bounded).
488
+ function walkPreparedComplexLines(
489
+ prepared ,
490
+ cursor ,
491
+ maxWidth ,
492
+ onLine ,
493
+ stats ,
494
+ singleLine = false,
495
+ ) {
496
+ const {
497
+ widths,
498
+ segmentFlags,
499
+ breakableFitAdvances,
500
+ entryGeometry,
501
+ discretionaryHyphenWidth,
502
+ letterSpacing,
503
+ lineStartExtras,
504
+ lineEndTrims,
505
+ overflowLineEndTrims,
506
+ tabStopAdvance,
507
+ minimumTabAdvance,
508
+ } = prepared
509
+ const segmentCount = segmentFlags.length
510
+ const items = prepared.items
511
+ const hyphenWidths = items === undefined ? null : items.hyphenWidths
512
+ const hyphenRooms = items === undefined ? null : items.hyphenRooms
513
+ const itemEndHalts = items === undefined ? null : items.itemEndHalts
514
+ const insideExtras = items === undefined ? null : items.insideExtras
515
+ const fillExtras = items === undefined ? null : items.fillExtras
516
+ const openingEdges = items === undefined ? null : items.openingEdges
517
+ const emptyObjectSpaces = items === undefined ? null : items.emptyObjectSpaces
518
+ const emptyObjectReturns = items === undefined ? null : items.emptyObjectReturns
519
+ const engineProfile = getEngineProfile()
520
+ // Preserved spaces and tabs at the end of a line hang past it (CSS Text 3
521
+ // §4.1.2), so they take no room when fitting and don't size the line (§8.2).
522
+ // Gecko doesn't hang tabs.
523
+ const hangingKinds = 1 << PRESERVED_SPACE | (engineProfile.hangTabs ? 1 << TAB : 0)
524
+ // In Gecko a text frame's width leaves out its trailing spaces that overflow the line and
525
+ // keeps those that fit, whatever follows the frame (EngineProfile, hangsSpacesPerTextFrame),
526
+ // so in a rich-inline paragraph the run of preserved spaces that hang goes on, with what
527
+ // overflows, past a segment that takes no room: an object of width 0, a soft hyphen or a
528
+ // zero-width break, which the Gecko scan makes of a soft hyphen after white space.
529
+ const hangGoesOnPastEmpty = items !== undefined && engineProfile.hangsSpacesPerTextFrame
530
+ const testsHyphenBeforeAtomic = engineProfile.testsHyphenBeforeAtomic
531
+ const zeroWidthGlueTakesLine = engineProfile.zeroWidthGlueTakesLine
532
+ const tabsInAppUnits = engineProfile.tabsInAppUnits
533
+ const paddedOpeningFit = engineProfile.paddedOpeningFit
534
+ const hardBreakItemRetreat = engineProfile.hardBreakItemRetreat
535
+ // A negative width lays out as 0, as in the simple stepper.
536
+ const availableWidth = Math.max(0, maxWidth)
537
+ const fitLimit = availableWidth + engineProfile.lineFitEpsilon
538
+ // Preparation records soft-hyphen contexts only where the text has a soft hyphen.
539
+ const retreatsFromUnfitHyphen = prepared.discretionaryHyphenContexts !== null
540
+ // Blink's retry leaves room for the hyphen at every earlier opportunity. Gecko and
541
+ // WebKit return to any opportunity whose line fits. A rich-inline paragraph has that room per
542
+ // break (ParagraphSegmentData, hyphenRooms).
543
+ const reservesHyphenWidth = engineProfile.unfitHyphenRetreat === 'reduced-width'
544
+ const reservedHyphenWidth = reservesHyphenWidth ? discretionaryHyphenWidth : 0
545
+ // WebKit's return stops at the line's first opportunity, whatever its hyphen overflows:
546
+ // the soft hyphen the line reaches before any of its opportunities has fit, since one
547
+ // without a hyphen fits where the text before it did.
548
+ const keepsFirstBreak = retreatsFromUnfitHyphen && engineProfile.unfitHyphenRetreat === 'full-width-or-first'
549
+
550
+ let lastLineWidth = null
551
+ while (true) {
552
+ const lineStartSegmentIndex = cursor.segmentIndex
553
+ const lineStartGraphemeIndex = cursor.graphemeIndex
554
+ let lineW = 0
555
+ let hasContent = false
556
+ let lineEndSegmentIndex = lineStartSegmentIndex
557
+ let lineEndGraphemeIndex = lineStartGraphemeIndex
558
+ let pendingBreakSegmentIndex = -1
559
+ // A line that ends at the pending break both fits and paints this width.
560
+ let pendingBreakWidth = 0
561
+ // The opportunity the line returns to when a selected discretionary hyphen does
562
+ // not fit, with that line's painted width: the latest whose line leaves room for
563
+ // the hyphen, which Blink's retry against the width minus the hyphen finds, or
564
+ // in Gecko and WebKit the latest that fits at the full width (reservedHyphenWidth).
565
+ // Under keepsFirstBreak it is the line's first soft hyphen, whatever its hyphen
566
+ // overflows, until a later opportunity fits.
567
+ let fitBreakSegmentIndex = -1
568
+ let fitBreakPaintWidth = 0
569
+ // The latest run of preserved spaces and tabs: the segment after it, and the
570
+ // line's width before it, less the line-end trim of the text it follows, with
571
+ // the gap after the glyph before it. In a rich-inline paragraph, the run can
572
+ // stay on its line after an object however far it overflows (staysAfterObject),
573
+ // and that width also has the edges in the run that a line ending in it paints,
574
+ // which are hangEdgesWidth wide (ParagraphSegmentData).
575
+ let hangEndSegmentIndex = -1
576
+ let hangStartWidth = 0
577
+ let hangStays = false
578
+ let hangEdgesWidth = 0
579
+ // The line-end trim of the last whole segment, where only that trim let it fit, kept
580
+ // past segments after it that take no room at the line end, as spaces. Every later
581
+ // segment that takes room overflows, so the line ends before it and paints that much less.
582
+ let lineEndTrimmed = 0
583
+ // Retained line-start ZWSP establishes the line without owning a spacing gap.
584
+ let zeroWidthPrefix = true
585
+ let afterUnspacedControl = false
586
+ // Where the line ends and the width it paints there, once decided: -1 ends it at
587
+ // the line's current end. With returnsFromHyphen, a line that ends at a selected
588
+ // discretionary hyphen that doesn't fit returns to the recorded earlier opportunity.
589
+ let endSegmentIndex = -1
590
+ let endGraphemeIndex = 0
591
+ let endWidth = 0
592
+ let returnsFromHyphen = false
593
+
594
+ let lineWidth = null
595
+ if ((segmentFlags[lineStartSegmentIndex] & KIND_BITS) === HARD_BREAK) {
596
+ // A line that starts at a hard break is an empty chunk's (normalizePreparedLineStart),
597
+ // as wide as what a line pays where it starts there: inside a padded rich-inline item,
598
+ // its extraWidth.
599
+ cursor.segmentIndex = lineStartSegmentIndex + 1
600
+ cursor.graphemeIndex = 0
601
+ lineWidth = lineStartExtras === null ? 0 : lineStartExtras[lineStartSegmentIndex]
602
+ } else {
603
+ decided: {
604
+ // Where the line ends when its source runs out: after the chunk's hard
605
+ // break, or at the end of the text.
606
+ let consumedEndSegmentIndex
607
+ for (let i = lineStartSegmentIndex; ; i++) {
608
+ if (i >= segmentCount) {
609
+ consumedEndSegmentIndex = segmentCount
610
+ break
611
+ }
612
+ // The graphemes of segment i from fillStart go on the line one by one, after
613
+ // the gap fillSpacing.
614
+ let fillStart
615
+ let fillSpacing = 0
616
+ const flags = segmentFlags[i]
617
+ const kind = flags & KIND_BITS
618
+ if (kind === HARD_BREAK) {
619
+ consumedEndSegmentIndex = i + 1
620
+ break
621
+ }
622
+ const spaced = (flags & SPACED) !== 0
623
+ const breakAfter = (1 << kind & BREAK_AFTER_KINDS) !== 0
624
+ const startGraphemeIndex = i === lineStartSegmentIndex ? lineStartGraphemeIndex : 0
625
+ // The gap before a segment belongs to the grapheme before it. A control or
626
+ // a tab that takes no letter spacing still follows that gap but adds none
627
+ // after itself, and so does an object, which is no character (CSS Text 3,
628
+ // letter-spacing), as is the edge of a padded opening that hangs as white
629
+ // space; other segments that take none leave it as it was.
630
+ const gap = letterSpacing !== 0 && hasContent && !zeroWidthPrefix && !afterUnspacedControl ? letterSpacing : 0
631
+ let leadingSpacing = 0
632
+ if (letterSpacing !== 0 && (spaced || kind === CONTROL || kind === TAB || kind === OBJECT || (openingEdges !== null && openingEdges[i] !== 0))) {
633
+ leadingSpacing = gap
634
+ afterUnspacedControl = !spaced
635
+ }
636
+ if (kind !== ZERO_WIDTH_BREAK && kind !== ZERO_WIDTH_GLUE) zeroWidthPrefix = false
637
+ const w = kind !== TAB ? widths[i]
638
+ : items === undefined ? getTabAdvance(lineW + leadingSpacing, tabStopAdvance, minimumTabAdvance, tabsInAppUnits)
639
+ : getItemTabAdvance(prepared, items, i, hasContent ? lineW + leadingSpacing : lineStartExtras === null ? 0 : lineStartExtras[i] , tabsInAppUnits)
640
+ const advance = leadingSpacing + w
641
+ const endTrim = lineEndTrims === null ? 0 : lineEndTrims[i]
642
+
643
+ if (kind === SOFT_HYPHEN && startGraphemeIndex === 0) {
644
+ if (hasContent) {
645
+ // The hyphen a line that ends here paints, and whether it has to fit. What only a
646
+ // rich-inline paragraph has is under one test, which is all a text's soft hyphen
647
+ // pays for it (RESEARCH.md, Keeping Work Bounded).
648
+ let hyphenWidth = discretionaryHyphenWidth
649
+ let fitsHyphen = true
650
+ if (items !== undefined) {
651
+ // A break comes after an object, so a line returns to it from a soft hyphen right
652
+ // after the object whose hyphen doesn't fit: Chrome, Firefox and Safari break
653
+ // there with the hyphen where it fits, and else after the object.
654
+ if (retreatsFromUnfitHyphen && i > lineStartSegmentIndex && (segmentFlags[i - 1] & KIND_BITS) === OBJECT) {
655
+ fitBreakSegmentIndex = i
656
+ fitBreakPaintWidth = lineW
209
657
  }
210
- emitCurrentLine();
211
- startLineAtGrapheme(segmentIndex, g, gw);
212
- } else {
213
- lineW += gw;
214
- lineEndSegmentIndex = segmentIndex;
215
- lineEndGraphemeIndex = g + 1;
216
- }
217
- const graphemeEnd = g + 1;
218
- if (preferredBreaks !== null && preferredBreaks[preferredBreakIndex] === graphemeEnd) {
219
- lastPreferredBreakEnd = graphemeEnd;
220
- lastPreferredBreakWidth = lineW;
221
- preferredBreakIndex++;
222
- }
223
- g++;
224
- }
225
- if (hasContent && lineEndSegmentIndex === segmentIndex && lineEndGraphemeIndex === fitAdvances.length) {
226
- lineEndSegmentIndex = segmentIndex + 1;
227
- lineEndGraphemeIndex = 0;
228
- }
229
- }
230
- let i = 0;
231
- while(i < widths.length){
232
- if (!hasContent) {
233
- i = normalizeLineStartSegmentIndex(prepared, i);
234
- if (i >= widths.length) break;
235
- }
236
- const w = widths[i];
237
- const kind = kinds[i];
238
- const breakAfter = breaksAfter(kind);
239
- if (!hasContent) {
240
- if (w > fitLimit && breakableFitAdvances[i] !== null) {
241
- appendBreakableSegmentFrom(i, 0);
242
- } else {
243
- startLineAtSegment(i, w);
244
- }
245
- if (breakAfter) {
246
- pendingBreakSegmentIndex = i + 1;
247
- pendingBreakPaintWidth = lineW - w;
248
- }
249
- i++;
250
- continue;
251
- }
252
- const newW = lineW + w;
253
- if (newW > fitLimit) {
254
- if (breakAfter) {
255
- appendWholeSegment(i, w);
256
- emitCurrentLine(i + 1, 0, lineW - w);
257
- i++;
258
- continue;
259
- }
260
- if (pendingBreakSegmentIndex >= 0) {
261
- if (lineEndSegmentIndex > pendingBreakSegmentIndex || lineEndSegmentIndex === pendingBreakSegmentIndex && lineEndGraphemeIndex > 0) {
262
- emitCurrentLine();
263
- continue;
658
+ if (hangGoesOnPastEmpty && hangEndSegmentIndex === i) {
659
+ hangEndSegmentIndex = i + 1
660
+ hangStartWidth = Math.max(hangStartWidth, Math.min(lineW, availableWidth))
264
661
  }
265
- emitCurrentLine(pendingBreakSegmentIndex, 0, pendingBreakPaintWidth);
266
- continue;
267
- }
268
- if (w > fitLimit && breakableFitAdvances[i] !== null) {
269
- emitCurrentLine();
270
- appendBreakableSegmentFrom(i, 0);
271
- i++;
272
- continue;
662
+ if (hyphenWidths !== null) hyphenWidth = hyphenWidths[i]
663
+ // A soft hyphen's fit includes its own hyphen, but for one right before an atomic
664
+ // item where the engine never tests that hyphen (EngineProfile).
665
+ fitsHyphen = testsHyphenBeforeAtomic || i + 1 === segmentCount || (segmentFlags[i + 1] & KIND_BITS) !== OBJECT || (openingEdges !== null && openingEdges[i + 1] !== 0)
666
+ }
667
+ lineEndSegmentIndex = i + 1
668
+ lineEndGraphemeIndex = 0
669
+ if (i + 1 < segmentCount && (segmentFlags[i + 1] & KIND_BITS) !== HARD_BREAK) {
670
+ pendingBreakSegmentIndex = i + 1
671
+ pendingBreakWidth = lineW + hyphenWidth
672
+ if (retreatsFromUnfitHyphen && ((fitsHyphen ? pendingBreakWidth : lineW) <= fitLimit || (keepsFirstBreak && fitBreakSegmentIndex < 0))) {
673
+ fitBreakSegmentIndex = pendingBreakSegmentIndex
674
+ fitBreakPaintWidth = pendingBreakWidth
675
+ }
676
+ }
273
677
  }
274
- emitCurrentLine();
275
- continue;
276
- }
277
- appendWholeSegment(i, w);
278
- if (breakAfter) {
279
- pendingBreakSegmentIndex = i + 1;
280
- pendingBreakPaintWidth = lineW - w;
281
- }
282
- i++;
283
- }
284
- if (hasContent) emitCurrentLine();
285
- return lineCount;
286
- }
287
- export function walkPreparedLinesRaw(prepared, maxWidth, onLine) {
288
- if (prepared.simpleLineWalkFastPath) return walkPreparedLinesSimple(prepared, maxWidth, onLine);
289
- const cursor = {
290
- segmentIndex: 0,
291
- graphemeIndex: 0
292
- };
293
- const chunkIndex = normalizePreparedLineStart(prepared, cursor);
294
- return walkPreparedComplexLines(prepared, cursor, chunkIndex, maxWidth, onLine).lineCount;
295
- }
296
- function stepPreparedChunkLineGeometry(prepared, cursor, chunkIndex, maxWidth) {
297
- return walkPreparedComplexLines(prepared, cursor, chunkIndex, maxWidth, undefined, 1).lastLineWidth;
298
- }
299
- function walkPreparedComplexLines(prepared, cursor, chunkIndex, maxWidth, onLine, lineLimit = Number.POSITIVE_INFINITY) {
300
- const { widths, kinds, breakableFitAdvances, breakablePreferredBreaks, discretionaryHyphenWidth } = prepared;
301
- const engineProfile = getEngineProfile();
302
- const lineFitEpsilon = engineProfile.lineFitEpsilon;
303
- const fitLimit = maxWidth + lineFitEpsilon;
304
- let lineStartSegmentIndex;
305
- let lineStartGraphemeIndex;
306
- let lineW;
307
- let hasContent;
308
- let lineEndSegmentIndex;
309
- let lineEndGraphemeIndex;
310
- let pendingBreakSegmentIndex;
311
- let pendingBreakFitWidth;
312
- let pendingBreakPaintWidth;
313
- let pendingBreakKind;
314
- function getCurrentLinePaintWidth() {
315
- return pendingBreakKind === 'soft-hyphen' && pendingBreakSegmentIndex === lineEndSegmentIndex && lineEndGraphemeIndex === 0 ? pendingBreakPaintWidth : lineW;
316
- }
317
- function finishLine(endSegmentIndex = lineEndSegmentIndex, endGraphemeIndex = lineEndGraphemeIndex, width = getCurrentLinePaintWidth()) {
318
- if (!hasContent) return null;
319
- cursor.segmentIndex = endSegmentIndex;
320
- cursor.graphemeIndex = endGraphemeIndex;
321
- return finalizeLinePaintWidth(prepared, width, lineStartSegmentIndex, lineStartGraphemeIndex, endSegmentIndex, endGraphemeIndex);
322
- }
323
- function startLineAtSegment(segmentIndex, width) {
324
- hasContent = true;
325
- lineEndSegmentIndex = segmentIndex + 1;
326
- lineEndGraphemeIndex = 0;
327
- lineW = width;
328
- }
329
- function startLineAtGrapheme(segmentIndex, graphemeIndex, width) {
330
- hasContent = true;
331
- lineEndSegmentIndex = segmentIndex;
332
- lineEndGraphemeIndex = graphemeIndex + 1;
333
- lineW = width;
334
- }
335
- function appendWholeSegment(segmentIndex, advance) {
336
- if (!hasContent) {
337
- startLineAtSegment(segmentIndex, advance);
338
- return;
339
- }
340
- lineW += advance;
341
- lineEndSegmentIndex = segmentIndex + 1;
342
- lineEndGraphemeIndex = 0;
343
- }
344
- function updatePendingBreakForWholeSegment(kind, breakAfter, segmentIndex, segmentWidth, leadingSpacing, advance) {
345
- if (!breakAfter) return;
346
- const fitAdvance = getBreakOpportunityFitContribution(prepared, kind, segmentIndex, leadingSpacing);
347
- const paintAdvance = getLineEndPaintContribution(prepared, kind, segmentIndex, leadingSpacing, segmentWidth);
348
- pendingBreakSegmentIndex = segmentIndex + 1;
349
- pendingBreakFitWidth = lineW - advance + fitAdvance;
350
- pendingBreakPaintWidth = lineW - advance + paintAdvance;
351
- pendingBreakKind = kind;
352
- }
353
- function appendBreakableSegmentFrom(segmentIndex, startGraphemeIndex) {
354
- const fitAdvances = breakableFitAdvances[segmentIndex];
355
- const preferredBreaks = breakablePreferredBreaks[segmentIndex] ?? null;
356
- let preferredBreakIndex = preferredBreaks === null ? -1 : getNextPreferredBreakIndex(preferredBreaks, 0, startGraphemeIndex + 1);
357
- let lastPreferredBreakEnd = -1;
358
- let lastPreferredBreakWidth = 0;
359
- for(let g = startGraphemeIndex; g < fitAdvances.length; g++){
360
- const baseGw = fitAdvances[g];
361
- if (!hasContent) {
362
- startLineAtGrapheme(segmentIndex, g, baseGw);
363
- } else {
364
- const gw = getBreakableGraphemeAdvance(prepared, true, baseGw);
365
- const candidatePaintWidth = lineW + gw;
366
- if (getBreakableCandidateFitWidth(prepared, candidatePaintWidth) > fitLimit) {
367
- if (preferredBreaks !== null && lastPreferredBreakEnd > startGraphemeIndex) {
368
- return finishLine(segmentIndex, lastPreferredBreakEnd, lastPreferredBreakWidth);
369
- }
370
- return finishLine();
678
+ continue
679
+ }
680
+
681
+ // A line that ends after a whole segment charges its advance and the letter
682
+ // spacing gap after it. Spaces and zero-width breaks hang, and zero-width
683
+ // text owns no gap, though NEL does. Text that takes no letter spacing, such
684
+ // as zero-width glue, fits like the line that still ends with the gap before it.
685
+ let fitAdvance = 0
686
+ if (letterSpacing !== 0 && !spaced && !breakAfter && kind !== CONTROL) {
687
+ fitAdvance = gap + w
688
+ } else if (breakAfter ? kind === TAB : w !== 0 || kind === CONTROL) {
689
+ const contribution = w + (spaced ? letterSpacing : 0)
690
+ if (contribution !== 0) fitAdvance = leadingSpacing + contribution
691
+ }
692
+ // Two statements, for how the engines compile them: with them Safari 27 laid pre-wrap text
693
+ // out 4.3% faster than with the same tests as one `||` and Chrome 154 letter-spaced CJK
694
+ // text 2.8%, and Firefox 156 read within 1.5% either way (RESEARCH.md, Keeping Work
695
+ // Bounded, Work Done Only Where A Rule Applies; Decisions Log, 2026-10-08).
696
+ let hangs = (1 << kind & hangingKinds) !== 0
697
+ if (hangGoesOnPastEmpty && kind === ZERO_WIDTH_BREAK && hangEndSegmentIndex === i) hangs = true
698
+ if (hangs) {
699
+ if (hangEndSegmentIndex !== i) {
700
+ hangStartWidth = lineW - lineEndTrimmed + leadingSpacing
701
+ hangStays = false
702
+ if (items !== undefined && i > lineStartSegmentIndex && (segmentFlags[i - 1] & KIND_BITS) === OBJECT) {
703
+ hangStays = staysAfterObject(segmentFlags, i, hangingKinds)
704
+ // A run that goes down with its tab leaves from the break after the object.
705
+ if (!hangStays) {
706
+ pendingBreakSegmentIndex = i
707
+ pendingBreakWidth = lineW - lineEndTrimmed
371
708
  }
372
- lineW = candidatePaintWidth;
373
- lineEndSegmentIndex = segmentIndex;
374
- lineEndGraphemeIndex = g + 1;
709
+ }
710
+ hangEdgesWidth = 0
375
711
  }
376
- const graphemeEnd = g + 1;
377
- if (preferredBreaks !== null && preferredBreaks[preferredBreakIndex] === graphemeEnd) {
378
- lastPreferredBreakEnd = graphemeEnd;
379
- lastPreferredBreakWidth = lineW;
380
- preferredBreakIndex++;
712
+ hangEndSegmentIndex = i + 1
713
+ if (openingEdges !== null) {
714
+ hangStartWidth += openingEdges[i]
715
+ hangEdgesWidth += openingEdges[i]
381
716
  }
382
- }
383
- if (hasContent && lineEndSegmentIndex === segmentIndex && lineEndGraphemeIndex === fitAdvances.length) {
384
- lineEndSegmentIndex = segmentIndex + 1;
385
- lineEndGraphemeIndex = 0;
386
- }
387
- return null;
388
- }
389
- let lineCount = 0;
390
- let lastLineWidth = null;
391
- while(chunkIndex >= 0 && lineCount < lineLimit){
392
- lineStartSegmentIndex = cursor.segmentIndex;
393
- lineStartGraphemeIndex = cursor.graphemeIndex;
394
- lineW = 0;
395
- hasContent = false;
396
- lineEndSegmentIndex = cursor.segmentIndex;
397
- lineEndGraphemeIndex = cursor.graphemeIndex;
398
- pendingBreakSegmentIndex = -1;
399
- pendingBreakFitWidth = 0;
400
- pendingBreakPaintWidth = 0;
401
- pendingBreakKind = null;
402
- const chunk = prepared.chunks[chunkIndex];
403
- let lineWidth = null;
404
- if (chunk.startSegmentIndex === chunk.endSegmentIndex) {
405
- cursor.segmentIndex = chunk.consumedEndSegmentIndex;
406
- cursor.graphemeIndex = 0;
407
- lineWidth = 0;
408
- } else {
409
- lineLoop: for(let i = cursor.segmentIndex; i < chunk.endSegmentIndex; i++){
410
- const kind = kinds[i];
411
- const breakAfter = breaksAfter(kind);
412
- const startGraphemeIndex = i === cursor.segmentIndex ? cursor.graphemeIndex : 0;
413
- const leadingSpacing = getLeadingLetterSpacing(prepared, hasContent, i);
414
- const w = kind === 'tab' ? getTabAdvance(lineW + leadingSpacing, prepared.tabStopAdvance) : widths[i];
415
- const advance = leadingSpacing + w;
416
- const fitAdvance = getWholeSegmentFitContribution(prepared, kind, i, leadingSpacing, w);
417
- if (kind === 'soft-hyphen' && startGraphemeIndex === 0) {
418
- if (hasContent) {
419
- lineEndSegmentIndex = i + 1;
420
- lineEndGraphemeIndex = 0;
421
- if (i + 1 < chunk.endSegmentIndex) {
422
- pendingBreakSegmentIndex = i + 1;
423
- pendingBreakFitWidth = lineW + discretionaryHyphenWidth;
424
- pendingBreakPaintWidth = lineW + discretionaryHyphenWidth;
425
- pendingBreakKind = kind;
426
- }
427
- }
428
- continue;
717
+ }
718
+ // Where glue can't hold a line, glue at a line start isn't the line's content:
719
+ // the segment after it starts the line, however wide.
720
+ if (!hasContent && kind === ZERO_WIDTH_GLUE && !zeroWidthGlueTakesLine) {
721
+ lineEndSegmentIndex = i + 1
722
+ lineEndGraphemeIndex = 0
723
+ continue
724
+ }
725
+
726
+ // A fresh line and a line with content admit a whole segment apart, each
727
+ // with its own copy of what follows admission. One path for both, testing
728
+ // at each step whether the line has content, laid out short lines 3 to 17%
729
+ // slower in all three browsers (RESEARCH.md, Keeping Work Bounded).
730
+ if (!hasContent) {
731
+ if (startGraphemeIndex > 0) {
732
+ fillStart = startGraphemeIndex
733
+ } else {
734
+ const startExtra = lineStartExtras === null ? 0 : lineStartExtras[i]
735
+ // With no break before it on the line, an overflowing segment is laid out
736
+ // by Blink's retry between graphemes, which halts where that lets it fit.
737
+ const startTrim = overflowLineEndTrims !== null && fitAdvance + startExtra - overflowLineEndTrims[i] <= fitLimit
738
+ ? overflowLineEndTrims[i]
739
+ : endTrim
740
+ // Preserved spaces hang whole, with the advances rich inline gives them too
741
+ // (hardBreakItemRetreat).
742
+ if (fitAdvance + startExtra - startTrim > fitLimit && breakableFitAdvances[i] !== null && !hangs) {
743
+ fillStart = 0
744
+ } else {
745
+ hasContent = true
746
+ lineEndSegmentIndex = i + 1
747
+ lineEndGraphemeIndex = 0
748
+ lineW = w + startExtra
749
+ // What the line pays at its start stays where the white space it starts with hangs.
750
+ if (hangs) hangStartWidth += startExtra
751
+ lineEndTrimmed = fitAdvance + startExtra > fitLimit && kind !== OBJECT ? startTrim : 0
752
+ // A mark halted at its item's end stays halted where the line goes on (below).
753
+ if (itemEndHalts !== null && lineEndTrimmed !== 0 && itemEndHalts[i] !== 0) {
754
+ lineW -= lineEndTrimmed
755
+ lineEndTrimmed = 0
429
756
  }
430
- if (!hasContent) {
431
- if (startGraphemeIndex > 0) {
432
- const line = appendBreakableSegmentFrom(i, startGraphemeIndex);
433
- if (line !== null) {
434
- lineWidth = line;
435
- break lineLoop;
436
- }
437
- } else if (fitAdvance > fitLimit && breakableFitAdvances[i] !== null) {
438
- const line = appendBreakableSegmentFrom(i, 0);
439
- if (line !== null) {
440
- lineWidth = line;
441
- break lineLoop;
442
- }
443
- } else {
444
- startLineAtSegment(i, w);
445
- }
446
- updatePendingBreakForWholeSegment(kind, breakAfter, i, w, leadingSpacing, advance);
447
- continue;
757
+ // The break segment hangs with the gap before it, a run of preserved
758
+ // spaces and tabs hangs whole, and a tab that doesn't hang counts whole.
759
+ if (breakAfter && (i + 1 === segmentCount || (segmentFlags[i + 1] & UNBROKEN) === 0)) {
760
+ pendingBreakSegmentIndex = i + 1
761
+ pendingBreakWidth = hangs ? hangStartWidth : kind === TAB ? lineW : lineW - advance
448
762
  }
449
- const newFitW = lineW + fitAdvance;
450
- if (newFitW > fitLimit) {
451
- const currentBreakFitWidth = lineW + getBreakOpportunityFitContribution(prepared, kind, i, leadingSpacing);
452
- const currentBreakPaintWidth = lineW + getLineEndPaintContribution(prepared, kind, i, leadingSpacing, w);
453
- if (breakAfter && currentBreakFitWidth <= fitLimit) {
454
- appendWholeSegment(i, advance);
455
- lineWidth = finishLine(i + 1, 0, currentBreakPaintWidth);
456
- break lineLoop;
457
- }
458
- if (pendingBreakSegmentIndex >= 0 && pendingBreakFitWidth <= fitLimit) {
459
- if (lineEndSegmentIndex > pendingBreakSegmentIndex || lineEndSegmentIndex === pendingBreakSegmentIndex && lineEndGraphemeIndex > 0) {
460
- lineWidth = finishLine();
461
- break lineLoop;
462
- }
463
- lineWidth = finishLine(pendingBreakSegmentIndex, 0, pendingBreakPaintWidth);
464
- break lineLoop;
465
- }
466
- lineWidth = finishLine();
467
- break lineLoop;
763
+ if (retreatsFromUnfitHyphen && breakAfter && pendingBreakWidth + (hyphenRooms === null || pendingBreakSegmentIndex < 0 ? reservedHyphenWidth : hyphenRooms[pendingBreakSegmentIndex] ) <= fitLimit) {
764
+ fitBreakSegmentIndex = pendingBreakSegmentIndex
765
+ fitBreakPaintWidth = pendingBreakWidth
468
766
  }
469
- appendWholeSegment(i, advance);
470
- updatePendingBreakForWholeSegment(kind, breakAfter, i, w, leadingSpacing, advance);
767
+ continue
768
+ }
471
769
  }
472
- if (lineWidth === null) {
473
- lineWidth = pendingBreakSegmentIndex === chunk.consumedEndSegmentIndex && lineEndGraphemeIndex === 0 ? finishLine(chunk.consumedEndSegmentIndex, 0, pendingBreakPaintWidth) : finishLine(chunk.consumedEndSegmentIndex, 0, lineW);
770
+ } else {
771
+ // A run of preserved spaces and tabs fits where the text before it fits, and after an
772
+ // object however far the line overflows (staysAfterObject).
773
+ const newFitW = hangs ? hangStartWidth - hangEdgesWidth : lineW + fitAdvance
774
+ // Whether the segment ends the line. What only a rich-inline paragraph has is under one
775
+ // test, as at a soft hyphen (above).
776
+ let overflows = newFitW - endTrim > fitLimit
777
+ if (items !== undefined) {
778
+ // Blink and WebKit move an object of width 0 that doesn't fit to the next line as any
779
+ // other. Gecko places an empty frame though it sticks out of the line (CanPlaceFrame,
780
+ // nsLineLayout.cpp:1264-1269; EngineProfile, emptyAtomicAlwaysFits). It sticks out
781
+ // where the content before it ends past the line's end, with the collapsed space
782
+ // before the object, which a line end trims no more once the object follows it
783
+ // (nsLineLayout.cpp:1017-1020), and without the preserved spaces that hang, which
784
+ // end at the line's end (nsTextFrame.cpp:11216-11229). The line then ends before it
785
+ // where the text before it ends in white space and its last frame ends past the
786
+ // line's end without that frame's own spaces, as the line breaks after the white
787
+ // space (nsTextFrame.cpp:11443-11456), and where a break comes before the object
788
+ // and what follows it sends the line back there (ParagraphSegmentData).
789
+ let placesEmptyObject = false
790
+ if (emptyObjectSpaces !== null && kind === OBJECT && w === 0 && newFitW > fitLimit) {
791
+ const contentW = hangEndSegmentIndex === i ? hangStartWidth : lineW
792
+ const space = emptyObjectSpaces[i]
793
+ placesEmptyObject = contentW <= fitLimit || !((space >= 0 && contentW - space > fitLimit) || emptyObjectReturns [i] === 1)
794
+ }
795
+ // No engine lets content make a line narrower (findWholeLine in src/rich-inline.ts),
796
+ // so a segment of less than no advance, as a letter under a letter spacing more
797
+ // negative than it is wide, doesn't bring a line that overflows back.
798
+ const narrowsOverflow = fitAdvance < 0 && lineW - lineEndTrimmed > fitLimit
799
+ overflows = (overflows || narrowsOverflow) && !(hangs && hangStays) && !placesEmptyObject
800
+ if (hangGoesOnPastEmpty && !overflows && hangEndSegmentIndex === i && kind === OBJECT && w === 0) {
801
+ hangEndSegmentIndex = i + 1
802
+ hangStartWidth = Math.max(hangStartWidth, Math.min(lineW + advance, availableWidth))
803
+ }
474
804
  }
475
- }
476
- if (lineWidth === null) break;
477
- lastLineWidth = lineWidth;
478
- lineCount++;
479
- onLine?.(lineWidth, lineStartSegmentIndex, lineStartGraphemeIndex, cursor.segmentIndex, cursor.graphemeIndex);
480
- if (lineCount < lineLimit) chunkIndex = normalizeLineStartChunkIndexFromHint(prepared, chunkIndex, cursor);
481
- }
482
- return {
483
- lineCount,
484
- lastLineWidth
485
- };
486
- }
487
- function stepPreparedSimpleLineGeometry(prepared, cursor, maxWidth) {
488
- const { widths, kinds, breakableFitAdvances, breakablePreferredBreaks } = prepared;
489
- const engineProfile = getEngineProfile();
490
- const lineFitEpsilon = engineProfile.lineFitEpsilon;
491
- const fitLimit = maxWidth + lineFitEpsilon;
492
- let lineW = 0;
493
- let hasContent = false;
494
- let lineEndSegmentIndex = cursor.segmentIndex;
495
- let lineEndGraphemeIndex = cursor.graphemeIndex;
496
- let pendingBreakSegmentIndex = -1;
497
- let pendingBreakPaintWidth = 0;
498
- for(let i = cursor.segmentIndex; i < widths.length; i++){
499
- const kind = kinds[i];
500
- const breakAfter = breaksAfter(kind);
501
- const startGraphemeIndex = i === cursor.segmentIndex ? cursor.graphemeIndex : 0;
502
- const breakableFitAdvance = breakableFitAdvances[i];
503
- const w = widths[i];
504
- if (!hasContent) {
505
- if (startGraphemeIndex > 0 || w > fitLimit && breakableFitAdvance !== null) {
506
- const fitAdvances = breakableFitAdvance;
507
- const preferredBreaks = breakablePreferredBreaks[i] ?? null;
508
- let preferredBreakIndex = preferredBreaks === null ? -1 : getNextPreferredBreakIndex(preferredBreaks, 0, startGraphemeIndex + 1);
509
- let lastPreferredBreakEnd = -1;
510
- let lastPreferredBreakWidth = 0;
511
- const firstGraphemeWidth = fitAdvances[startGraphemeIndex];
512
- hasContent = true;
513
- lineW = firstGraphemeWidth;
514
- lineEndSegmentIndex = i;
515
- lineEndGraphemeIndex = startGraphemeIndex + 1;
516
- if (preferredBreaks !== null && preferredBreaks[preferredBreakIndex] === lineEndGraphemeIndex) {
517
- lastPreferredBreakEnd = lineEndGraphemeIndex;
518
- lastPreferredBreakWidth = lineW;
519
- preferredBreakIndex++;
805
+ if (overflows) {
806
+ // A break segment hangs with the gap before it, after the content before
807
+ // it, which fits without its line-end trim. A collapsible space or ZWSP
808
+ // hangs even after overflowing content that started the line, as the
809
+ // simple stepper does; a preserved space there starts the next line. A
810
+ // tab that doesn't hang goes to the next line, as text does. A ZWSP also
811
+ // hangs right after an object, as after the start edge of its item's opening,
812
+ // which the line took by the part it fits; after a chip wider than the line
813
+ // that is Firefox's line, where Chrome and Safari give a ZWSP that ends the
814
+ // paragraph a line of its own (ENGINE_FOLLOWUPS.md, Rich-inline item edges).
815
+ const contentW = lineW - lineEndTrimmed
816
+ if (breakAfter && (hangs || kind !== TAB) && (contentW <= fitLimit ||
817
+ (pendingBreakSegmentIndex < 0 && (kind === SPACE || kind === ZERO_WIDTH_BREAK)) ||
818
+ (kind === ZERO_WIDTH_BREAK && (segmentFlags[i - 1] & KIND_BITS) === OBJECT))) {
819
+ endWidth = hangs ? hangStartWidth : contentW
820
+ lineW += advance
821
+ endSegmentIndex = i + 1
822
+ endGraphemeIndex = 0
823
+ break decided
824
+ }
825
+ // Gecko's line returns from a tab that doesn't fit to its latest break (below), as
826
+ // BreakAndMeasureText keeps the last break whose line fits (gfxTextRun.cpp:1086-1101).
827
+ // Without one it wraps before the tab, as break-word lets it before any cluster
828
+ // (:1069-1072), and the spaces before the tab hang.
829
+ if (kind === TAB && !hangs && pendingBreakSegmentIndex < 0) {
830
+ endSegmentIndex = i
831
+ endGraphemeIndex = 0
832
+ endWidth = hangEndSegmentIndex === i && i > lineStartSegmentIndex ? hangStartWidth : contentW
833
+ break decided
834
+ }
835
+
836
+ const unbroken = (flags & UNBROKEN) !== 0
837
+ // An object with no break before it is the start edge of a padded rich-inline
838
+ // item's opening (src/rich-inline.ts). Blink's line trails once the preserved
839
+ // spaces it ends with overflow: it takes the white space after them, the tags of
840
+ // spans that open among it and a forced break with no fit (HandleTrailingSpaces,
841
+ // line_breaker.cc:2426-2534), so an edge right after such a run joins it, taking
842
+ // no room. The spaces overflow where the content before them fits with the start
843
+ // edges of the spans that open among them, half of each such edge (getOpeningFit),
844
+ // and the spaces after those edges don't; an overflow before the spaces or at a
845
+ // span's end edge returns instead. WebKit fits the content it places without the
846
+ // white space that hangs before it (hangingContentWidth,
847
+ // InlineContentBreaker.cpp:183-186), so there the edge joins the run where it fits
848
+ // after the content before the run.
849
+ if (kind === OBJECT && unbroken && hangEndSegmentIndex === i && (paddedOpeningFit === 'start'
850
+ ? hangStartWidth - hangEdgesWidth / 2 <= fitLimit && lineW - hangEdgesWidth / 2 > fitLimit
851
+ : paddedOpeningFit === 'placed' && hangStartWidth - hangEdgesWidth + w - endTrim <= fitLimit)) {
852
+ hangEndSegmentIndex = i + 1
853
+ hangStartWidth += w
854
+ hangEdgesWidth += w
855
+ lineW += advance
856
+ lineEndSegmentIndex = i + 1
857
+ lineEndGraphemeIndex = 0
858
+ continue
859
+ }
860
+ // Where the scan gives no break before the segment, as before NEL (UAX #14
861
+ // LB6), the line returns to its last break. Without one, Blink and WebKit retry
862
+ // between graphemes, so the segment's graphemes fill it.
863
+ if (unbroken && pendingBreakSegmentIndex >= 0) {
864
+ lineEndSegmentIndex = pendingBreakSegmentIndex
865
+ lineEndGraphemeIndex = 0
866
+ }
867
+ if (!unbroken || pendingBreakSegmentIndex >= 0) {
868
+ returnsFromHyphen = true
869
+ break decided
870
+ }
871
+ // Blink's retry halts the segment where that lets it fit, and the line ends after it,
872
+ // but where the segment ends its item, whose mark stays halted while the line goes on.
873
+ if (overflowLineEndTrims !== null && newFitW - overflowLineEndTrims[i] <= fitLimit) {
874
+ lineW += advance
875
+ lineEndSegmentIndex = i + 1
876
+ lineEndGraphemeIndex = 0
877
+ lineEndTrimmed = overflowLineEndTrims[i]
878
+ if (itemEndHalts !== null && itemEndHalts[i] !== 0) {
879
+ lineW -= lineEndTrimmed
880
+ lineEndTrimmed = 0
881
+ }
882
+ continue
883
+ }
884
+ // Where the line of such an edge has no break to return to, the line ends before
885
+ // it, but before a hard break only in Blink, whose retry breaks between any two
886
+ // graphemes: WebKit and Gecko end the line before the last grapheme of the text
887
+ // before the edge, and keep the edge on a line that grapheme starts (EngineProfile,
888
+ // hardBreakItemRetreat). Of preserved spaces there, whose advances rich inline
889
+ // gives, Gecko moves the last and WebKit those that don't fit: all of them where a
890
+ // negative letter spacing leaves a space no advance, so that giving one up gives no
891
+ // overflow back. The ones the line keeps hang.
892
+ if (kind === OBJECT && hardBreakItemRetreat !== 'item' && i + 1 < segmentCount && (segmentFlags[i + 1] & KIND_BITS) === HARD_BREAK) {
893
+ const beforeFlags = segmentFlags[i - 1]
894
+ const beforeKind = beforeFlags & KIND_BITS
895
+ const spaceAdvances = beforeKind === PRESERVED_SPACE ? breakableFitAdvances[i - 1] : null
896
+ if (spaceAdvances !== null) {
897
+ const count = spaceAdvances.length
898
+ const spaceAdvance = spaceAdvances[0]
899
+ const moved = hardBreakItemRetreat !== 'fit' || lineW <= fitLimit ? 1 : spaceAdvance > 0 ? Math.min(count, Math.ceil((lineW - fitLimit) / spaceAdvance)) : count
900
+ // The start edge of the spaces' own item is no content to leave on the line.
901
+ const contentStart = openingEdges !== null && openingEdges[lineStartSegmentIndex] !== 0 ? lineStartSegmentIndex + 1 : lineStartSegmentIndex
902
+ if (i - 1 > contentStart || count - moved > lineStartGraphemeIndex) {
903
+ endSegmentIndex = i - 1
904
+ endGraphemeIndex = count - moved
905
+ endWidth = hangStartWidth
906
+ break decided
907
+ }
520
908
  }
521
- for(let g = startGraphemeIndex + 1; g < fitAdvances.length; g++){
522
- const gw = fitAdvances[g];
523
- if (lineW + gw > fitLimit) {
524
- if (preferredBreaks !== null && lastPreferredBreakEnd > startGraphemeIndex) {
525
- cursor.segmentIndex = i;
526
- cursor.graphemeIndex = lastPreferredBreakEnd;
527
- return lastPreferredBreakWidth;
528
- }
529
- cursor.segmentIndex = lineEndSegmentIndex;
530
- cursor.graphemeIndex = lineEndGraphemeIndex;
531
- return lineW;
532
- }
533
- lineW += gw;
534
- lineEndSegmentIndex = i;
535
- lineEndGraphemeIndex = g + 1;
536
- if (preferredBreaks !== null && preferredBreaks[preferredBreakIndex] === lineEndGraphemeIndex) {
537
- lastPreferredBreakEnd = lineEndGraphemeIndex;
538
- lastPreferredBreakWidth = lineW;
539
- preferredBreakIndex++;
540
- }
909
+ const beforeAdvances = beforeKind === TEXT || beforeKind === CONTROL ? breakableFitAdvances[i - 1] : null
910
+ const spacing = (beforeFlags & SPACED) !== 0 ? letterSpacing : 0
911
+ if (beforeAdvances !== null && (i - 1 > lineStartSegmentIndex || beforeAdvances.length - 1 > lineStartGraphemeIndex)) {
912
+ endSegmentIndex = i - 1
913
+ endGraphemeIndex = beforeAdvances.length - 1
914
+ endWidth = lineW - beforeAdvances[endGraphemeIndex] - spacing
915
+ break decided
541
916
  }
542
- if (lineEndSegmentIndex === i && lineEndGraphemeIndex === fitAdvances.length) {
543
- lineEndSegmentIndex = i + 1;
544
- lineEndGraphemeIndex = 0;
917
+ if (beforeAdvances === null && (beforeKind === TEXT || beforeKind === CONTROL) && i - 1 > lineStartSegmentIndex) {
918
+ endSegmentIndex = i - 1
919
+ endGraphemeIndex = 0
920
+ endWidth = lineW - widths[i - 1] - spacing
921
+ break decided
545
922
  }
923
+ if (spaceAdvances !== null || (beforeKind !== PRESERVED_SPACE && beforeKind !== TAB)) {
924
+ lineW += advance
925
+ lineEndSegmentIndex = i + 1
926
+ lineEndGraphemeIndex = 0
927
+ lineEndTrimmed = 0
928
+ continue
929
+ }
930
+ }
931
+ if (breakableFitAdvances[i] === null) {
932
+ returnsFromHyphen = true
933
+ break decided
934
+ }
935
+ fillStart = 0
936
+ fillSpacing = leadingSpacing
546
937
  } else {
547
- hasContent = true;
548
- lineW = w;
549
- lineEndSegmentIndex = i + 1;
550
- lineEndGraphemeIndex = 0;
938
+ // A break the scan gives before text is one the line can return to.
939
+ if ((flags & RETURNABLE) !== 0 && !breakAfter && pendingBreakSegmentIndex !== i) {
940
+ pendingBreakSegmentIndex = i
941
+ pendingBreakWidth = lineW
942
+ }
943
+ // A break the scan gives between two text segments, as after `-` or between
944
+ // ideographs, is an opportunity the line returns to like any other. The line up
945
+ // to it fit when its last segment was admitted, with the letter-spacing gap after
946
+ // it that lineW leaves out, so only Blink's room for the hyphen is tested here.
947
+ if (retreatsFromUnfitHyphen && !breakAfter && (flags & UNBROKEN) === 0 && i > lineStartSegmentIndex &&
948
+ !breaksAfterKind(segmentFlags[i - 1] & KIND_BITS) && (!reservesHyphenWidth || lineW + (hyphenRooms === null ? discretionaryHyphenWidth : hyphenRooms[i] ) <= fitLimit)) {
949
+ fitBreakSegmentIndex = i
950
+ fitBreakPaintWidth = lineW
951
+ }
952
+ lineEndSegmentIndex = i + 1
953
+ lineEndGraphemeIndex = 0
954
+ if (itemEndHalts !== null && newFitW > fitLimit && itemEndHalts[i] !== 0) {
955
+ // A closing mark that ends its item and fits the line only halted stays halted where
956
+ // the line goes on: the line takes it at its halted width, and what follows fits
957
+ // after that (ParagraphSegmentData, itemEndHalts).
958
+ lineW += advance - endTrim
959
+ lineEndTrimmed = 0
960
+ } else {
961
+ lineW += advance
962
+ // A segment that takes no room at the line end, as a space, leaves the glyph
963
+ // before it last on the line, with its trim.
964
+ if (fitAdvance !== 0 && !hangs) lineEndTrimmed = newFitW > fitLimit && kind !== OBJECT ? endTrim : 0
965
+ }
966
+ if (breakAfter && (i + 1 === segmentCount || (segmentFlags[i + 1] & UNBROKEN) === 0)) {
967
+ pendingBreakSegmentIndex = i + 1
968
+ pendingBreakWidth = hangs ? hangStartWidth : kind === TAB ? lineW : lineW - advance - lineEndTrimmed
969
+ }
970
+ if (retreatsFromUnfitHyphen && breakAfter && pendingBreakWidth + (hyphenRooms === null || pendingBreakSegmentIndex < 0 ? reservedHyphenWidth : hyphenRooms[pendingBreakSegmentIndex] ) <= fitLimit) {
971
+ fitBreakSegmentIndex = pendingBreakSegmentIndex
972
+ fitBreakPaintWidth = pendingBreakWidth
973
+ }
974
+ continue
551
975
  }
552
- if (breakAfter) {
553
- pendingBreakSegmentIndex = i + 1;
554
- pendingBreakPaintWidth = lineW - w;
976
+ }
977
+
978
+ // A grapheme fits with the letter spacing after it.
979
+ const fitAdvances = breakableFitAdvances[i]
980
+ const fitCount = fitAdvances.length
981
+ const entry = entryGeometry === null ? null : entryGeometry[i]
982
+ // Entry geometry describes whole segment tails on a fresh line.
983
+ const freshWhole = hasContent ? null : getSegmentEntryWidth(entry, fillStart, fitCount)
984
+ if (freshWhole !== null) {
985
+ // This branch reads a width for each question: in getFreshLineEnd(), the entry's
986
+ // admissionFit for whether the whole tail fits and the fresh prefixes for where
987
+ // the line ends where it doesn't; then the tail's fresh width, freshWhole, for the
988
+ // line that goes on. The line takes its first grapheme even where that doesn't
989
+ // fit, so it advances.
990
+ const end = getFreshLineEnd(entry , fillStart, fitCount, fitLimit)
991
+ hasContent = true
992
+ if (end <= fitCount) {
993
+ lineEndSegmentIndex = i
994
+ lineEndGraphemeIndex = end
995
+ lineW = getSegmentEntryWidth(entry, fillStart, end) - letterSpacing
996
+ // Where the fresh prefixes reach the segment's end though the whole tail wasn't
997
+ // admitted, the line ends after the segment and takes nothing after it. Only
998
+ // an admitted tail lets the line go on with the text after it (below).
999
+ if (end === fitCount) {
1000
+ endSegmentIndex = i + 1
1001
+ endGraphemeIndex = 0
1002
+ endWidth = lineW - lineEndTrimmed
1003
+ }
1004
+ break decided
555
1005
  }
556
- continue;
557
- }
558
- if (lineW + w > fitLimit) {
559
- if (breakAfter) {
560
- cursor.segmentIndex = i + 1;
561
- cursor.graphemeIndex = 0;
562
- return lineW;
563
- }
564
- if (pendingBreakSegmentIndex >= 0) {
565
- if (lineEndSegmentIndex > pendingBreakSegmentIndex || lineEndSegmentIndex === pendingBreakSegmentIndex && lineEndGraphemeIndex > 0) {
566
- cursor.segmentIndex = lineEndSegmentIndex;
567
- cursor.graphemeIndex = lineEndGraphemeIndex;
568
- return lineW;
1006
+ lineEndSegmentIndex = i + 1
1007
+ lineEndGraphemeIndex = 0
1008
+ lineW = freshWhole - letterSpacing
1009
+ } else {
1010
+ for (let g = fillStart; g < fitCount; g++) {
1011
+ const baseGw = fitAdvances[g]
1012
+ if (!hasContent) {
1013
+ hasContent = true
1014
+ lineEndSegmentIndex = i
1015
+ lineEndGraphemeIndex = g + 1
1016
+ const startExtras = prepared.breakableLineStartExtras?.[i] ?? null
1017
+ lineW = startExtras === null ? baseGw : baseGw + startExtras[g]
1018
+ // A line that starts inside a padded rich-inline item, or fills its first
1019
+ // segment, pays the item's extraWidth (ParagraphSegmentData).
1020
+ if (g > 0 ? insideExtras !== null : fillExtras !== null) lineW += g > 0 ? insideExtras [i] : fillExtras [i]
1021
+ // A line that holds only this grapheme, overflowing, keeps the graphemes after
1022
+ // it that can't start a line, and ends.
1023
+ const end = lineW + letterSpacing > fitLimit
1024
+ ? getOverflowingFirstGraphemeEnd(prepared, i, g, fitCount)
1025
+ : g + 1
1026
+ if (end > g + 1) {
1027
+ for (let k = g + 1; k < end; k++) lineW += fitAdvances[k] + letterSpacing
1028
+ endSegmentIndex = end === fitCount ? i + 1 : i
1029
+ endGraphemeIndex = end === fitCount ? 0 : end
1030
+ endWidth = lineW - lineEndTrimmed
1031
+ break decided
569
1032
  }
570
- cursor.segmentIndex = pendingBreakSegmentIndex;
571
- cursor.graphemeIndex = 0;
572
- return pendingBreakPaintWidth;
1033
+ } else {
1034
+ const candidatePaintWidth = lineW + (baseGw + (g > fillStart ? letterSpacing : fillSpacing))
1035
+ if (candidatePaintWidth + letterSpacing > fitLimit) break decided
1036
+ lineW = candidatePaintWidth
1037
+ lineEndSegmentIndex = i
1038
+ lineEndGraphemeIndex = g + 1
1039
+ }
573
1040
  }
574
- cursor.segmentIndex = lineEndSegmentIndex;
575
- cursor.graphemeIndex = lineEndGraphemeIndex;
576
- return lineW;
1041
+ }
1042
+ if (hasContent && lineEndSegmentIndex === i && lineEndGraphemeIndex === fitCount) {
1043
+ lineEndSegmentIndex = i + 1
1044
+ lineEndGraphemeIndex = 0
1045
+ }
1046
+ }
1047
+
1048
+ // A line that ends where its source runs out at an unfit selected hyphen
1049
+ // returns as well.
1050
+ endSegmentIndex = consumedEndSegmentIndex
1051
+ endGraphemeIndex = 0
1052
+ if (pendingBreakSegmentIndex === consumedEndSegmentIndex && lineEndGraphemeIndex === 0) {
1053
+ endWidth = pendingBreakWidth
1054
+ returnsFromHyphen = true
1055
+ } else {
1056
+ endWidth = lineW - lineEndTrimmed
577
1057
  }
578
- lineW += w;
579
- lineEndSegmentIndex = i + 1;
580
- lineEndGraphemeIndex = 0;
581
- if (breakAfter) {
582
- pendingBreakSegmentIndex = i + 1;
583
- pendingBreakPaintWidth = lineW - w;
1058
+ }
1059
+
1060
+ if (hasContent) {
1061
+ if (
1062
+ returnsFromHyphen &&
1063
+ fitBreakSegmentIndex >= 0 &&
1064
+ pendingBreakSegmentIndex === lineEndSegmentIndex &&
1065
+ lineEndGraphemeIndex === 0 &&
1066
+ returnsFromUnfitHyphen(prepared, lineStartSegmentIndex, lineEndSegmentIndex, pendingBreakWidth, fitLimit)
1067
+ ) {
1068
+ endSegmentIndex = fitBreakSegmentIndex
1069
+ endGraphemeIndex = 0
1070
+ endWidth = fitBreakPaintWidth
1071
+ } else if (endSegmentIndex < 0) {
1072
+ // A line that ends at its pending break paints the pending width, and one that wraps
1073
+ // right after a run of preserved spaces and tabs hangs it, with no break there too.
1074
+ endSegmentIndex = lineEndSegmentIndex
1075
+ endGraphemeIndex = lineEndGraphemeIndex
1076
+ endWidth = pendingBreakSegmentIndex === lineEndSegmentIndex && lineEndGraphemeIndex === 0 ? pendingBreakWidth
1077
+ : items !== undefined && hangEndSegmentIndex === lineEndSegmentIndex && lineEndGraphemeIndex === 0 ? hangStartWidth
1078
+ : lineW - lineEndTrimmed
584
1079
  }
1080
+ cursor.segmentIndex = endSegmentIndex
1081
+ cursor.graphemeIndex = endGraphemeIndex
1082
+ // Preserved spaces and tabs before a hard break or the end of the text
1083
+ // hang only where they don't fit (CSS Text 3 §8.2).
1084
+ const hangsWhereUnfit =
1085
+ endGraphemeIndex === 0 &&
1086
+ hangEndSegmentIndex >= 0 &&
1087
+ (endSegmentIndex === hangEndSegmentIndex || endSegmentIndex === hangEndSegmentIndex + 1) &&
1088
+ (hangEndSegmentIndex === segmentCount || (segmentFlags[hangEndSegmentIndex] & KIND_BITS) === HARD_BREAK)
1089
+ const paintWidth = (hangsWhereUnfit ? lineW - lineEndTrimmed : endWidth) +
1090
+ getTerminalLetterSpacing(prepared, openingEdges, hangingKinds, lineStartSegmentIndex, lineStartGraphemeIndex, endSegmentIndex, endGraphemeIndex)
1091
+ lineWidth = hangsWhereUnfit ? Math.max(hangStartWidth, Math.min(paintWidth, availableWidth)) : paintWidth
1092
+ }
585
1093
  }
586
- if (!hasContent) return null;
587
- cursor.segmentIndex = lineEndSegmentIndex;
588
- cursor.graphemeIndex = lineEndGraphemeIndex;
589
- return lineW;
590
- }
591
- export function stepPreparedLineGeometryFromChunk(prepared, cursor, chunkIndex, maxWidth) {
592
- if (prepared.simpleLineWalkFastPath) {
593
- return stepPreparedSimpleLineGeometry(prepared, cursor, maxWidth);
1094
+ if (lineWidth === null) break
1095
+ lastLineWidth = lineWidth
1096
+ if (stats !== null) {
1097
+ stats.lineCount++
1098
+ if (lineWidth > stats.maxLineWidth) stats.maxLineWidth = lineWidth
594
1099
  }
595
- return stepPreparedChunkLineGeometry(prepared, cursor, chunkIndex, maxWidth);
1100
+ onLine?.(lineWidth, lineStartSegmentIndex, lineStartGraphemeIndex, cursor.segmentIndex, cursor.graphemeIndex)
1101
+ // A single-line caller owns normalization of the following line.
1102
+ if (singleLine || !normalizePreparedLineStart(prepared, cursor)) break
1103
+ }
1104
+ return lastLineWidth
596
1105
  }
597
- export function stepPreparedLineGeometry(prepared, cursor, maxWidth) {
598
- const chunkIndex = normalizePreparedLineStart(prepared, cursor);
599
- if (chunkIndex < 0) return null;
600
- return stepPreparedLineGeometryFromChunk(prepared, cursor, chunkIndex, maxWidth);
601
- }
602
- export function measurePreparedLineGeometry(prepared, maxWidth) {
603
- if (prepared.widths.length === 0) {
604
- return {
605
- lineCount: 0,
606
- maxLineWidth: 0
607
- };
1106
+
1107
+ // Steps one line of a fast-path handle from a normalized line start.
1108
+ function stepPreparedSimpleLineGeometry(
1109
+ prepared ,
1110
+ cursor ,
1111
+ maxWidth ,
1112
+ ) {
1113
+ const { widths, segmentFlags, breakableFitAdvances, entryGeometry, lineStartExtras, lineEndTrims, overflowLineEndTrims } = prepared
1114
+ // A negative width lays out as 0, as in the complex walker.
1115
+ const fitLimit = Math.max(0, maxWidth) + getEngineProfile().lineFitEpsilon
1116
+ const start = cursor.segmentIndex
1117
+
1118
+ // The first segment of the line, or the rest of one a line ended inside. One that
1119
+ // overflows and can break fills the line grapheme by grapheme.
1120
+ const startAdvances = breakableFitAdvances[start]
1121
+ const startW = lineStartExtras === null ? widths[start] : widths[start] + lineStartExtras[start]
1122
+ const startEndTrim = lineEndTrims === null ? 0 : lineEndTrims[start]
1123
+ // Blink's retry between graphemes halts an overflowing first segment where that lets it fit.
1124
+ const startTrim = overflowLineEndTrims !== null && startW - overflowLineEndTrims[start] <= fitLimit
1125
+ ? overflowLineEndTrims[start]
1126
+ : startEndTrim
1127
+ // A line that starts inside a segment where it has fresh-line geometry takes the
1128
+ // tail, and goes on, or its fresh prefixes.
1129
+ const entry = cursor.graphemeIndex > 0 && entryGeometry !== null ? entryGeometry[start] : null
1130
+ let lineW
1131
+ // The line-end trim of the last segment, where only that trim let it fit. Every
1132
+ // later segment overflows, so the line ends after it and paints that much less.
1133
+ let endTrimmed = 0
1134
+ if (entry !== null && entry.entries[cursor.graphemeIndex] !== null) {
1135
+ const segmentEnd = startAdvances .length
1136
+ const end = getFreshLineEnd(entry, cursor.graphemeIndex, segmentEnd, fitLimit)
1137
+ lineW = getSegmentEntryWidth(entry, cursor.graphemeIndex, Math.min(end, segmentEnd))
1138
+ if (end <= segmentEnd) {
1139
+ cursor.segmentIndex = end === segmentEnd ? start + 1 : start
1140
+ cursor.graphemeIndex = end === segmentEnd ? 0 : end
1141
+ return lineW
608
1142
  }
609
- const cursor = {
610
- segmentIndex: 0,
611
- graphemeIndex: 0
612
- };
613
- let lineCount = 0;
614
- let maxLineWidth = 0;
615
- if (!prepared.simpleLineWalkFastPath) {
616
- const chunkIndex = normalizePreparedLineStart(prepared, cursor);
617
- lineCount = walkPreparedComplexLines(prepared, cursor, chunkIndex, maxWidth, (width)=>{
618
- if (width > maxLineWidth) maxLineWidth = width;
619
- }).lineCount;
620
- return {
621
- lineCount,
622
- maxLineWidth
623
- };
1143
+ } else if (cursor.graphemeIndex > 0 || (startW - startTrim > fitLimit && startAdvances !== null)) {
1144
+ const fitAdvances = startAdvances
1145
+ const startExtras = prepared.breakableLineStartExtras?.[start] ?? null
1146
+ let g = cursor.graphemeIndex + 1
1147
+ lineW = startExtras === null ? fitAdvances[g - 1] : fitAdvances[g - 1] + startExtras[g - 1]
1148
+ // A line that starts inside a padded rich-inline item, or fills its first segment, pays
1149
+ // the item's extraWidth (ParagraphSegmentData).
1150
+ const items = prepared.items
1151
+ if (items !== undefined && (g > 1 ? items.insideExtras !== null : items.fillExtras !== null)) lineW += g > 1 ? items.insideExtras [start] : items.fillExtras [start]
1152
+ // A line that holds only an overflowing grapheme keeps the graphemes after it
1153
+ // that can't start a line, and ends.
1154
+ const overflowEnd = lineW > fitLimit ? getOverflowingFirstGraphemeEnd(prepared, start, g - 1, fitAdvances.length) : g
1155
+ if (overflowEnd > g) {
1156
+ for (; g < overflowEnd; g++) lineW += fitAdvances[g]
1157
+ cursor.segmentIndex = g === fitAdvances.length ? start + 1 : start
1158
+ cursor.graphemeIndex = g === fitAdvances.length ? 0 : g
1159
+ return lineW
624
1160
  }
625
- while(true){
626
- const lineWidth = stepPreparedLineGeometry(prepared, cursor, maxWidth);
627
- if (lineWidth === null) {
628
- return {
629
- lineCount,
630
- maxLineWidth
631
- };
632
- }
633
- lineCount++;
634
- if (lineWidth > maxLineWidth) maxLineWidth = lineWidth;
1161
+ for (; g < fitAdvances.length; g++) {
1162
+ if (lineW + fitAdvances[g] > fitLimit) {
1163
+ cursor.graphemeIndex = g
1164
+ return lineW
1165
+ }
1166
+ lineW += fitAdvances[g]
635
1167
  }
1168
+ } else {
1169
+ lineW = startW
1170
+ if (startW > fitLimit) endTrimmed = startTrim
1171
+ }
1172
+
1173
+ // Every boundary of a fast-path handle is a break, so the line takes whole
1174
+ // segments until one overflows. An overflowing space or ZWSP hangs and ends the
1175
+ // line; before other text, the line leaves out a space or ZWSP it ends with.
1176
+ for (let i = start + 1; i < widths.length; i++) {
1177
+ const w = widths[i]
1178
+ const endTrim = lineEndTrims === null ? 0 : lineEndTrims[i]
1179
+ if (lineW + w - endTrim > fitLimit) {
1180
+ const hangs = breaksAfterKind(segmentFlags[i] & KIND_BITS)
1181
+ cursor.segmentIndex = hangs ? i + 1 : i
1182
+ cursor.graphemeIndex = 0
1183
+ return !hangs && breaksAfterKind(segmentFlags[i - 1] & KIND_BITS) ? lineW - widths[i - 1] : lineW - endTrimmed
1184
+ }
1185
+ lineW += w
1186
+ endTrimmed = lineW > fitLimit ? endTrim : 0
1187
+ }
1188
+ cursor.segmentIndex = widths.length
1189
+ cursor.graphemeIndex = 0
1190
+ return lineW - endTrimmed
1191
+ }
1192
+
1193
+ // Steps one line from a normalized line start. A cursor inside a segment needs that segment's
1194
+ // breakable fit advances.
1195
+ export function stepPreparedLineGeometryFromStart(
1196
+ prepared ,
1197
+ cursor ,
1198
+ maxWidth ,
1199
+ ) {
1200
+ if (prepared.simpleLineWalkFastPath) return stepPreparedSimpleLineGeometry(prepared, cursor, maxWidth)
1201
+ return walkPreparedComplexLines(prepared, cursor, maxWidth, undefined, null, true)
636
1202
  }