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
@@ -0,0 +1,807 @@
1
+ /*! Pretext Unreleased (bac1774c0) | MIT; BSD/Unicode/ICU notices in LICENSE | Copyright (c) 2026 Pretext contributors */
2
+ // Preparation's measurement phase: each segment of a text analysis measured in a font, with
3
+ // the facts the line walkers read at a line's edges. layout.ts and rich-inline.ts prepare
4
+ // through it, as they walk through line-break.ts.
5
+
6
+ import { observeSegmentEntries, textMayHaveEntryGeometry, } from './entry-geometry.js'
7
+ import { getHanKerningTrims, textMayHanKern, } from './han-kerning.js'
8
+ import { findGraphemeEnds, } from './graphemes.js'
9
+ import { scriptClassFacts } from './generated/engine-break-data.js'
10
+ import { DEFAULT_IGNORABLE, getClass, hasProperty, lazyRegExp, unpackClasses, } from './line-breaks.js'
11
+ import {
12
+ CONTROL,
13
+ HARD_BREAK,
14
+ KIND_BITS,
15
+ ONE_CLUSTER,
16
+ PRESERVED_SPACE,
17
+ SOFT_HYPHEN,
18
+ SPACE,
19
+ SPACED,
20
+ TAB,
21
+ TEXT,
22
+ UNBROKEN,
23
+ ZERO_WIDTH_BREAK,
24
+ ZERO_WIDTH_GLUE,
25
+
26
+
27
+ } from './analysis.js'
28
+ import {
29
+
30
+
31
+
32
+ getCorrectedSegmentWidth,
33
+ getEmojiCorrection,
34
+ getFollowingSpaceMetrics,
35
+ getFontMeasurement,
36
+ getFontSpaceKerning,
37
+ getHyphenText,
38
+ getSegmentFit,
39
+ getSegmentMetrics,
40
+ getSpaceKerning,
41
+ getSpaceWidth,
42
+ getTextWidth,
43
+ measureWithLetterSpacing,
44
+ noSpaceKerning,
45
+ textMayContainEmoji,
46
+
47
+
48
+ zeros,
49
+ } from './measurement.js'
50
+
51
+
52
+
53
+
54
+ // Text and spaces take letter spacing after each grapheme; a ZWSP takes none.
55
+ function countRenderedSpacingGraphemes(text , kind , graphemeTable ) {
56
+ return kind === ZERO_WIDTH_BREAK ? 0 : findGraphemeEnds(graphemeTable, text, 0, text.length, null)
57
+ }
58
+
59
+ function addInternalLetterSpacing(width , graphemeCount , letterSpacing ) {
60
+ return graphemeCount > 1 ? width + (graphemeCount - 1) * letterSpacing : width
61
+ }
62
+
63
+ // Code points that WebKit's FontCascade::characterRangeCodePath sends to the
64
+ // complex text path, stored as start/end pairs. So does a ZWJ after an emoji.
65
+ const complexTextPathRanges = [
66
+ 0x02E5, 0x02E9, 0x0300, 0x036F, 0x0591, 0x05BD, 0x05BF, 0x05CF, 0x0600, 0x109F,
67
+ 0x1100, 0x11FF, 0x135D, 0x135F, 0x1700, 0x18AF, 0x1900, 0x194F, 0x1980, 0x19DF,
68
+ 0x1A00, 0x1CFF, 0x1DC0, 0x1DFF, 0x20D0, 0x20FF, 0x26F9, 0x26F9, 0x2CEF, 0x2CF1,
69
+ 0x302A, 0x302F, 0x3099, 0x309C, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, 0xA800, 0xABFF,
70
+ 0xD7B0, 0xD7FF, 0xFE00, 0xFE0F, 0xFE20, 0xFE2F, 0x10A00, 0x10A5F, 0x11000, 0x110CF,
71
+ 0x11100, 0x111DF, 0x11200, 0x1124F, 0x112B0, 0x1137F, 0x11400, 0x114DF, 0x11580, 0x1165F,
72
+ 0x11680, 0x116CF, 0x11700, 0x11CBF, 0x16B00, 0x16B8F, 0x1E900, 0x1E95F, 0x1F1E6, 0x1F1FF,
73
+ 0x1F3FB, 0x1F3FF, 0xE0000, 0xE007F, 0xE0100, 0xE01EF,
74
+ ]
75
+
76
+ const extendedPictographicRe = /\p{Extended_Pictographic}/u
77
+ const leadingCombiningMarkRe = lazyRegExp(String.raw`^\p{M}`, 'u')
78
+ // Decimal digits and the joiners of numbers, times and dates.
79
+ const numericRunRe = /^[\p{Nd}:\-/×,.+\u2013\u2014]+$/u
80
+ const markRunRe = /^\p{M}+$/u
81
+ const nonspacingMarkRunRe = /^\p{Mn}+$/u
82
+ const controlOrMarkRunRe = /^(?:[\p{Cc}\u2028\u2029]|\p{M}+)$/u
83
+ const controlCharacterRe = /^[\p{Cc}\u2028\u2029]$/u
84
+ // The fewest UTF-16 units of a long chain of mark runs that a run's context keeps after
85
+ // the grapheme (getMarkContext): measured after the whole chain, a long chain prepared in
86
+ // time that grows with the square of its length. Safari gives a run a width that depends
87
+ // on how far it sits from the grapheme, up to 61 units in the chains measured
88
+ // (RESEARCH.md, Break Opportunities From Engine Data), so a context that keeps fewer
89
+ // moves widths there.
90
+ const MARK_CHAIN_CONTEXT_UNITS = 96
91
+
92
+ function needsComplexTextPath(text ) {
93
+ let previousIsEmoji = false
94
+ for (let i = 0; i < text.length;) {
95
+ const codePoint = text.codePointAt(i)
96
+ i += codePoint > 0xFFFF ? 2 : 1
97
+ if (codePoint === 0x200D && previousIsEmoji) return true
98
+ previousIsEmoji = codePoint > 0xFFFF && extendedPictographicRe.test(String.fromCodePoint(codePoint))
99
+ for (let range = 0; range < complexTextPathRanges.length && codePoint >= complexTextPathRanges[range] ; range += 2) {
100
+ if (codePoint <= complexTextPathRanges[range + 1] ) return true
101
+ }
102
+ }
103
+ return false
104
+ }
105
+
106
+ const explicitBidiControlRe = /[\u202A-\u202E\u2066-\u2069]/
107
+ // Format characters stand for bidi class BN, except the direction marks LRM,
108
+ // RLM and ALM, which are strong characters like letters.
109
+ const trailingFormatCharacterRe = /(?![\u200E\u200F\u061C])\p{Cf}$/u
110
+ // Letters in the right-to-left blocks have bidi class R or AL, as do RLM and
111
+ // ALM. Every other letter except modifier letters has class L, as does LRM.
112
+ const rightToLeftLetterRe = /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u200F\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u
113
+ // Either of the two: a right-to-left letter or an explicit bidi control.
114
+ const mixedDirectionRe = /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u200F\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}\u202A-\u202E\u2066-\u2069]/u
115
+ // The last letter or direction mark before format characters other than a soft
116
+ // hyphen, and the first letter, direction mark or ASCII digit after the space,
117
+ // past spaces and format characters.
118
+ const letterBeforeFormatTailRe = lazyRegExp(String.raw`([\p{Lu}\p{Ll}\p{Lt}\p{Lo}\u200E\u200F\u061C])\p{M}*(?:(?![\u00AD\u200E\u200F\u061C])\p{Cf})+$`, 'u')
119
+ const letterAfterSpacesRe = lazyRegExp(String.raw` (?: |(?![\u200E\u200F\u061C])\p{Cf})*([0-9\p{Lu}\p{Ll}\p{Lt}\p{Lo}\u200E\u200F\u061C])`, 'uy')
120
+
121
+ function isSpaceKind(kind ) {
122
+ return kind === SPACE || kind === PRESERVED_SPACE
123
+ }
124
+
125
+ // Blink splits a text into script runs and shapes each apart (ScriptRunIterator,
126
+ // script_run_iterator.cc; HarfBuzzShaper::Shape, harfbuzz_shaper.cc:1063-1104). Two rules of
127
+ // the Chromium profile turn on the run a character is in: a word kerns with a space only
128
+ // where the two share a run (spaceSharesScriptRun), and the letters of a run in a script
129
+ // whose letters join take no letter spacing (getUnspacedGraphemes). Both read the runs
130
+ // through readScriptRuns, with a character's scripts as bits: Cyrillic, Greek, Latin, one bit
131
+ // for the seven scripts whose letters join, and one for every other script, so two scripts
132
+ // that share a bit read as one; a character of any script has all five. Cyrillic, Greek and
133
+ // Latin are in Blink's order for a Common character's extensions, by ICU script code with
134
+ // Latin last (GetScripts, :191-198), so a run's lowest bit is the script it resolves to. A
135
+ // character's scripts, and a few facts more in the bits above them, come from ICU's Script,
136
+ // Script_Extensions and bracket pairs as Chrome's build holds them, and Firefox's: a class
137
+ // for every code point ('chromium/script') and each class's bits (scriptClassFacts).
138
+ // scripts/generate-engine-break-data.ts has which scripts a character is given there, and
139
+ // what that leaves out.
140
+ export const OTHER_SCRIPT = 1
141
+ export const CYRILLIC_SCRIPT = 2
142
+ export const GREEK_SCRIPT = 4
143
+ export const LATIN_SCRIPT = 8
144
+ export const CURSIVE_SCRIPT = 16
145
+ export const ANY_SCRIPT = 31
146
+ // A mark that scripts list, as they do the Arabic vowel signs, is of any script itself and has
147
+ // those scripts in the next five bits. A Common character that no script lists takes them
148
+ // from such a mark right after it (FetchNextCharacter, :624-635), so a digit or a dotted circle
149
+ // that carries a fatha starts an Arabic run.
150
+ export const MARK_SCRIPTS_SHIFT = 5
151
+ export const TAKES_MARK_SCRIPTS = 1 << 10
152
+ // A character whose own script, whatever else lists it, is one whose letters join.
153
+ export const CURSIVE_LETTER = 1 << 11
154
+ // A paired bracket, and from this bit up a closing bracket's code less its opening bracket's.
155
+ export const OPENING_BRACKET = 1 << 12
156
+ export const CLOSING_BRACKET = 1 << 13
157
+ export const OPENING_BRACKET_SHIFT = 14
158
+
159
+ // Every code point's class, unpacked when a text first asks for a script.
160
+ let scriptClasses = null
161
+ function getScriptFacts(c ) {
162
+ return scriptClassFacts[getClass(scriptClasses ??= unpackClasses('chromium/script'), c)]
163
+ }
164
+
165
+ // The facts of the character at text[i], with the scripts it has there, as Blink reads them
166
+ // (GetScripts, script_run_iterator.cc:118-215).
167
+ function getScriptFactsAt(text , i ) {
168
+ const c = text.codePointAt(i)
169
+ const facts = getScriptFacts(c)
170
+ const next = i + (c > 0xFFFF ? 2 : 1)
171
+ if ((facts & TAKES_MARK_SCRIPTS) !== 0 && next < text.length) {
172
+ const markScripts = getScriptFacts(text.codePointAt(next) ) >> MARK_SCRIPTS_SHIFT & ANY_SCRIPT
173
+ if (markScripts !== 0) return facts & ~ANY_SCRIPT | markScripts
174
+ }
175
+ return facts
176
+ }
177
+
178
+ // Whether a text holds a character whose scripts are cursive, or a mark that gives them: no
179
+ // other text has a grapheme that takes no letter spacing. ASCII, which is Latin letters and
180
+ // Common characters, has neither.
181
+ function mayHoldCursive(text ) {
182
+ for (let i = 0; i < text.length; i++) {
183
+ if (text.charCodeAt(i) < 0x80) continue
184
+ const facts = getScriptFacts(text.codePointAt(i) )
185
+ if ((facts & ANY_SCRIPT) === CURSIVE_SCRIPT || (facts >> MARK_SCRIPTS_SHIFT & ANY_SCRIPT) === CURSIVE_SCRIPT) return true
186
+ }
187
+ return false
188
+ }
189
+
190
+ // How far a text's script runs are read, the scripts the run there can be in, and each
191
+ // bracket open there: its opening character, then the script of the run it was opened in,
192
+ // 0 while that run goes on.
193
+
194
+
195
+ // The scripts of the run text[to - 1] is in, read on from the last call as
196
+ // ScriptRunIterator::Consume reads them (script_run_iterator.cc:325-429): a run keeps the
197
+ // scripts its characters share and ends before one that shares none. A run of no script yet
198
+ // takes the script of the first character after it that has one, so the read goes on to that
199
+ // character. A closing bracket takes the script of the run its opening bracket is in, once
200
+ // that run has ended, among the last 32 opened, and closes the ones opened since; its own
201
+ // stays open, so a second closing bracket goes back to that run too (OpenBracket,
202
+ // CloseBracket and FixupStack, :431-489, :567-590).
203
+ function readScriptRuns(runs , text , to ) {
204
+ const { openBrackets } = runs
205
+ while (runs.read < to || (runs.scripts === ANY_SCRIPT && runs.read < text.length)) {
206
+ const c = text.codePointAt(runs.read)
207
+ const facts = getScriptFactsAt(text, runs.read)
208
+ let scripts = facts & ANY_SCRIPT
209
+ // No bracket's code is as low as a run's script.
210
+ const opened = (facts & CLOSING_BRACKET) !== 0 ? openBrackets.lastIndexOf(c - (facts >> OPENING_BRACKET_SHIFT)) : -1
211
+ if (opened >= 0) {
212
+ if (openBrackets[opened + 1] !== 0) scripts = openBrackets[opened + 1]
213
+ openBrackets.length = opened + 2
214
+ }
215
+ if ((runs.scripts & scripts) !== 0) {
216
+ runs.scripts &= scripts
217
+ } else {
218
+ // The run that ends resolves to its first script (ResolveCurrentScript, :639-642), which
219
+ // the brackets opened in it take.
220
+ for (let k = openBrackets.length - 1; k > 0 && openBrackets[k] === 0; k -= 2) openBrackets[k] = runs.scripts & -runs.scripts
221
+ runs.scripts = scripts
222
+ }
223
+ if ((facts & OPENING_BRACKET) !== 0) {
224
+ if (openBrackets.length === 64) openBrackets.splice(0, 2)
225
+ openBrackets.push(c, 0)
226
+ }
227
+ runs.read += c > 0xFFFF ? 2 : 1
228
+ }
229
+ return runs.scripts
230
+ }
231
+
232
+ // Whether the space before the text segment text[at..end) is in the script run of the segment's
233
+ // first character past default ignorables, the one it kerns with: a space joins the run of the
234
+ // text before it (ScriptRunIterator::MergeSets, :490-510). The nearest character before the
235
+ // space that has one script names that run, so the search back starts before the space, at
236
+ // text[at - 2], and ends there; a closing bracket, or a character of several scripts, takes its
237
+ // script from the runs before it, which are then read.
238
+ function spaceSharesScriptRun(runs , text , at , end ) {
239
+ let scripts = getScriptFactsAt(text, at) & ANY_SCRIPT
240
+ // A default ignorable with a script of its own, as U+3164, counts as the word's first letter.
241
+ for (let first = at; scripts === ANY_SCRIPT && first + 1 < end && hasProperty(text.charCodeAt(first), DEFAULT_IGNORABLE); first++) scripts = getScriptFactsAt(text, first + 1) & ANY_SCRIPT
242
+ if (scripts === ANY_SCRIPT) return true
243
+ for (let i = at - 2; i >= 0; i--) {
244
+ if ((text.charCodeAt(i) & 0xFC00) === 0xDC00 && i > 0) i--
245
+ const facts = getScriptFactsAt(text, i)
246
+ if ((facts & CLOSING_BRACKET) !== 0) break
247
+ const before = facts & ANY_SCRIPT
248
+ if (before === ANY_SCRIPT) continue
249
+ if ((before & (before - 1)) === 0) return (before & scripts) !== 0
250
+ break
251
+ }
252
+ return (readScriptRuns(runs, text, at) & scripts) !== 0
253
+ }
254
+
255
+ // The scripts whose letters join, Arabic, Syriac, N'Ko, Mandaic, Mongolian, Phags-pa and
256
+ // Hanifi Rohingya, take no letter spacing in Blink and Gecko, which name the same seven
257
+ // (IsCursiveScript, shape_result.cc:977-990; UnicodeProperties.h:350-355). Gecko asks the
258
+ // script of a cluster's first character (GetSpacingInternal, nsTextFrame.cpp:4202-4213), so
259
+ // digits and punctuation among the letters keep their spacing. Blink asks the script of the
260
+ // shaping run the cluster is in and spaces only its spaces (ComputeSpacing,
261
+ // shape_result_spacing.cc:103-131, behind the runtime flag
262
+ // IgnoreLetterSpacingInCursiveScripts): a run takes in the characters of no script after
263
+ // it, the ones that start the text, and the punctuation its script shares, so of an Arabic
264
+ // word, a space and `123.` only the space is spaced. That is Chrome since 149; 138 to 148
265
+ // don't space the run's spaces either, and before 138 every letter is spaced, which no
266
+ // version check here follows (ENGINE_FOLLOWUPS.md, Letter spacing). WebKit spaces every
267
+ // glyph with an advance. Chrome 154, Firefox 156 and webkit-host lay 67 strings out so
268
+ // (2026-10-03). The profile's unspacedCursive names the engine's rule. This gives the
269
+ // graphemes of the text segment text[start..end) that take no letter spacing, as ascending
270
+ // indices, or null without any: in Gecko, which is given no runs, those whose first
271
+ // character is a letter of a cursive script; in Blink those whose first character is in a
272
+ // run that resolves to one and isn't a no-break space. A rich item's text starts a run of
273
+ // its own (ENGINE_FOLLOWUPS.md, Letter spacing).
274
+ function getUnspacedGraphemes(text , start , end , graphemeTable , runs ) {
275
+ const ends = new Int32Array(end - start)
276
+ const count = findGraphemeEnds(graphemeTable, text, start, end, ends)
277
+ let unspaced = null
278
+ for (let g = 0, at = start; g < count; at = ends[g++] ) {
279
+ const joins = runs === null ? (getScriptFacts(text.codePointAt(at) ) & CURSIVE_LETTER) !== 0
280
+ : readScriptRuns(runs, text, at + 1) === CURSIVE_SCRIPT && text.charCodeAt(at) !== 0xA0
281
+ if (joins) (unspaced ??= []).push(g)
282
+ }
283
+ return unspaced
284
+ }
285
+
286
+ // Bidi class B: the characters that end a bidi paragraph.
287
+ function isParagraphSeparatorCode(code ) {
288
+ return code === 0x0a || code === 0x0d || (code >= 0x1c && code <= 0x1e) || code === 0x85 || code === 0x2029
289
+ }
290
+
291
+ // The lists of a rich-inline paragraph's handle that each item's measurement adds its segments
292
+ // to; `segmentFlags` has room for them from `widths.length` on.
293
+
294
+
295
+
296
+
297
+
298
+
299
+ // Measures segments [from, to) of an analysis in a font, alone: all of a text's, or those of one
300
+ // item of a rich-inline paragraph (RESEARCH.md, Rich Inline As One Paragraph), where only the
301
+ // marks Blink halts and a run of U+3000 read the text around the item (getHanKerningTrims,
302
+ // addIdeographicSpaceHangs). Each segment's width, flags byte and advances go on the end of
303
+ // three lists: a text's own, or for an item its paragraph's, so an item is measured with no
304
+ // copy of its part of the analysis and no lists of its own to copy from. The handle returned
305
+ // holds those lists; what else it holds is the measured segments' alone, each list from the
306
+ // first of them.
307
+ export function measureAnalysis(
308
+ analysis ,
309
+ from ,
310
+ to ,
311
+ font ,
312
+ includeSegments ,
313
+ letterSpacing ,
314
+ engineProfile ,
315
+ language ,
316
+ // Whether text segments take emergency breaks between graphemes: an atomic rich item,
317
+ // which is only laid out whole, takes none.
318
+ overflowBreaks ,
319
+ // The paragraph's lists for a rich-inline item, null for a text.
320
+ paragraph ,
321
+ paragraphHyphen = null,
322
+ ) {
323
+ const { normalized, texts, starts, flags } = analysis
324
+ // The measured text and where it starts in the analysis's: what is asked of the whole text
325
+ // is asked of it, and places in it count from its start.
326
+ const ownStart = from === 0 ? 0 : starts[from]
327
+ const own = from === 0 && to === flags.length ? normalized : to === from + 1 ? texts[from] : normalized.slice(ownStart, to < flags.length ? starts[to] : normalized.length)
328
+ // A text's lists are made here, where they are filled, and not by its caller: V8 types a
329
+ // list by what the `[]` that made it has seen, and with every caller making them, in a loop
330
+ // that pushed, Chrome prepared long texts 5-11% slower and Firefox walked CJK lines 12%
331
+ // slower. A paragraph's lists are its caller's, and the measuring loop below stores by index
332
+ // for them. With those stores a text's lists made by its caller weren't timed, so whether
333
+ // they still have to be made here isn't known (RESEARCH.md, Keeping Work Bounded, JavaScript
334
+ // Engines, under A list made where it is filled).
335
+ const widths = paragraph === null ? [] : paragraph.widths
336
+ // An engine's scan makes one prepared segment per analysis segment, whose flags the
337
+ // walkers, layout()'s count and rich-inline layout read where the scan gives no break.
338
+ const segmentFlags = paragraph === null ? new Uint8Array(to - from) : paragraph.segmentFlags
339
+ const breakableFitAdvances = paragraph === null ? [] : paragraph.breakableFitAdvances
340
+ const segments = includeSegments ? [] : null
341
+ // Where the first measured segment goes in the lists.
342
+ const base = widths.length
343
+ const hasLetterSpacing = letterSpacing !== 0
344
+ const fontMeasurement = getFontMeasurement(font, language, hasLetterSpacing)
345
+ const emojiCorrection = textMayContainEmoji(own) ? getEmojiCorrection(font, fontMeasurement) : 0
346
+ const spaceWidth = getSpaceWidth(fontMeasurement)
347
+ // The advance between tab stops: eight spaces, each with its letter spacing where the
348
+ // engine counts it (EngineProfile's letterSpaceTabStops). Gecko rounds the space and the
349
+ // letter spacing to app units, sixtieths of a pixel, each on its own
350
+ // (ComputeTabWidthAppUnits, nsTextFrame.cpp:3875-3906).
351
+ const tabStopSpacing = engineProfile.letterSpaceTabStops ? letterSpacing : 0
352
+ const tabStopAdvance = engineProfile.tabsInAppUnits
353
+ ? (Math.round(spaceWidth * 60) + Math.round(tabStopSpacing * 60)) * 8 / 60
354
+ : (spaceWidth + tabStopSpacing) * 8
355
+ // The least a tab advances, measured at a text's first tab (EngineProfile's tabMinimumCharacter).
356
+ let minimumTabAdvance = 0
357
+ // Only a segment holding a default-ignorable code point has entry geometry, so text
358
+ // without one doesn't look for it.
359
+ const entryFitBasis = engineProfile.entryFitBasis !== 'disabled' && textMayHaveEntryGeometry(own) ? engineProfile.entryFitBasis : 'disabled'
360
+
361
+ // A collapsed space's first source character, for engines that look at the
362
+ // source after a text item.
363
+ function getSpaceSourceCode(analysisIndex ) {
364
+ const start = starts[analysisIndex]
365
+ return analysis.spaceSources === null ? normalized.charCodeAt(start) : analysis.spaceSources[start]
366
+ }
367
+
368
+ // A WebKit text item runs to its next break opportunity, so it also owns any
369
+ // zero-width breaks before the space. The item is measured with one following
370
+ // U+0020 minus an unshaped space, which keeps the kerning between the item's
371
+ // end and that space. With letter spacing the same measurement also moves the
372
+ // space's gap onto the item and clamps the item at zero, which the
373
+ // per-grapheme gap model does not represent, so only the unspaced case takes
374
+ // the kerning. A soft hyphen before the space also takes none: on an RTL page
375
+ // WebKit needs about a hyphen's width more to fit such an item, and
376
+ // preparation cannot see the page direction. Returns the zero-width breaks
377
+ // between the text and the space, or null when the text takes no kerning.
378
+ function getFollowingSpaceTail(analysisIndex , text ) {
379
+ if (!engineProfile.measureTextWithFollowingSpace || hasLetterSpacing) return null
380
+ let next = analysisIndex + 1
381
+ while (next < to && (flags[next] & KIND_BITS) === ZERO_WIDTH_BREAK) next++
382
+ if (next >= to) return null
383
+ const nextKind = flags[next] & KIND_BITS
384
+ if ((nextKind !== SPACE && nextKind !== PRESERVED_SPACE) || getSpaceSourceCode(next) !== 0x20) return null
385
+ const tail = normalized.slice(starts[analysisIndex + 1], starts[next])
386
+ return formatTailStaysWithWord(tail === '' ? text : text + tail, starts[next] - ownStart) ? tail : null
387
+ }
388
+
389
+ // Text directly before such a space is measured together with the space
390
+ // instead of alone, so its kerned width costs no extra Canvas call. Other
391
+ // occurrences of the same text measure it alone.
392
+ function getTextMetrics(text , followingSpaceTail ) {
393
+ return followingSpaceTail === '' ? getFollowingSpaceMetrics(text, fontMeasurement) : getSegmentMetrics(text, fontMeasurement)
394
+ }
395
+
396
+ // A zero-width break before the space ends the measured item, so only the
397
+ // item's kerning with the space is added to the text's own width.
398
+ function getTailKerning(item ) {
399
+ return getFollowingSpaceMetrics(item, fontMeasurement).width - getSegmentMetrics(item, fontMeasurement).width - spaceWidth
400
+ }
401
+
402
+ // WebKit splits text items where resolved bidi levels change before it
403
+ // measures them. Format characters between a word and the space resolve with
404
+ // that space, so they stay in the word's item, and the word's last glyph keeps
405
+ // its kerning with the space, only when the space resolves to the word's
406
+ // direction. Without the paragraph direction that is known when the word's last
407
+ // letter or direction mark and the first one after the space have the same
408
+ // direction, with only spaces and format characters between (UAX #9 N1).
409
+ // Explicit embeddings, overrides and isolates end with their paragraph (UAX #9
410
+ // X8), so only controls in the space's own paragraph leave its direction
411
+ // unknown. Spaces arrive in order, so each paragraph is scanned once.
412
+ let hasExplicitBidiControls = null
413
+ let controlParagraphEnd = -1
414
+ let paragraphHasExplicitBidiControls = false
415
+ function spaceParagraphHasExplicitBidiControls(spaceStart ) {
416
+ hasExplicitBidiControls ??= explicitBidiControlRe.test(own)
417
+ if (!hasExplicitBidiControls) return false
418
+ if (spaceStart < controlParagraphEnd) return paragraphHasExplicitBidiControls
419
+ let start = spaceStart
420
+ while (start > 0 && !isParagraphSeparatorCode(own.charCodeAt(start - 1))) start--
421
+ let end = spaceStart
422
+ while (end < own.length && !isParagraphSeparatorCode(own.charCodeAt(end))) end++
423
+ controlParagraphEnd = end
424
+ paragraphHasExplicitBidiControls = explicitBidiControlRe.test(own.slice(start, end))
425
+ return paragraphHasExplicitBidiControls
426
+ }
427
+ function formatTailStaysWithWord(item , spaceStart ) {
428
+ if (!trailingFormatCharacterRe.test(item)) return true
429
+ const before = letterBeforeFormatTailRe().exec(item)
430
+ if (before === null) return false
431
+ letterAfterSpacesRe().lastIndex = spaceStart
432
+ const after = letterAfterSpacesRe().exec(own)
433
+ if (after === null) return false
434
+ // An ASCII digit takes the direction of the text before it (UAX #9 W7 and N1).
435
+ const next = after[1]
436
+ return (next.charCodeAt(0) <= 0x39 || rightToLeftLetterRe.test(before[1] ) === rightToLeftLetterRe.test(next)) &&
437
+ !spaceParagraphHasExplicitBidiControls(spaceStart)
438
+ }
439
+
440
+ // The source a run of combining marks shapes after when only zero-width glue,
441
+ // controls or other such runs, with no break, separate the run from the grapheme
442
+ // before it: that grapheme and what separates them. Without the separators, Canvas
443
+ // can compose the marks with the grapheme or draw both in another font. A walk that
444
+ // reaches the last run that asked takes that run's answer, so each segment is walked
445
+ // and each grapheme found once, however many runs share it. Once what separates them
446
+ // passes MARK_CHAIN_CONTEXT_UNITS, the context keeps the grapheme and the fewest of the
447
+ // chain's last runs, each with the separators before it, that hold at least that many
448
+ // units: in Chrome, Safari and Firefox, runs of 1 to 400 marks then measure as they do
449
+ // after the whole chain, to 0.002px, and without the grapheme some took 25px less
450
+ // (RESEARCH.md, Break Opportunities From Engine Data).
451
+ let markRunIndex = -1
452
+ let markBaseStart = -1 // where that run's grapheme starts in the normalized text, or -1
453
+ let markChainStart = -1 // the segment after that grapheme
454
+ let markChainKept = -1 // the first segment of the chain the context keeps
455
+ // Asked only for a text segment no break comes before.
456
+ function getMarkContext(analysisIndex , text ) {
457
+ if (!markRunRe.test(text)) return null
458
+ let baseStart = -1
459
+ for (let k = analysisIndex - 1; k >= from; k--) {
460
+ const kind = flags[k] & KIND_BITS
461
+ const start = starts[k]
462
+ const end = starts[k + 1]
463
+ if (kind === ZERO_WIDTH_GLUE || ((kind === TEXT || kind === CONTROL) && controlOrMarkRunRe.test(texts[k] ))) {
464
+ if (k !== markRunIndex) continue
465
+ baseStart = markBaseStart
466
+ } else if (kind === TEXT) {
467
+ const ends = new Int32Array(end - start)
468
+ const count = findGraphemeEnds(engineProfile.graphemeTable, normalized, start, end, ends)
469
+ baseStart = count > 1 ? ends[count - 2] : start
470
+ markChainStart = markChainKept = k + 1
471
+ }
472
+ break
473
+ }
474
+ markRunIndex = analysisIndex
475
+ markBaseStart = baseStart
476
+ if (baseStart < 0) return null
477
+ const start = starts[analysisIndex]
478
+ if (start - baseStart <= MARK_CHAIN_CONTEXT_UNITS) return normalized.slice(baseStart, start)
479
+ // The kept part starts after the grapheme or a run of marks, moves only forward and
480
+ // never holds fewer than MARK_CHAIN_CONTEXT_UNITS.
481
+ for (let k = markChainKept + 1; start - starts[k] >= MARK_CHAIN_CONTEXT_UNITS; k++) {
482
+ if (markRunRe.test(texts[k - 1] )) markChainKept = k
483
+ }
484
+ if (markChainKept === markChainStart) return normalized.slice(baseStart, start)
485
+ return normalized.slice(baseStart, starts[markChainStart]) + normalized.slice(starts[markChainKept], start)
486
+ }
487
+
488
+ // The font's kerning with the space where words take Blink's kerning with the spaces beside
489
+ // them, or null. Blink shapes nothing across a change of direction
490
+ // (ShouldBreakShapingBeforeText, inline_node.cc:472-490), and which spaces share a word's
491
+ // direction depends on the paragraph's, which preparation can't see, so text with a
492
+ // right-to-left letter or an explicit bidi control takes none.
493
+ let fontSpaceKerning = engineProfile.kernsSpacesInScriptRun && own.includes(' ') ? getFontSpaceKerning(fontMeasurement) : null
494
+ if (fontSpaceKerning !== null && mixedDirectionRe.test(own)) fontSpaceKerning = null
495
+ const scriptRuns = { read: 0, scripts: ANY_SCRIPT, openBrackets: [] }
496
+ // What the word before a space adds to that space, the next segment.
497
+ let spaceShare = 0
498
+
499
+ // Whether the text may hold graphemes that take no letter spacing in this engine.
500
+ const cursiveSpacing = hasLetterSpacing && engineProfile.unspacedCursive !== 'none' && mayHoldCursive(own)
501
+
502
+ // Text of the simple walkers' kinds without letter spacing. They lay it out where
503
+ // the scan breaks at every segment boundary, and layout() counts it with the
504
+ // simple stepper where it doesn't (countPreparedLines).
505
+ let simpleKinds = !hasLetterSpacing
506
+ let entryGeometry = null
507
+ let lineStartProhibitions = null
508
+ let breakableLineStartExtras = null
509
+ // WebKit's line-start rule applies only in text holding a code unit above U+00FF.
510
+ const keepsLineStartPunctuation = engineProfile.keepsLineStartPunctuation && /[\u0100-\uFFFF]/.test(own)
511
+ let discretionaryHyphenContexts = null
512
+ let previousJoinablePiece = null
513
+ let previousJoinableMetrics = null
514
+
515
+ // Pieces split by a soft hyphen are measured apart, but Blink and Gecko shape
516
+ // the unbroken text together: cursive joins, marks and kerning across the soft
517
+ // hyphen. Canvas shows how much narrower the neighbors measure joined than
518
+ // apart, which isolated widths can't show when proving that a hyphen overflows.
519
+ // WebKit's return fits each side as measured alone and takes none (unfitHyphenRetreat).
520
+ const returnFitsEachSideAlone = engineProfile.unfitHyphenRetreat === 'full-width-or-first'
521
+ function getJoinedNarrowing(analysisIndex , before , beforeMetrics ) {
522
+ if (before === null) return 0
523
+ let next = analysisIndex + 1
524
+ while (next < to && (flags[next] & KIND_BITS) === SOFT_HYPHEN) next++
525
+ if (next >= to || (flags[next] & KIND_BITS) !== TEXT) return 0
526
+ const after = texts[next]
527
+ const apart = getCorrectedSegmentWidth(before, beforeMetrics , fontMeasurement, emojiCorrection) + getTextWidth(after, fontMeasurement, emojiCorrection)
528
+ const together = getTextWidth(before + after, fontMeasurement, emojiCorrection)
529
+ return apart - together > engineProfile.lineFitEpsilon ? apart - together : 0
530
+ }
531
+
532
+ function getEntryGeometry(text , fit , width , fitBasis ) {
533
+ // The fit fixes the text, font and advances, and Pretext sets no other context state.
534
+ // The WebKit profile moves the advances by a following space, and it observes no
535
+ // entries; the Chromium profile's kerning with a space is in the width alone, which
536
+ // its fresh entries don't read.
537
+ const cached = fit.entryGeometry
538
+ if (cached !== null && cached.letterSpacing === letterSpacing && cached.emojiCorrection === emojiCorrection) return cached.geometry
539
+ const geometry = observeSegmentEntries(text, fit.advances , letterSpacing, width, fitBasis,
540
+ source => measureWithLetterSpacing(source, letterSpacing, emojiCorrection, fontMeasurement))
541
+ // Replacing this last observation leaves prepared copies intact.
542
+ fit.entryGeometry = { letterSpacing, emojiCorrection, geometry }
543
+ return geometry
544
+ }
545
+
546
+ // A text segment's width as measured: alone, or together with the following space less
547
+ // that space, plus the item's kerning with a space that follows zero-width breaks. Apart
548
+ // from the loop below, whose code JavaScriptCore otherwise never optimizes fully on CJK
549
+ // text (RESEARCH.md, Keeping Work Bounded).
550
+ function getTextSegmentWidth(text , textMetrics , measuredWithSpace , followingSpaceKerning ) {
551
+ return getCorrectedSegmentWidth(text, textMetrics, fontMeasurement, emojiCorrection) - (measuredWithSpace ? spaceWidth : 0) + followingSpaceKerning
552
+ }
553
+
554
+ // Each segment is stored at its index: `at` in the text's or the paragraph's three lists,
555
+ // `mi - from` in the lists of the measured segments alone. Stored and not pushed, for V8:
556
+ // once it has deoptimized at a push onto a paragraph's list, it compiles that push as a call
557
+ // from then on, for texts too. With stores Chrome 154 prepares plain text again 4-9% faster
558
+ // than with pushes on a page of mostly plain text whose first rich paragraphs are short, and
559
+ // they cost about 1% of preparing a text again in Safari 27, 1.9% at most in one run
560
+ // (RESEARCH.md, Keeping Work Bounded, JavaScript Engines, under A list made where it is
561
+ // filled; Decisions Log, 2026-10-09, a paragraph's lists).
562
+ for (let mi = from, at = base; mi < to; mi++, at++) {
563
+ const text = texts[mi]
564
+ const segment = flags[mi]
565
+ const kind = (segment & KIND_BITS)
566
+ let width = 0
567
+ // Graphemes that take letter spacing after them.
568
+ let spacingGraphemeCount = 0
569
+ let fitAdvances = null
570
+ let entry = null
571
+ let prohibitions = null
572
+ switch (kind) {
573
+ case TEXT: {
574
+ // A control the engine hides takes no advance, only letter spacing.
575
+ if (engineProfile.hidesControlCharacters && controlCharacterRe.test(text)) {
576
+ spacingGraphemeCount = 1
577
+ break
578
+ }
579
+ // Such a run of marks adds its context with the marks, minus the context, and
580
+ // takes no letter spacing of its own. Only a segment no break comes before can be
581
+ // one, and the test is here so that the others make no call: with it Chrome 154
582
+ // prepares CJK text again 2.6% faster than with a call for every text segment, which
583
+ // V8 didn't inline (RESEARCH.md, Keeping Work Bounded, JavaScript Engines, under V8's
584
+ // inlining budgets and the mark context).
585
+ const markContext = (segment & UNBROKEN) === 0 ? null : getMarkContext(mi, text)
586
+ if (markContext !== null) {
587
+ width = engineProfile.shapesMarksAcrossSoftHyphen && texts[mi - 1] === '\u00AD' && nonspacingMarkRunRe.test(text)
588
+ ? 0
589
+ : getTextWidth(markContext + text, fontMeasurement, emojiCorrection) - getTextWidth(markContext, fontMeasurement, emojiCorrection)
590
+ break
591
+ }
592
+ // With an empty following-space tail, the text is measured together with the
593
+ // space; with a zero-width tail, the item's kerning is added.
594
+ const followingSpaceTail = getFollowingSpaceTail(mi, text)
595
+ const measuredWithSpace = followingSpaceTail === ''
596
+ const textMetrics = getTextMetrics(text, followingSpaceTail)
597
+ previousJoinablePiece = text
598
+ previousJoinableMetrics = textMetrics
599
+ let followingSpaceKerning = followingSpaceTail === null || measuredWithSpace ? 0 : getTailKerning(text + followingSpaceTail)
600
+ if (fontSpaceKerning !== null && textMetrics.spaceKerning !== noSpaceKerning) {
601
+ const spaceBefore = mi > from && isSpaceKind(flags[mi - 1] & KIND_BITS)
602
+ const spaceAfter = mi + 1 < to && isSpaceKind(flags[mi + 1] & KIND_BITS)
603
+ if (spaceBefore || spaceAfter) {
604
+ const kerning = textMetrics.spaceKerning ?? getSpaceKerning(text, textMetrics, fontMeasurement, fontSpaceKerning)
605
+ if (spaceAfter) {
606
+ // Premise: a kerning that tightens the word and the space after it goes on the
607
+ // space, which hangs, so a line that ends there has the word without it, as in
608
+ // Blink under a text-align other than start and for text whose element has a
609
+ // decoration or a background (NeedsAccurateEndPosition, line_breaker.cc:255-268).
610
+ // One that widens stays on the word, whose end Blink finds in the run shaped
611
+ // whole. In other text a line's last word is wider here than there by the
612
+ // kerning, never narrower (RESEARCH.md, Kerning At Line Edges).
613
+ followingSpaceKerning = Math.max(kerning.after, 0)
614
+ spaceShare = Math.min(kerning.after, 0)
615
+ }
616
+ // The space takes its kerning with this word, and takes it along where it hangs: a
617
+ // line that breaks between the two is shaped again without it
618
+ // (shaping_line_breaker.cc:307-324). Preserved spaces that start the text or follow
619
+ // a forced break are a Blink item of their own (inline_items_builder.cc:988-1034),
620
+ // which kerns with nothing.
621
+ if (spaceBefore && kerning.before !== 0 &&
622
+ !((flags[mi - 1] & KIND_BITS) === PRESERVED_SPACE && (mi === from + 1 || (flags[mi - 2] & KIND_BITS) === HARD_BREAK)) &&
623
+ spaceSharesScriptRun(scriptRuns, own, starts[mi] - ownStart, starts[mi] - ownStart + text.length)) {
624
+ widths[at - 1] = widths[at - 1] + kerning.before
625
+ }
626
+ }
627
+ }
628
+ width = getTextSegmentWidth(text, textMetrics, measuredWithSpace, followingSpaceKerning)
629
+ // The walkers put a gap after every grapheme of a spaced segment, so a grapheme
630
+ // the engine gives none takes one back from its advance, here and in the advances
631
+ // a break inside the segment falls by.
632
+ let unspaced = null
633
+ if (hasLetterSpacing) {
634
+ spacingGraphemeCount = countRenderedSpacingGraphemes(text, kind, engineProfile.graphemeTable)
635
+ if (cursiveSpacing) {
636
+ unspaced = getUnspacedGraphemes(own, starts[mi] - ownStart, starts[mi] - ownStart + text.length, engineProfile.graphemeTable, engineProfile.unspacedCursive === 'run' ? scriptRuns : null)
637
+ if (unspaced !== null) width -= unspaced.length * letterSpacing
638
+ }
639
+ }
640
+ // Under break-word, Blink retries an overflowing line with a break allowed between
641
+ // any two graphemes (line_breaker.cc), WebKit searches the word's grapheme prefixes
642
+ // (TextUtil::breakWord) and Gecko may wrap before any cluster (gfxTextRun.cpp:1069-1072),
643
+ // so every text segment takes emergency grapheme breaks, unless it is one Gecko cluster
644
+ // or an atomic item's.
645
+ if (!overflowBreaks || (segment & ONE_CLUSTER) !== 0 || text.length === 1) break
646
+ const fitMode = letterSpacing !== 0 ? 'segment-prefixes'
647
+ : numericRunRe.test(text) ? 'pair-context'
648
+ : textMetrics.width < engineProfile.prefixFitMinWidth ? 'sum-graphemes'
649
+ : engineProfile.cutWordFit
650
+ const fit = getSegmentFit(text, textMetrics, fontMeasurement, emojiCorrection, fitMode,
651
+ measuredWithSpace ? spaceWidth : null, engineProfile.keepsLineStartPunctuation)
652
+ fitAdvances = fit.advances
653
+ if (fitAdvances === null) break
654
+ // A difference from the advance, so it holds whatever the advances take below.
655
+ if (fit.lineStartExtras !== null) (breakableLineStartExtras ??= new Array (to - from).fill(null))[mi - from] = fit.lineStartExtras
656
+ // The cached advances are shared by every occurrence of this text; only
657
+ // the final grapheme touches the following space.
658
+ if (followingSpaceKerning !== 0) {
659
+ fitAdvances = fitAdvances.slice()
660
+ fitAdvances[fitAdvances.length - 1] = fitAdvances[fitAdvances.length - 1] + followingSpaceKerning
661
+ }
662
+ // Such a segment takes no entry geometry, whose fresh widths Canvas would space
663
+ // by its own rule (ENGINE_FOLLOWUPS.md, Letter spacing).
664
+ if (unspaced !== null) {
665
+ fitAdvances = fitAdvances.slice()
666
+ for (let k = 0; k < unspaced.length; k++) fitAdvances[unspaced[k] ] = fitAdvances[unspaced[k] ] - letterSpacing
667
+ } else if (entryFitBasis !== 'disabled') {
668
+ entry = getEntryGeometry(text, fit, addInternalLetterSpacing(width, spacingGraphemeCount, letterSpacing), entryFitBasis)
669
+ }
670
+ if (keepsLineStartPunctuation) prohibitions = fit.lineStartProhibitions
671
+ break
672
+ }
673
+ case SPACE:
674
+ case PRESERVED_SPACE:
675
+ case ZERO_WIDTH_BREAK:
676
+ width = getTextWidth(text, fontMeasurement, emojiCorrection) + spaceShare
677
+ spaceShare = 0
678
+ if (hasLetterSpacing) spacingGraphemeCount = countRenderedSpacingGraphemes(text, kind, engineProfile.graphemeTable)
679
+ break
680
+ case TAB:
681
+ if (engineProfile.letterSpaceTabs) spacingGraphemeCount = 1
682
+ if (minimumTabAdvance === 0) minimumTabAdvance = getTextWidth(engineProfile.tabMinimumCharacter, fontMeasurement, emojiCorrection) / 2
683
+ break
684
+ case CONTROL: {
685
+ width = getTextWidth(text, fontMeasurement, emojiCorrection)
686
+ // NEL shares a WebKit text item with the text before it and with
687
+ // combining marks after it, and the complex text path spaces it. Complex
688
+ // text shares the item only when its direction matches the page's, which
689
+ // preparation cannot see, so NEL next to complex text keeps its spacing.
690
+ const nextText = mi + 1 < to ? texts[mi + 1] : ''
691
+ if (hasLetterSpacing && (
692
+ (mi > from && (flags[mi - 1] & KIND_BITS) === TEXT && needsComplexTextPath(texts[mi - 1] )) ||
693
+ (leadingCombiningMarkRe().test(nextText) && needsComplexTextPath(nextText))
694
+ )) spacingGraphemeCount = 1
695
+ break
696
+ }
697
+ case SOFT_HYPHEN:
698
+ case ZERO_WIDTH_GLUE:
699
+ case HARD_BREAK:
700
+ break
701
+ }
702
+ if (kind !== TEXT && kind !== SPACE && kind !== ZERO_WIDTH_BREAK) simpleKinds = false
703
+ if (kind !== TEXT && kind !== SOFT_HYPHEN) previousJoinablePiece = null
704
+ segmentFlags[at] = (segment & ~ONE_CLUSTER) | (hasLetterSpacing && spacingGraphemeCount > 0 ? SPACED : 0)
705
+ widths[at] = addInternalLetterSpacing(width, spacingGraphemeCount, letterSpacing)
706
+ breakableFitAdvances[at] = fitAdvances
707
+ if (entry !== null && entryGeometry === null) entryGeometry = Array.from({ length: mi - from }, () => null)
708
+ if (entryGeometry !== null) entryGeometry[mi - from] = entry
709
+ if (prohibitions !== null && lineStartProhibitions === null) lineStartProhibitions = Array.from({ length: mi - from }, () => null)
710
+ if (lineStartProhibitions !== null) lineStartProhibitions[mi - from] = prohibitions
711
+ if (segments !== null) segments[mi - from] = text
712
+ // Contexts for every segment of soft hyphens, whatever its kind here, one that is glue
713
+ // too, where the scan gives no break after it: a handle with contexts is one whose lines
714
+ // return from an unfit hyphen, measured below.
715
+ if (kind !== TEXT && text.charCodeAt(0) === 0xAD) {
716
+ discretionaryHyphenContexts ??= zeros(mi - from)
717
+ discretionaryHyphenContexts[mi - from] = returnFitsEachSideAlone ? 0 : getJoinedNarrowing(mi, previousJoinablePiece, previousJoinableMetrics)
718
+ } else if (discretionaryHyphenContexts !== null) {
719
+ discretionaryHyphenContexts[mi - from] = 0
720
+ }
721
+ }
722
+
723
+ // The hyphen a chosen soft hyphen paints, which only a text that holds one asks for, with
724
+ // the gap before it, plus the hyphen's own spacing where the engine letter-spaces it.
725
+ const hyphenText = paragraphHyphen ?? (discretionaryHyphenContexts === null ? '-' : engineProfile.hyphenFromPrimaryFont ? getHyphenText(fontMeasurement) : '\u2010')
726
+ const discretionaryHyphenWidth = getTextWidth(hyphenText, fontMeasurement, emojiCorrection) +
727
+ (letterSpacing === 0 ? 0 : letterSpacing * (engineProfile.letterSpaceDiscretionaryHyphen ? 2 : 1))
728
+
729
+ // A segment's width is its width between the text before and after it; one that starts
730
+ // a line takes back the halt Blink gives its first character there.
731
+ let hanKerning = { widthTrims: null, lineStartExtras: null, lineEndTrims: null, overflowLineEndTrims: null }
732
+ if (engineProfile.hanKerning && textMayHanKern(own)) {
733
+ hanKerning = getHanKerningTrims(fontMeasurement, analysis, from, to)
734
+ const trims = hanKerning.widthTrims
735
+ if (trims !== null) for (let i = 0; i < trims.length; i++) widths[base + i] = widths[base + i] - trims[i]
736
+ }
737
+ let lineEndTrims = hanKerning.lineEndTrims
738
+ if (engineProfile.hangsIdeographicSpace && own.includes('\u3000')) {
739
+ lineEndTrims = addIdeographicSpaceHangs(lineEndTrims, analysis, fontMeasurement, letterSpacing, discretionaryHyphenWidth, from, to)
740
+ }
741
+ const prepared = {
742
+ widths,
743
+ segmentFlags,
744
+ simpleLineWalkFastPath: simpleKinds && !analysis.hasUnbroken,
745
+ simpleLineCountFastPath: simpleKinds,
746
+ breakableFitAdvances,
747
+ entryGeometry,
748
+ letterSpacing,
749
+ discretionaryHyphenWidth,
750
+ discretionaryHyphenContexts,
751
+ lineStartProhibitions,
752
+ breakableLineStartExtras,
753
+ lineStartExtras: hanKerning.lineStartExtras,
754
+ lineEndTrims,
755
+ overflowLineEndTrims: hanKerning.overflowLineEndTrims,
756
+ tabStopAdvance,
757
+ minimumTabAdvance,
758
+ }
759
+ if (segments !== null) prepared.segments = segments
760
+ return prepared
761
+ }
762
+
763
+ // Blink (Chrome 153) hangs a run of U+3000 that ends a line, as it hangs spaces: ShapingLineBreaker
764
+ // counts U+3000 as a breakable space (IsBreakableSpace, shaping_line_breaker.cc:38-41, with
765
+ // Character::IsOtherSpaceSeparator, character.h:156-158), so a line whose width runs out on
766
+ // the run ends after it and fits without it (shaping_line_breaker.cc:384-452), and the line
767
+ // breaker keeps the run as trailing space (HandleTrailingSpaces, line_breaker.cc:2447-2514),
768
+ // in normal and pre-wrap white space alike. A text segment that ends in such a run, where a
769
+ // line can end after it or a collapsible space follows, which hangs with it, drops the run
770
+ // and its letter spacing at a line end. The line then ends where the run starts
771
+ // (shaping_line_breaker.cc:490-493), so after a soft hyphen it ends with a hyphen
772
+ // (SetBreakOffset, shaping_line_breaker.cc:212-216), which has to fit. Gecko (Firefox 156)
773
+ // marks U+3000 as a space glyph, like SPACE (SetupClusterBoundaries, gfxFont.cpp:749-750), and
774
+ // BreakAndMeasureText fits a line without its trailing space glyphs (gfxTextRun.cpp:1152-1160,
775
+ // 1175), so Firefox hangs the run too. `trims` holds segments [from, to) of the analysis, a
776
+ // rich-inline item's in its paragraph's, from index 0. A run that ends an item hangs whatever
777
+ // the next item starts with, as the engines hang it wherever a line ends; a text's run before
778
+ // a ZWSP, a soft hyphen or a character no line starts with doesn't yet (ENGINE_FOLLOWUPS.md,
779
+ // Line edges).
780
+ function addIdeographicSpaceHangs(
781
+ trims ,
782
+ analysis ,
783
+ measurement ,
784
+ letterSpacing ,
785
+ hyphenWidth ,
786
+ from ,
787
+ to ,
788
+ ) {
789
+ const { normalized, starts, flags } = analysis
790
+ for (let i = from; i < to; i++) {
791
+ const end = i + 1 < flags.length ? starts[i + 1] : normalized.length
792
+ if ((flags[i] & KIND_BITS) !== TEXT || normalized.charCodeAt(end - 1) !== 0x3000) continue
793
+ if (i + 1 < to) {
794
+ const next = flags[i + 1] & KIND_BITS
795
+ if (next !== HARD_BREAK && next !== SPACE && !(next === TEXT && (flags[i + 1] & UNBROKEN) === 0)) continue
796
+ }
797
+ let start = end - 1
798
+ while (start > starts[i] && normalized.charCodeAt(start - 1) === 0x3000) start--
799
+ const run = normalized.slice(start, end)
800
+ const afterSoftHyphen = i > 0 && start === starts[i] && normalized.charCodeAt(start - 1) === 0xAD
801
+ const hang = getSegmentMetrics(run, measurement).width + run.length * letterSpacing - (afterSoftHyphen ? hyphenWidth : 0)
802
+ if (hang <= 0) continue
803
+ trims ??= zeros(to - from)
804
+ trims[i - from] = trims[i - from] + hang
805
+ }
806
+ return trims
807
+ }