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.
- package/LICENSE +5 -2
- package/README.md +245 -54
- package/dist/agent/vendor/hyphen-en-us/LICENSE +14 -0
- package/dist/agent/vendor/hyphen-en-us/SOURCE.json +33 -0
- package/dist/agent/vendor/hyphen-en-us/hyph-en-us.hyp.txt +14 -0
- package/dist/agent/vendor/hyphen-en-us/hyph-en-us.pat.txt +4938 -0
- package/dist/agent/vendor/hyphen-en-us/patterns.js +25 -0
- package/dist/agent/vendor/pretext/LICENSE +208 -0
- package/dist/agent/vendor/pretext/NOTICE +26 -13
- package/dist/agent/vendor/pretext/SOURCE.json +213 -45
- package/dist/agent/vendor/pretext/analysis.js +675 -1224
- package/dist/agent/vendor/pretext/entry-geometry.js +183 -0
- package/dist/agent/vendor/pretext/gecko-line-breaks.js +688 -0
- package/dist/agent/vendor/pretext/generated/engine-break-data.js +57 -0
- package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +80 -0
- package/dist/agent/vendor/pretext/graphemes.js +98 -0
- package/dist/agent/vendor/pretext/han-kerning.js +253 -0
- package/dist/agent/vendor/pretext/layout.js +352 -290
- package/dist/agent/vendor/pretext/line-break.js +1154 -588
- package/dist/agent/vendor/pretext/line-breaks.js +892 -0
- package/dist/agent/vendor/pretext/line-text.js +81 -39
- package/dist/agent/vendor/pretext/measurement.js +1105 -176
- package/dist/agent/vendor/pretext/prepare.js +807 -0
- package/dist/agent/vendor/pretext/rich-inline.js +1242 -273
- package/dist/agent/will.mjs +2 -0
- package/dist/images/gif.mjs +47 -0
- package/dist/images/playback.mjs +101 -0
- package/dist/kit/assets.mjs +5 -1
- package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
- package/dist/kit/conformance/04-wrap-box.md +1 -1
- package/dist/kit/conformance/05-behind.md +1 -1
- package/dist/kit/conformance/06-front.md +1 -1
- package/dist/kit/conformance/07-rotate-raster.md +1 -1
- package/dist/kit/conformance/08-drawing-rotation.md +1 -1
- package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
- package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
- package/dist/kit/conformance/10-both-sides.md +1 -1
- package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
- package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
- package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
- package/dist/kit/conformance/README.md +19 -28
- package/dist/kit/conformance/run.mjs +13 -11
- package/dist/kit/export-fonts.mjs +26 -11
- package/dist/kit/index.mjs +1 -0
- package/dist/kit/ledger/carried.mjs +17 -10
- package/dist/kit/ledger/data.mjs +73 -13
- package/dist/kit/ledger/format.mjs +176 -54
- package/dist/kit/ledger/hash.mjs +91 -18
- package/dist/kit/ledger/history.mjs +156 -116
- package/dist/kit/ledger/inverse-source.mjs +158 -19
- package/dist/kit/ledger/journal-records.mjs +292 -67
- package/dist/kit/ledger/merge.mjs +4 -3
- package/dist/kit/model.mjs +1 -1
- package/dist/kit/pdf-glyphs.mjs +4 -4
- package/dist/kit/pdf-vectors.mjs +4 -1
- package/dist/kit/rapier-ledger.mjs +2 -2
- package/dist/kit/render-ink.mjs +1 -1
- package/dist/kit/render-markdown.mjs +400 -148
- package/dist/kit/render-print.mjs +131 -55
- package/dist/kit/render-sanitize.mjs +30 -7
- package/dist/kit/render-work.mjs +99 -20
- package/dist/kit/render.mjs +133 -140
- package/dist/kit/typography.mjs +2 -0
- package/dist/layout/hyphenation.mjs +127 -0
- package/dist/layout/line-plan.mjs +88 -96
- package/dist/layout/model.mjs +71 -88
- package/dist/layout/paragraph-fit.mjs +191 -0
- package/dist/layout/preparation.mjs +373 -0
- package/dist/layout/prepared-paragraph.mjs +223 -0
- package/dist/layout/typography.mjs +345 -111
- package/dist/packages/rapier-diff/index.mjs +359 -224
- package/dist/spec/document-fonts.mjs +14 -5
- package/dist/spec/document-settings.mjs +11 -164
- package/dist/spec/html-blocks.mjs +618 -79
- package/dist/spec/md-assets.mjs +178 -36
- package/dist/spec/md-layout.mjs +13 -2
- package/dist/spec/md-marks.mjs +220 -127
- package/dist/spec/md-source.mjs +157 -0
- package/package.json +2 -1
- package/style.css +15 -50
- package/dist/agent/vendor/pretext/bidi.js +0 -151
- 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
|
+
}
|