expo-pretext 0.2.0
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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/android/src/main/java/expo/modules/pretext/ExpoPretextModule.kt +354 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoPretext.podspec +20 -0
- package/ios/ExpoPretext.swift +444 -0
- package/package.json +59 -0
- package/src/ExpoPretext.ts +70 -0
- package/src/__tests__/cache.test.ts +71 -0
- package/src/__tests__/font-utils.test.ts +69 -0
- package/src/__tests__/layout.test.ts +300 -0
- package/src/__tests__/obstacle-layout.test.ts +127 -0
- package/src/__tests__/setup-mocks.ts +14 -0
- package/src/analysis.ts +1208 -0
- package/src/bidi.ts +175 -0
- package/src/build.ts +503 -0
- package/src/cache.ts +59 -0
- package/src/engine-profile.ts +38 -0
- package/src/font-utils.ts +50 -0
- package/src/generated/bidi-data.ts +998 -0
- package/src/hooks/useFlashListHeights.ts +88 -0
- package/src/hooks/usePreparedText.ts +16 -0
- package/src/hooks/useTextHeight.ts +45 -0
- package/src/index.ts +56 -0
- package/src/layout.ts +353 -0
- package/src/line-break.ts +1113 -0
- package/src/obstacle-layout.ts +193 -0
- package/src/prepare.ts +246 -0
- package/src/rich-inline.ts +647 -0
- package/src/streaming.ts +61 -0
- package/src/types.ts +104 -0
package/src/bidi.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Simplified bidi metadata helper for the rich prepareWithSegments() path,
|
|
2
|
+
// forked from pdf.js via Sebastian's text-layout. It classifies characters
|
|
3
|
+
// into bidi types, computes embedding levels, and maps them onto prepared
|
|
4
|
+
// segments for custom rendering. The line-breaking engine does not consume
|
|
5
|
+
// these levels.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
latin1BidiTypes,
|
|
9
|
+
nonLatin1BidiRanges,
|
|
10
|
+
type GeneratedBidiType as BidiType,
|
|
11
|
+
} from './generated/bidi-data'
|
|
12
|
+
|
|
13
|
+
function classifyCodePoint(codePoint: number): BidiType {
|
|
14
|
+
if (codePoint <= 0x00FF) return latin1BidiTypes[codePoint]!
|
|
15
|
+
|
|
16
|
+
let lo = 0
|
|
17
|
+
let hi = nonLatin1BidiRanges.length - 1
|
|
18
|
+
while (lo <= hi) {
|
|
19
|
+
const mid = (lo + hi) >> 1
|
|
20
|
+
const range = nonLatin1BidiRanges[mid]!
|
|
21
|
+
if (codePoint < range[0]) {
|
|
22
|
+
hi = mid - 1
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
if (codePoint > range[1]) {
|
|
26
|
+
lo = mid + 1
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
return range[2]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return 'L'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function computeBidiLevels(str: string): Int8Array | null {
|
|
36
|
+
const len = str.length
|
|
37
|
+
if (len === 0) return null
|
|
38
|
+
|
|
39
|
+
// eslint-disable-next-line unicorn/no-new-array
|
|
40
|
+
const types: BidiType[] = new Array(len)
|
|
41
|
+
let sawBidi = false
|
|
42
|
+
|
|
43
|
+
// Keep the resolved bidi classes aligned to UTF-16 code-unit offsets,
|
|
44
|
+
// because the rich prepared segments index back into the normalized string
|
|
45
|
+
// with JavaScript string offsets.
|
|
46
|
+
for (let i = 0; i < len;) {
|
|
47
|
+
const first = str.charCodeAt(i)
|
|
48
|
+
let codePoint = first
|
|
49
|
+
let codeUnitLength = 1
|
|
50
|
+
|
|
51
|
+
if (first >= 0xD800 && first <= 0xDBFF && i + 1 < len) {
|
|
52
|
+
const second = str.charCodeAt(i + 1)
|
|
53
|
+
if (second >= 0xDC00 && second <= 0xDFFF) {
|
|
54
|
+
codePoint = ((first - 0xD800) << 10) + (second - 0xDC00) + 0x10000
|
|
55
|
+
codeUnitLength = 2
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const t = classifyCodePoint(codePoint)
|
|
60
|
+
if (t === 'R' || t === 'AL' || t === 'AN') sawBidi = true
|
|
61
|
+
for (let j = 0; j < codeUnitLength; j++) {
|
|
62
|
+
types[i + j] = t
|
|
63
|
+
}
|
|
64
|
+
i += codeUnitLength
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!sawBidi) return null
|
|
68
|
+
|
|
69
|
+
// Use the first strong character to pick the paragraph base direction.
|
|
70
|
+
// Rich-path bidi metadata is only an approximation, but this keeps mixed
|
|
71
|
+
// LTR/RTL text aligned with the common UBA paragraph rule.
|
|
72
|
+
let startLevel = 0
|
|
73
|
+
for (let i = 0; i < len; i++) {
|
|
74
|
+
const t = types[i]!
|
|
75
|
+
if (t === 'L') {
|
|
76
|
+
startLevel = 0
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
if (t === 'R' || t === 'AL') {
|
|
80
|
+
startLevel = 1
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const levels = new Int8Array(len)
|
|
85
|
+
for (let i = 0; i < len; i++) levels[i] = startLevel
|
|
86
|
+
|
|
87
|
+
const e: BidiType = (startLevel & 1) ? 'R' : 'L'
|
|
88
|
+
const sor = e
|
|
89
|
+
|
|
90
|
+
// W1-W7
|
|
91
|
+
let lastType: BidiType = sor
|
|
92
|
+
for (let i = 0; i < len; i++) {
|
|
93
|
+
if (types[i] === 'NSM') types[i] = lastType
|
|
94
|
+
else lastType = types[i]!
|
|
95
|
+
}
|
|
96
|
+
lastType = sor
|
|
97
|
+
for (let i = 0; i < len; i++) {
|
|
98
|
+
const t = types[i]!
|
|
99
|
+
if (t === 'EN') types[i] = lastType === 'AL' ? 'AN' : 'EN'
|
|
100
|
+
else if (t === 'R' || t === 'L' || t === 'AL') lastType = t
|
|
101
|
+
}
|
|
102
|
+
for (let i = 0; i < len; i++) {
|
|
103
|
+
if (types[i] === 'AL') types[i] = 'R'
|
|
104
|
+
}
|
|
105
|
+
for (let i = 1; i < len - 1; i++) {
|
|
106
|
+
if (types[i] === 'ES' && types[i - 1] === 'EN' && types[i + 1] === 'EN') {
|
|
107
|
+
types[i] = 'EN'
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
types[i] === 'CS' &&
|
|
111
|
+
(types[i - 1] === 'EN' || types[i - 1] === 'AN') &&
|
|
112
|
+
types[i + 1] === types[i - 1]
|
|
113
|
+
) {
|
|
114
|
+
types[i] = types[i - 1]!
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (let i = 0; i < len; i++) {
|
|
118
|
+
if (types[i] !== 'EN') continue
|
|
119
|
+
let j
|
|
120
|
+
for (j = i - 1; j >= 0 && types[j] === 'ET'; j--) types[j] = 'EN'
|
|
121
|
+
for (j = i + 1; j < len && types[j] === 'ET'; j++) types[j] = 'EN'
|
|
122
|
+
}
|
|
123
|
+
for (let i = 0; i < len; i++) {
|
|
124
|
+
const t = types[i]!
|
|
125
|
+
if (t === 'WS' || t === 'ES' || t === 'ET' || t === 'CS') types[i] = 'ON'
|
|
126
|
+
}
|
|
127
|
+
lastType = sor
|
|
128
|
+
for (let i = 0; i < len; i++) {
|
|
129
|
+
const t = types[i]!
|
|
130
|
+
if (t === 'EN') types[i] = lastType === 'L' ? 'L' : 'EN'
|
|
131
|
+
else if (t === 'R' || t === 'L') lastType = t
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// N1-N2
|
|
135
|
+
for (let i = 0; i < len; i++) {
|
|
136
|
+
if (types[i] !== 'ON') continue
|
|
137
|
+
let end = i + 1
|
|
138
|
+
while (end < len && types[end] === 'ON') end++
|
|
139
|
+
const before: BidiType = i > 0 ? types[i - 1]! : sor
|
|
140
|
+
const after: BidiType = end < len ? types[end]! : sor
|
|
141
|
+
const bDir: BidiType = before !== 'L' ? 'R' : 'L'
|
|
142
|
+
const aDir: BidiType = after !== 'L' ? 'R' : 'L'
|
|
143
|
+
if (bDir === aDir) {
|
|
144
|
+
for (let j = i; j < end; j++) types[j] = bDir
|
|
145
|
+
}
|
|
146
|
+
i = end - 1
|
|
147
|
+
}
|
|
148
|
+
for (let i = 0; i < len; i++) {
|
|
149
|
+
if (types[i] === 'ON') types[i] = e
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// I1-I2
|
|
153
|
+
for (let i = 0; i < len; i++) {
|
|
154
|
+
const t = types[i]!
|
|
155
|
+
if ((levels[i]! & 1) === 0) {
|
|
156
|
+
if (t === 'R') levels[i]!++
|
|
157
|
+
else if (t === 'AN' || t === 'EN') levels[i]! += 2
|
|
158
|
+
} else if (t === 'L' || t === 'AN' || t === 'EN') {
|
|
159
|
+
levels[i]!++
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return levels
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function computeSegmentLevels(normalized: string, segStarts: number[]): Int8Array | null {
|
|
167
|
+
const bidiLevels = computeBidiLevels(normalized)
|
|
168
|
+
if (bidiLevels === null) return null
|
|
169
|
+
|
|
170
|
+
const segLevels = new Int8Array(segStarts.length)
|
|
171
|
+
for (let i = 0; i < segStarts.length; i++) {
|
|
172
|
+
segLevels[i] = bidiLevels[segStarts[i]!]!
|
|
173
|
+
}
|
|
174
|
+
return segLevels
|
|
175
|
+
}
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// Build prepared text handles from pre-analyzed + pre-measured data.
|
|
2
|
+
// Extracted from layout.ts — contains the factory logic that constructs
|
|
3
|
+
// the internal PreparedText structures consumed by the line-breaking engine.
|
|
4
|
+
|
|
5
|
+
import { computeSegmentLevels } from './bidi'
|
|
6
|
+
import {
|
|
7
|
+
canContinueKeepAllTextRun,
|
|
8
|
+
endsWithClosingQuote,
|
|
9
|
+
isCJK,
|
|
10
|
+
isNumericRunSegment,
|
|
11
|
+
kinsokuEnd,
|
|
12
|
+
kinsokuStart,
|
|
13
|
+
leftStickyPunctuation,
|
|
14
|
+
type AnalysisChunk,
|
|
15
|
+
type SegmentBreakKind,
|
|
16
|
+
type TextAnalysis,
|
|
17
|
+
type WhiteSpaceMode,
|
|
18
|
+
type WordBreakMode,
|
|
19
|
+
} from './analysis'
|
|
20
|
+
import { getEngineProfile } from './engine-profile'
|
|
21
|
+
import type {
|
|
22
|
+
TextStyle,
|
|
23
|
+
PreparedText,
|
|
24
|
+
PreparedTextWithSegments as PublicPreparedTextWithSegments,
|
|
25
|
+
} from './types'
|
|
26
|
+
|
|
27
|
+
// --- Grapheme segmenter ---
|
|
28
|
+
// Intl.Segmenter may not be available in Hermes — use a fallback
|
|
29
|
+
// that splits on Unicode code points via spread operator.
|
|
30
|
+
|
|
31
|
+
export interface GraphemeSegmenterLike {
|
|
32
|
+
segment(text: string): Iterable<{ segment: string; index: number }>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let sharedGraphemeSegmenter: GraphemeSegmenterLike | null = null
|
|
36
|
+
|
|
37
|
+
export function getSharedGraphemeSegmenter(): GraphemeSegmenterLike {
|
|
38
|
+
if (sharedGraphemeSegmenter === null) {
|
|
39
|
+
if (typeof Intl !== 'undefined' && typeof (Intl as any).Segmenter === 'function') {
|
|
40
|
+
sharedGraphemeSegmenter = new (Intl as any).Segmenter(undefined, { granularity: 'grapheme' })
|
|
41
|
+
} else {
|
|
42
|
+
// Fallback: split on code points via spread operator.
|
|
43
|
+
// This handles most cases correctly (including astral plane),
|
|
44
|
+
// but may not perfectly handle complex grapheme clusters (ZWJ emoji).
|
|
45
|
+
sharedGraphemeSegmenter = {
|
|
46
|
+
segment(text: string): Iterable<{ segment: string; index: number }> {
|
|
47
|
+
const chars = [...text]
|
|
48
|
+
let idx = 0
|
|
49
|
+
return chars.map(ch => {
|
|
50
|
+
const entry = { segment: ch, index: idx }
|
|
51
|
+
idx += ch.length
|
|
52
|
+
return entry
|
|
53
|
+
})
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return sharedGraphemeSegmenter
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function clearGraphemeSegmenter(): void {
|
|
62
|
+
sharedGraphemeSegmenter = null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- Internal types ---
|
|
66
|
+
|
|
67
|
+
// The core parallel-array structure that the line-breaking engine operates on.
|
|
68
|
+
// Identical to Pretext's internal representation.
|
|
69
|
+
export type PreparedCore = {
|
|
70
|
+
widths: number[]
|
|
71
|
+
lineEndFitAdvances: number[]
|
|
72
|
+
lineEndPaintAdvances: number[]
|
|
73
|
+
kinds: SegmentBreakKind[]
|
|
74
|
+
simpleLineWalkFastPath: boolean
|
|
75
|
+
segLevels: Int8Array | null
|
|
76
|
+
breakableWidths: (number[] | null)[]
|
|
77
|
+
breakablePrefixWidths: (number[] | null)[]
|
|
78
|
+
discretionaryHyphenWidth: number
|
|
79
|
+
tabStopAdvance: number
|
|
80
|
+
chunks: PreparedLineChunk[]
|
|
81
|
+
style: TextStyle | null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type InternalPreparedText = PreparedText & PreparedCore
|
|
85
|
+
|
|
86
|
+
export type InternalPreparedTextWithSegments = InternalPreparedText & {
|
|
87
|
+
segments: string[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type PreparedLineChunk = {
|
|
91
|
+
startSegmentIndex: number
|
|
92
|
+
endSegmentIndex: number
|
|
93
|
+
consumedEndSegmentIndex: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type PrepareOptions = {
|
|
97
|
+
whiteSpace?: WhiteSpaceMode
|
|
98
|
+
wordBreak?: WordBreakMode
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Internal helpers ---
|
|
102
|
+
|
|
103
|
+
type MeasuredTextUnit = {
|
|
104
|
+
text: string
|
|
105
|
+
start: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function createEmptyPrepared(
|
|
109
|
+
includeSegments: boolean,
|
|
110
|
+
style: TextStyle | null,
|
|
111
|
+
): InternalPreparedText | InternalPreparedTextWithSegments {
|
|
112
|
+
const base: PreparedCore = {
|
|
113
|
+
widths: [],
|
|
114
|
+
lineEndFitAdvances: [],
|
|
115
|
+
lineEndPaintAdvances: [],
|
|
116
|
+
kinds: [],
|
|
117
|
+
simpleLineWalkFastPath: true,
|
|
118
|
+
segLevels: null,
|
|
119
|
+
breakableWidths: [],
|
|
120
|
+
breakablePrefixWidths: [],
|
|
121
|
+
discretionaryHyphenWidth: 0,
|
|
122
|
+
tabStopAdvance: 0,
|
|
123
|
+
chunks: [],
|
|
124
|
+
style,
|
|
125
|
+
}
|
|
126
|
+
if (includeSegments) {
|
|
127
|
+
return { ...base, segments: [] } as unknown as InternalPreparedTextWithSegments
|
|
128
|
+
}
|
|
129
|
+
return base as unknown as InternalPreparedText
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildBaseCjkUnits(
|
|
133
|
+
segText: string,
|
|
134
|
+
engineProfile: ReturnType<typeof getEngineProfile>,
|
|
135
|
+
): MeasuredTextUnit[] {
|
|
136
|
+
const units: MeasuredTextUnit[] = []
|
|
137
|
+
let unitText = ''
|
|
138
|
+
let unitStart = 0
|
|
139
|
+
|
|
140
|
+
function pushUnit(): void {
|
|
141
|
+
if (unitText.length === 0) return
|
|
142
|
+
units.push({ text: unitText, start: unitStart })
|
|
143
|
+
unitText = ''
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const gs of getSharedGraphemeSegmenter().segment(segText)) {
|
|
147
|
+
const grapheme = gs.segment
|
|
148
|
+
|
|
149
|
+
if (unitText.length === 0) {
|
|
150
|
+
unitText = grapheme
|
|
151
|
+
unitStart = gs.index
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (
|
|
156
|
+
kinsokuEnd.has(unitText) ||
|
|
157
|
+
kinsokuStart.has(grapheme) ||
|
|
158
|
+
leftStickyPunctuation.has(grapheme) ||
|
|
159
|
+
(engineProfile.carryCJKAfterClosingQuote &&
|
|
160
|
+
isCJK(grapheme) &&
|
|
161
|
+
endsWithClosingQuote(unitText))
|
|
162
|
+
) {
|
|
163
|
+
unitText += grapheme
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!isCJK(unitText) && !isCJK(grapheme)) {
|
|
168
|
+
unitText += grapheme
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pushUnit()
|
|
173
|
+
unitText = grapheme
|
|
174
|
+
unitStart = gs.index
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
pushUnit()
|
|
178
|
+
return units
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function mergeKeepAllTextUnits(units: MeasuredTextUnit[]): MeasuredTextUnit[] {
|
|
182
|
+
if (units.length <= 1) return units
|
|
183
|
+
|
|
184
|
+
const merged: MeasuredTextUnit[] = [{ ...units[0]! }]
|
|
185
|
+
for (let i = 1; i < units.length; i++) {
|
|
186
|
+
const next = units[i]!
|
|
187
|
+
const previous = merged[merged.length - 1]!
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
canContinueKeepAllTextRun(previous.text) &&
|
|
191
|
+
isCJK(previous.text)
|
|
192
|
+
) {
|
|
193
|
+
previous.text += next.text
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
merged.push({ ...next })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return merged
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Look up width of a sub-segment from the pre-measured width map.
|
|
204
|
+
function lookupWidth(
|
|
205
|
+
text: string,
|
|
206
|
+
widthMap: Map<string, number>,
|
|
207
|
+
parentText: string,
|
|
208
|
+
parentWidth: number,
|
|
209
|
+
): number {
|
|
210
|
+
const cached = widthMap.get(text)
|
|
211
|
+
if (cached !== undefined) return cached
|
|
212
|
+
|
|
213
|
+
if (parentText.length > 0 && parentWidth > 0) {
|
|
214
|
+
return (text.length / parentText.length) * parentWidth
|
|
215
|
+
}
|
|
216
|
+
return 0
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Get grapheme widths for a text segment by splitting it and looking up
|
|
220
|
+
// individual grapheme widths from the width map, falling back to equal
|
|
221
|
+
// distribution from the total width.
|
|
222
|
+
function getGraphemeWidthsFromMap(
|
|
223
|
+
text: string,
|
|
224
|
+
totalWidth: number,
|
|
225
|
+
widthMap: Map<string, number>,
|
|
226
|
+
): number[] {
|
|
227
|
+
const graphemeSegmenter = getSharedGraphemeSegmenter()
|
|
228
|
+
const graphemes: string[] = []
|
|
229
|
+
for (const gs of graphemeSegmenter.segment(text)) {
|
|
230
|
+
graphemes.push(gs.segment)
|
|
231
|
+
}
|
|
232
|
+
if (graphemes.length <= 1) return [totalWidth]
|
|
233
|
+
|
|
234
|
+
const widths: number[] = []
|
|
235
|
+
let knownTotal = 0
|
|
236
|
+
let unknownCount = 0
|
|
237
|
+
for (const g of graphemes) {
|
|
238
|
+
const w = widthMap.get(g)
|
|
239
|
+
if (w !== undefined) {
|
|
240
|
+
widths.push(w)
|
|
241
|
+
knownTotal += w
|
|
242
|
+
} else {
|
|
243
|
+
widths.push(-1)
|
|
244
|
+
unknownCount++
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (unknownCount > 0) {
|
|
249
|
+
const remaining = Math.max(0, totalWidth - knownTotal)
|
|
250
|
+
const perUnknown = remaining / unknownCount
|
|
251
|
+
for (let i = 0; i < widths.length; i++) {
|
|
252
|
+
if (widths[i]! < 0) widths[i] = perUnknown
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return widths
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Build cumulative prefix widths from per-grapheme widths.
|
|
260
|
+
function buildPrefixWidths(graphemeWidths: number[]): number[] {
|
|
261
|
+
const prefixWidths: number[] = new Array(graphemeWidths.length)
|
|
262
|
+
let sum = 0
|
|
263
|
+
for (let i = 0; i < graphemeWidths.length; i++) {
|
|
264
|
+
sum += graphemeWidths[i]!
|
|
265
|
+
prefixWidths[i] = sum
|
|
266
|
+
}
|
|
267
|
+
return prefixWidths
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function mapAnalysisChunksToPreparedChunks(
|
|
271
|
+
chunks: AnalysisChunk[],
|
|
272
|
+
preparedStartByAnalysisIndex: number[],
|
|
273
|
+
preparedEndSegmentIndex: number,
|
|
274
|
+
): PreparedLineChunk[] {
|
|
275
|
+
const preparedChunks: PreparedLineChunk[] = []
|
|
276
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
277
|
+
const chunk = chunks[i]!
|
|
278
|
+
const startSegmentIndex =
|
|
279
|
+
chunk.startSegmentIndex < preparedStartByAnalysisIndex.length
|
|
280
|
+
? preparedStartByAnalysisIndex[chunk.startSegmentIndex]!
|
|
281
|
+
: preparedEndSegmentIndex
|
|
282
|
+
const endSegmentIndex =
|
|
283
|
+
chunk.endSegmentIndex < preparedStartByAnalysisIndex.length
|
|
284
|
+
? preparedStartByAnalysisIndex[chunk.endSegmentIndex]!
|
|
285
|
+
: preparedEndSegmentIndex
|
|
286
|
+
const consumedEndSegmentIndex =
|
|
287
|
+
chunk.consumedEndSegmentIndex < preparedStartByAnalysisIndex.length
|
|
288
|
+
? preparedStartByAnalysisIndex[chunk.consumedEndSegmentIndex]!
|
|
289
|
+
: preparedEndSegmentIndex
|
|
290
|
+
|
|
291
|
+
preparedChunks.push({
|
|
292
|
+
startSegmentIndex,
|
|
293
|
+
endSegmentIndex,
|
|
294
|
+
consumedEndSegmentIndex,
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
return preparedChunks
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// --- Build prepared handles from pre-measured data ---
|
|
301
|
+
|
|
302
|
+
function buildFromAnalysis(
|
|
303
|
+
analysis: TextAnalysis,
|
|
304
|
+
widthMap: Map<string, number>,
|
|
305
|
+
includeSegments: boolean,
|
|
306
|
+
wordBreak: WordBreakMode,
|
|
307
|
+
style: TextStyle | null,
|
|
308
|
+
): InternalPreparedText | InternalPreparedTextWithSegments {
|
|
309
|
+
const engineProfile = getEngineProfile()
|
|
310
|
+
|
|
311
|
+
const discretionaryHyphenWidth = widthMap.get('-') ?? (widthMap.get(' ') ?? 0) * 0.6
|
|
312
|
+
const spaceWidth = widthMap.get(' ') ?? 0
|
|
313
|
+
const tabStopAdvance = spaceWidth * 8
|
|
314
|
+
|
|
315
|
+
if (analysis.len === 0) return createEmptyPrepared(includeSegments, style)
|
|
316
|
+
|
|
317
|
+
const widths: number[] = []
|
|
318
|
+
const lineEndFitAdvances: number[] = []
|
|
319
|
+
const lineEndPaintAdvances: number[] = []
|
|
320
|
+
const kinds: SegmentBreakKind[] = []
|
|
321
|
+
let simpleLineWalkFastPath = analysis.chunks.length <= 1
|
|
322
|
+
const segStarts = includeSegments ? [] as number[] : null
|
|
323
|
+
const breakableWidths: (number[] | null)[] = []
|
|
324
|
+
const breakablePrefixWidths: (number[] | null)[] = []
|
|
325
|
+
const segments = includeSegments ? [] as string[] : null
|
|
326
|
+
const preparedStartByAnalysisIndex = Array.from<number>({ length: analysis.len })
|
|
327
|
+
|
|
328
|
+
function pushSegment(
|
|
329
|
+
text: string,
|
|
330
|
+
width: number,
|
|
331
|
+
lineEndFitAdvance: number,
|
|
332
|
+
lineEndPaintAdvance: number,
|
|
333
|
+
kind: SegmentBreakKind,
|
|
334
|
+
start: number,
|
|
335
|
+
breakable: number[] | null,
|
|
336
|
+
breakablePrefix: number[] | null,
|
|
337
|
+
): void {
|
|
338
|
+
if (kind !== 'text' && kind !== 'space' && kind !== 'zero-width-break') {
|
|
339
|
+
simpleLineWalkFastPath = false
|
|
340
|
+
}
|
|
341
|
+
widths.push(width)
|
|
342
|
+
lineEndFitAdvances.push(lineEndFitAdvance)
|
|
343
|
+
lineEndPaintAdvances.push(lineEndPaintAdvance)
|
|
344
|
+
kinds.push(kind)
|
|
345
|
+
segStarts?.push(start)
|
|
346
|
+
breakableWidths.push(breakable)
|
|
347
|
+
breakablePrefixWidths.push(breakablePrefix)
|
|
348
|
+
if (segments !== null) segments.push(text)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function pushTextSegment(
|
|
352
|
+
text: string,
|
|
353
|
+
kind: SegmentBreakKind,
|
|
354
|
+
start: number,
|
|
355
|
+
wordLike: boolean,
|
|
356
|
+
allowOverflowBreaks: boolean,
|
|
357
|
+
parentText: string,
|
|
358
|
+
parentWidth: number,
|
|
359
|
+
): void {
|
|
360
|
+
const width = lookupWidth(text, widthMap, parentText, parentWidth)
|
|
361
|
+
const lineEndFitAdvance =
|
|
362
|
+
kind === 'space' || kind === 'preserved-space' || kind === 'zero-width-break'
|
|
363
|
+
? 0
|
|
364
|
+
: width
|
|
365
|
+
const lineEndPaintAdvance =
|
|
366
|
+
kind === 'space' || kind === 'zero-width-break'
|
|
367
|
+
? 0
|
|
368
|
+
: width
|
|
369
|
+
|
|
370
|
+
if (allowOverflowBreaks && wordLike && text.length > 1) {
|
|
371
|
+
const graphemeWidths = getGraphemeWidthsFromMap(text, width, widthMap)
|
|
372
|
+
const graphemePrefixWidths =
|
|
373
|
+
engineProfile.preferPrefixWidthsForBreakableRuns || isNumericRunSegment(text)
|
|
374
|
+
? buildPrefixWidths(graphemeWidths)
|
|
375
|
+
: null
|
|
376
|
+
pushSegment(
|
|
377
|
+
text,
|
|
378
|
+
width,
|
|
379
|
+
lineEndFitAdvance,
|
|
380
|
+
lineEndPaintAdvance,
|
|
381
|
+
kind,
|
|
382
|
+
start,
|
|
383
|
+
graphemeWidths,
|
|
384
|
+
graphemePrefixWidths,
|
|
385
|
+
)
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
pushSegment(
|
|
390
|
+
text,
|
|
391
|
+
width,
|
|
392
|
+
lineEndFitAdvance,
|
|
393
|
+
lineEndPaintAdvance,
|
|
394
|
+
kind,
|
|
395
|
+
start,
|
|
396
|
+
null,
|
|
397
|
+
null,
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
for (let mi = 0; mi < analysis.len; mi++) {
|
|
402
|
+
preparedStartByAnalysisIndex[mi] = widths.length
|
|
403
|
+
const segText = analysis.texts[mi]!
|
|
404
|
+
const segWordLike = analysis.isWordLike[mi]!
|
|
405
|
+
const segKind = analysis.kinds[mi]!
|
|
406
|
+
const segStart = analysis.starts[mi]!
|
|
407
|
+
|
|
408
|
+
if (segKind === 'soft-hyphen') {
|
|
409
|
+
pushSegment(
|
|
410
|
+
segText,
|
|
411
|
+
0,
|
|
412
|
+
discretionaryHyphenWidth,
|
|
413
|
+
discretionaryHyphenWidth,
|
|
414
|
+
segKind,
|
|
415
|
+
segStart,
|
|
416
|
+
null,
|
|
417
|
+
null,
|
|
418
|
+
)
|
|
419
|
+
continue
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (segKind === 'hard-break') {
|
|
423
|
+
pushSegment(segText, 0, 0, 0, segKind, segStart, null, null)
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (segKind === 'tab') {
|
|
428
|
+
pushSegment(segText, 0, 0, 0, segKind, segStart, null, null)
|
|
429
|
+
continue
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const containsCJK = isCJK(segText)
|
|
433
|
+
|
|
434
|
+
if (segKind === 'text' && containsCJK) {
|
|
435
|
+
const parentWidth = widthMap.get(segText) ?? 0
|
|
436
|
+
const baseUnits = buildBaseCjkUnits(segText, engineProfile)
|
|
437
|
+
const measuredUnits = wordBreak === 'keep-all'
|
|
438
|
+
? mergeKeepAllTextUnits(baseUnits)
|
|
439
|
+
: baseUnits
|
|
440
|
+
|
|
441
|
+
for (let i = 0; i < measuredUnits.length; i++) {
|
|
442
|
+
const unit = measuredUnits[i]!
|
|
443
|
+
pushTextSegment(
|
|
444
|
+
unit.text,
|
|
445
|
+
'text',
|
|
446
|
+
segStart + unit.start,
|
|
447
|
+
segWordLike,
|
|
448
|
+
wordBreak === 'keep-all' || !isCJK(unit.text),
|
|
449
|
+
segText,
|
|
450
|
+
parentWidth,
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
pushTextSegment(segText, segKind, segStart, segWordLike, true, segText, 0)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const chunks = mapAnalysisChunksToPreparedChunks(analysis.chunks, preparedStartByAnalysisIndex, widths.length)
|
|
460
|
+
const segLevels = segStarts === null ? null : computeSegmentLevels(analysis.normalized, segStarts)
|
|
461
|
+
|
|
462
|
+
const core: PreparedCore = {
|
|
463
|
+
widths,
|
|
464
|
+
lineEndFitAdvances,
|
|
465
|
+
lineEndPaintAdvances,
|
|
466
|
+
kinds,
|
|
467
|
+
simpleLineWalkFastPath,
|
|
468
|
+
segLevels,
|
|
469
|
+
breakableWidths,
|
|
470
|
+
breakablePrefixWidths,
|
|
471
|
+
discretionaryHyphenWidth,
|
|
472
|
+
tabStopAdvance,
|
|
473
|
+
chunks,
|
|
474
|
+
style,
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (segments !== null) {
|
|
478
|
+
return { ...core, segments } as unknown as InternalPreparedTextWithSegments
|
|
479
|
+
}
|
|
480
|
+
return core as unknown as InternalPreparedText
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// --- Public build functions (called by prepare.ts) ---
|
|
484
|
+
|
|
485
|
+
export function buildPreparedText(
|
|
486
|
+
analysis: TextAnalysis,
|
|
487
|
+
widthMap: Map<string, number>,
|
|
488
|
+
style: TextStyle,
|
|
489
|
+
options?: PrepareOptions,
|
|
490
|
+
): PreparedText {
|
|
491
|
+
const wordBreak = options?.wordBreak ?? 'normal'
|
|
492
|
+
return buildFromAnalysis(analysis, widthMap, false, wordBreak, style) as PreparedText
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function buildPreparedTextWithSegments(
|
|
496
|
+
analysis: TextAnalysis,
|
|
497
|
+
widthMap: Map<string, number>,
|
|
498
|
+
style: TextStyle,
|
|
499
|
+
options?: PrepareOptions,
|
|
500
|
+
): PublicPreparedTextWithSegments {
|
|
501
|
+
const wordBreak = options?.wordBreak ?? 'normal'
|
|
502
|
+
return buildFromAnalysis(analysis, widthMap, true, wordBreak, style) as unknown as PublicPreparedTextWithSegments
|
|
503
|
+
}
|