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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useMemo, useEffect, useRef, useCallback } from 'react'
|
|
2
|
+
import { getNativeModule } from '../ExpoPretext'
|
|
3
|
+
import { textStyleToFontDescriptor, getLineHeight } from '../font-utils'
|
|
4
|
+
import { prepare } from '../prepare'
|
|
5
|
+
import { layout } from '../layout'
|
|
6
|
+
import type { TextStyle } from '../types'
|
|
7
|
+
|
|
8
|
+
type FlashListLayoutResult = {
|
|
9
|
+
estimatedItemSize: number
|
|
10
|
+
overrideItemLayout: (layout: { size?: number }, item: any, index: number) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Measure single text height — TextKit primary, segment fallback
|
|
14
|
+
function measureSingleHeight(text: string, style: TextStyle, maxWidth: number): number {
|
|
15
|
+
const native = getNativeModule()
|
|
16
|
+
if (native) {
|
|
17
|
+
try {
|
|
18
|
+
const font = textStyleToFontDescriptor(style)
|
|
19
|
+
const lh = getLineHeight(style)
|
|
20
|
+
return native.measureTextHeight(text, font, maxWidth, lh).height
|
|
21
|
+
} catch {}
|
|
22
|
+
}
|
|
23
|
+
// Fallback: segment-based
|
|
24
|
+
const prepared = prepare(text, style)
|
|
25
|
+
return layout(prepared, maxWidth).height
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useFlashListHeights<T>(
|
|
29
|
+
data: T[],
|
|
30
|
+
getText: (item: T) => string,
|
|
31
|
+
style: TextStyle,
|
|
32
|
+
maxWidth: number
|
|
33
|
+
): FlashListLayoutResult {
|
|
34
|
+
const heightsRef = useRef<Map<string, number>>(new Map())
|
|
35
|
+
const lineHeight = getLineHeight(style)
|
|
36
|
+
|
|
37
|
+
// Pre-warm cache
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const texts = data.map(getText)
|
|
40
|
+
const batchSize = 50
|
|
41
|
+
let offset = 0
|
|
42
|
+
|
|
43
|
+
function warmNext() {
|
|
44
|
+
const batch = texts.slice(offset, offset + batchSize)
|
|
45
|
+
if (batch.length === 0) return
|
|
46
|
+
|
|
47
|
+
for (const text of batch) {
|
|
48
|
+
if (!heightsRef.current.has(text)) {
|
|
49
|
+
heightsRef.current.set(text, measureSingleHeight(text, style, maxWidth))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
offset += batchSize
|
|
54
|
+
if (typeof requestIdleCallback !== 'undefined' && offset < texts.length) {
|
|
55
|
+
requestIdleCallback(warmNext)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof requestIdleCallback !== 'undefined') {
|
|
60
|
+
requestIdleCallback(warmNext)
|
|
61
|
+
} else {
|
|
62
|
+
for (const text of texts) {
|
|
63
|
+
if (!heightsRef.current.has(text)) {
|
|
64
|
+
heightsRef.current.set(text, measureSingleHeight(text, style, maxWidth))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}, [data.length, getText, style.fontFamily, style.fontSize, maxWidth])
|
|
69
|
+
|
|
70
|
+
const estimatedItemSize = useMemo(() => lineHeight * 2, [lineHeight])
|
|
71
|
+
|
|
72
|
+
const overrideItemLayout = useCallback(
|
|
73
|
+
(layoutObj: { size?: number }, item: T, _index: number) => {
|
|
74
|
+
const text = getText(item)
|
|
75
|
+
const cached = heightsRef.current.get(text)
|
|
76
|
+
if (cached !== undefined) {
|
|
77
|
+
layoutObj.size = cached
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const height = measureSingleHeight(text, style, maxWidth)
|
|
81
|
+
heightsRef.current.set(text, height)
|
|
82
|
+
layoutObj.size = height
|
|
83
|
+
},
|
|
84
|
+
[getText, style, maxWidth]
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return { estimatedItemSize, overrideItemLayout }
|
|
88
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { prepare } from '../prepare'
|
|
3
|
+
import type { TextStyle, PreparedText, PrepareOptions } from '../types'
|
|
4
|
+
|
|
5
|
+
export function usePreparedText(
|
|
6
|
+
text: string,
|
|
7
|
+
style: TextStyle,
|
|
8
|
+
options?: PrepareOptions
|
|
9
|
+
): PreparedText | null {
|
|
10
|
+
return useMemo(() => {
|
|
11
|
+
if (!text) return null
|
|
12
|
+
return prepare(text, style, options)
|
|
13
|
+
}, [text, style.fontFamily, style.fontSize, style.fontWeight,
|
|
14
|
+
style.fontStyle, style.lineHeight,
|
|
15
|
+
options?.whiteSpace, options?.locale, options?.accuracy])
|
|
16
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useRef, useMemo } from 'react'
|
|
2
|
+
import { getNativeModule } from '../ExpoPretext'
|
|
3
|
+
import { textStyleToFontDescriptor, getLineHeight } from '../font-utils'
|
|
4
|
+
import { prepareStreaming } from '../streaming'
|
|
5
|
+
import { layout } from '../layout'
|
|
6
|
+
import type { TextStyle, PrepareOptions } from '../types'
|
|
7
|
+
|
|
8
|
+
export function useTextHeight(
|
|
9
|
+
text: string,
|
|
10
|
+
style: TextStyle,
|
|
11
|
+
maxWidth: number,
|
|
12
|
+
options?: PrepareOptions
|
|
13
|
+
): number {
|
|
14
|
+
const keyRef = useRef({})
|
|
15
|
+
|
|
16
|
+
return useMemo(() => {
|
|
17
|
+
if (!text) return 0
|
|
18
|
+
|
|
19
|
+
// Primary: TextKit (NSLayoutManager) — pixel-perfect match with RN Text
|
|
20
|
+
const native = getNativeModule()
|
|
21
|
+
if (native) {
|
|
22
|
+
try {
|
|
23
|
+
const font = textStyleToFontDescriptor(style)
|
|
24
|
+
const lh = getLineHeight(style)
|
|
25
|
+
const result = native.measureTextHeight(text, font, maxWidth, lh)
|
|
26
|
+
return result.height
|
|
27
|
+
} catch {
|
|
28
|
+
// Fall through to segment-based
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Fallback: segment-based prepare + layout
|
|
33
|
+
try {
|
|
34
|
+
const prepared = prepareStreaming(keyRef.current, text, style, options)
|
|
35
|
+
return layout(prepared, maxWidth).height
|
|
36
|
+
} catch {
|
|
37
|
+
// Last resort: rough estimate
|
|
38
|
+
const lh = getLineHeight(style)
|
|
39
|
+
const charsPerLine = Math.max(1, maxWidth / (style.fontSize * 0.5))
|
|
40
|
+
return Math.ceil(text.length / charsPerLine) * lh
|
|
41
|
+
}
|
|
42
|
+
}, [text, style.fontFamily, style.fontSize, style.fontWeight,
|
|
43
|
+
style.fontStyle, style.lineHeight, maxWidth,
|
|
44
|
+
options?.whiteSpace, options?.locale, options?.accuracy])
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
// Public API for expo-pretext.
|
|
3
|
+
// Simple API (hooks) + Power API (Pretext 1:1) + Utilities.
|
|
4
|
+
|
|
5
|
+
// --- Types ---
|
|
6
|
+
export type {
|
|
7
|
+
TextStyle,
|
|
8
|
+
PrepareOptions,
|
|
9
|
+
PreparedText,
|
|
10
|
+
PreparedTextWithSegments,
|
|
11
|
+
LayoutResult,
|
|
12
|
+
LayoutLine,
|
|
13
|
+
LayoutLineRange,
|
|
14
|
+
LayoutCursor,
|
|
15
|
+
LayoutWithLinesResult,
|
|
16
|
+
InlineFlowItem,
|
|
17
|
+
PreparedInlineFlow,
|
|
18
|
+
InlineFlowCursor,
|
|
19
|
+
InlineFlowFragment,
|
|
20
|
+
InlineFlowLine,
|
|
21
|
+
} from './types'
|
|
22
|
+
|
|
23
|
+
// --- Simple API ---
|
|
24
|
+
export { useTextHeight } from './hooks/useTextHeight'
|
|
25
|
+
export { usePreparedText } from './hooks/usePreparedText'
|
|
26
|
+
export { useFlashListHeights } from './hooks/useFlashListHeights'
|
|
27
|
+
export { measureHeights } from './prepare'
|
|
28
|
+
|
|
29
|
+
// --- Core API ---
|
|
30
|
+
export { prepare, prepareWithSegments } from './prepare'
|
|
31
|
+
export { layout, layoutWithLines, layoutNextLine, walkLineRanges, measureNaturalWidth } from './layout'
|
|
32
|
+
|
|
33
|
+
// --- Rich Inline (formerly inline-flow) ---
|
|
34
|
+
export { prepareInlineFlow, walkInlineFlowLines, measureInlineFlow } from './rich-inline'
|
|
35
|
+
|
|
36
|
+
// --- Obstacle Layout ---
|
|
37
|
+
export {
|
|
38
|
+
carveTextLineSlots,
|
|
39
|
+
circleIntervalForBand,
|
|
40
|
+
rectIntervalForBand,
|
|
41
|
+
layoutColumn,
|
|
42
|
+
} from './obstacle-layout'
|
|
43
|
+
export type {
|
|
44
|
+
Interval,
|
|
45
|
+
CircleObstacle,
|
|
46
|
+
RectObstacle,
|
|
47
|
+
LayoutRegion,
|
|
48
|
+
PositionedLine,
|
|
49
|
+
LayoutColumnResult,
|
|
50
|
+
} from './obstacle-layout'
|
|
51
|
+
|
|
52
|
+
// --- Streaming ---
|
|
53
|
+
export { prepareStreaming, clearStreamingState } from './streaming'
|
|
54
|
+
|
|
55
|
+
// --- Utilities ---
|
|
56
|
+
export { clearCache, setLocale } from './layout'
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// Layout engine for expo-pretext, ported from chenglou/pretext src/layout.ts.
|
|
2
|
+
//
|
|
3
|
+
// The line-breaking algorithm, line walking, and all layout math are unchanged
|
|
4
|
+
// from Pretext. The public API surface is preserved:
|
|
5
|
+
// layout(prepared, maxWidth) -> { height, lineCount }
|
|
6
|
+
// layoutWithLines(prepared, maxWidth) -> { height, lineCount, lines }
|
|
7
|
+
// layoutNextLine(prepared, start, maxWidth) -> LayoutLine | null
|
|
8
|
+
// walkLineRanges(prepared, maxWidth, onLine) -> number
|
|
9
|
+
// measureNaturalWidth(prepared) -> number
|
|
10
|
+
// clearCache() — clears internal + JS caches
|
|
11
|
+
// setLocale(locale?) — sets locale for analysis
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
clearAnalysisCaches,
|
|
15
|
+
setAnalysisLocale,
|
|
16
|
+
type SegmentBreakKind,
|
|
17
|
+
} from './analysis'
|
|
18
|
+
import {
|
|
19
|
+
countPreparedLines,
|
|
20
|
+
layoutNextLineRange as stepPreparedLineRange,
|
|
21
|
+
measurePreparedLineGeometry,
|
|
22
|
+
walkPreparedLines,
|
|
23
|
+
type InternalLayoutLine,
|
|
24
|
+
} from './line-break'
|
|
25
|
+
import { clearJSCache } from './cache'
|
|
26
|
+
import {
|
|
27
|
+
getSharedGraphemeSegmenter,
|
|
28
|
+
clearGraphemeSegmenter,
|
|
29
|
+
type InternalPreparedText,
|
|
30
|
+
type InternalPreparedTextWithSegments,
|
|
31
|
+
} from './build'
|
|
32
|
+
import type {
|
|
33
|
+
TextStyle,
|
|
34
|
+
LayoutCursor,
|
|
35
|
+
LayoutResult,
|
|
36
|
+
LayoutLine,
|
|
37
|
+
LayoutLineRange,
|
|
38
|
+
PreparedText,
|
|
39
|
+
PreparedTextWithSegments as PublicPreparedTextWithSegments,
|
|
40
|
+
} from './types'
|
|
41
|
+
|
|
42
|
+
// Re-export build functions so existing consumers (prepare.ts, tests) can
|
|
43
|
+
// still import from layout.ts during migration. The canonical source is build.ts.
|
|
44
|
+
export { buildPreparedText, buildPreparedTextWithSegments } from './build'
|
|
45
|
+
export type { PreparedLineChunk, PrepareOptions } from './build'
|
|
46
|
+
|
|
47
|
+
// Rich-path only. Reuses grapheme splits while materializing multiple lines
|
|
48
|
+
// from the same prepared handle, without pushing that cache into the API.
|
|
49
|
+
let sharedLineTextCaches = new WeakMap<InternalPreparedTextWithSegments, Map<number, string[]>>()
|
|
50
|
+
|
|
51
|
+
export type LineGeometry = {
|
|
52
|
+
lineCount: number
|
|
53
|
+
maxLineWidth: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type LayoutLinesResult = LayoutResult & {
|
|
57
|
+
lines: LayoutLine[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Internal helpers for public API ---
|
|
61
|
+
|
|
62
|
+
function getInternalPrepared(prepared: PreparedText): InternalPreparedText {
|
|
63
|
+
return prepared as InternalPreparedText
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getInternalPreparedWithSegments(prepared: PublicPreparedTextWithSegments): InternalPreparedTextWithSegments {
|
|
67
|
+
return prepared as unknown as InternalPreparedTextWithSegments
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveLineHeight(prepared: InternalPreparedText, lineHeightOverride?: number): number {
|
|
71
|
+
if (lineHeightOverride !== undefined) return lineHeightOverride
|
|
72
|
+
const style = prepared.style
|
|
73
|
+
if (style !== null && style.lineHeight !== undefined) return style.lineHeight
|
|
74
|
+
if (style !== null) return style.fontSize * 1.2
|
|
75
|
+
return 0
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Public layout API ---
|
|
79
|
+
|
|
80
|
+
// Layout prepared text at a given max width. Pure arithmetic on cached widths —
|
|
81
|
+
// no native calls, no string operations, no allocations.
|
|
82
|
+
// ~0.0002ms per text block. Call on every resize.
|
|
83
|
+
//
|
|
84
|
+
// lineHeight is optional: if omitted, it is read from the prepared handle's
|
|
85
|
+
// TextStyle (style.lineHeight, or fontSize * 1.2 as fallback).
|
|
86
|
+
export function layout(prepared: PreparedText, maxWidth: number, lineHeight?: number): LayoutResult {
|
|
87
|
+
const internal = getInternalPrepared(prepared)
|
|
88
|
+
const lineCount = countPreparedLines(internal, maxWidth)
|
|
89
|
+
const lh = resolveLineHeight(internal, lineHeight)
|
|
90
|
+
return { lineCount, height: lineCount * lh }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getSegmentGraphemes(
|
|
94
|
+
segmentIndex: number,
|
|
95
|
+
segments: string[],
|
|
96
|
+
cache: Map<number, string[]>,
|
|
97
|
+
): string[] {
|
|
98
|
+
let graphemes = cache.get(segmentIndex)
|
|
99
|
+
if (graphemes !== undefined) return graphemes
|
|
100
|
+
|
|
101
|
+
graphemes = []
|
|
102
|
+
const graphemeSegmenter = getSharedGraphemeSegmenter()
|
|
103
|
+
for (const gs of graphemeSegmenter.segment(segments[segmentIndex]!)) {
|
|
104
|
+
graphemes.push(gs.segment)
|
|
105
|
+
}
|
|
106
|
+
cache.set(segmentIndex, graphemes)
|
|
107
|
+
return graphemes
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getLineTextCache(prepared: InternalPreparedTextWithSegments): Map<number, string[]> {
|
|
111
|
+
let cache = sharedLineTextCaches.get(prepared)
|
|
112
|
+
if (cache !== undefined) return cache
|
|
113
|
+
|
|
114
|
+
cache = new Map<number, string[]>()
|
|
115
|
+
sharedLineTextCaches.set(prepared, cache)
|
|
116
|
+
return cache
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function lineHasDiscretionaryHyphen(
|
|
120
|
+
kinds: SegmentBreakKind[],
|
|
121
|
+
startSegmentIndex: number,
|
|
122
|
+
startGraphemeIndex: number,
|
|
123
|
+
endSegmentIndex: number,
|
|
124
|
+
): boolean {
|
|
125
|
+
return (
|
|
126
|
+
endSegmentIndex > 0 &&
|
|
127
|
+
kinds[endSegmentIndex - 1] === 'soft-hyphen' &&
|
|
128
|
+
!(startSegmentIndex === endSegmentIndex && startGraphemeIndex > 0)
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildLineTextFromRange(
|
|
133
|
+
segments: string[],
|
|
134
|
+
kinds: SegmentBreakKind[],
|
|
135
|
+
cache: Map<number, string[]>,
|
|
136
|
+
startSegmentIndex: number,
|
|
137
|
+
startGraphemeIndex: number,
|
|
138
|
+
endSegmentIndex: number,
|
|
139
|
+
endGraphemeIndex: number,
|
|
140
|
+
): string {
|
|
141
|
+
let text = ''
|
|
142
|
+
const endsWithDiscretionaryHyphen = lineHasDiscretionaryHyphen(
|
|
143
|
+
kinds,
|
|
144
|
+
startSegmentIndex,
|
|
145
|
+
startGraphemeIndex,
|
|
146
|
+
endSegmentIndex,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
for (let i = startSegmentIndex; i < endSegmentIndex; i++) {
|
|
150
|
+
if (kinds[i] === 'soft-hyphen' || kinds[i] === 'hard-break') continue
|
|
151
|
+
if (i === startSegmentIndex && startGraphemeIndex > 0) {
|
|
152
|
+
text += getSegmentGraphemes(i, segments, cache).slice(startGraphemeIndex).join('')
|
|
153
|
+
} else {
|
|
154
|
+
text += segments[i]!
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (endGraphemeIndex > 0) {
|
|
159
|
+
if (endsWithDiscretionaryHyphen) text += '-'
|
|
160
|
+
text += getSegmentGraphemes(endSegmentIndex, segments, cache).slice(
|
|
161
|
+
startSegmentIndex === endSegmentIndex ? startGraphemeIndex : 0,
|
|
162
|
+
endGraphemeIndex,
|
|
163
|
+
).join('')
|
|
164
|
+
} else if (endsWithDiscretionaryHyphen) {
|
|
165
|
+
text += '-'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return text
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function createLayoutLine(
|
|
172
|
+
prepared: InternalPreparedTextWithSegments,
|
|
173
|
+
cache: Map<number, string[]>,
|
|
174
|
+
width: number,
|
|
175
|
+
startSegmentIndex: number,
|
|
176
|
+
startGraphemeIndex: number,
|
|
177
|
+
endSegmentIndex: number,
|
|
178
|
+
endGraphemeIndex: number,
|
|
179
|
+
): LayoutLine {
|
|
180
|
+
return {
|
|
181
|
+
text: buildLineTextFromRange(
|
|
182
|
+
prepared.segments,
|
|
183
|
+
prepared.kinds,
|
|
184
|
+
cache,
|
|
185
|
+
startSegmentIndex,
|
|
186
|
+
startGraphemeIndex,
|
|
187
|
+
endSegmentIndex,
|
|
188
|
+
endGraphemeIndex,
|
|
189
|
+
),
|
|
190
|
+
width,
|
|
191
|
+
start: {
|
|
192
|
+
segmentIndex: startSegmentIndex,
|
|
193
|
+
graphemeIndex: startGraphemeIndex,
|
|
194
|
+
},
|
|
195
|
+
end: {
|
|
196
|
+
segmentIndex: endSegmentIndex,
|
|
197
|
+
graphemeIndex: endGraphemeIndex,
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function materializeLayoutLine(
|
|
203
|
+
prepared: InternalPreparedTextWithSegments,
|
|
204
|
+
cache: Map<number, string[]>,
|
|
205
|
+
line: InternalLayoutLine,
|
|
206
|
+
): LayoutLine {
|
|
207
|
+
return createLayoutLine(
|
|
208
|
+
prepared,
|
|
209
|
+
cache,
|
|
210
|
+
line.width,
|
|
211
|
+
line.startSegmentIndex,
|
|
212
|
+
line.startGraphemeIndex,
|
|
213
|
+
line.endSegmentIndex,
|
|
214
|
+
line.endGraphemeIndex,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function toLayoutLineRange(line: InternalLayoutLine): LayoutLineRange {
|
|
219
|
+
return {
|
|
220
|
+
width: line.width,
|
|
221
|
+
start: {
|
|
222
|
+
segmentIndex: line.startSegmentIndex,
|
|
223
|
+
graphemeIndex: line.startGraphemeIndex,
|
|
224
|
+
},
|
|
225
|
+
end: {
|
|
226
|
+
segmentIndex: line.endSegmentIndex,
|
|
227
|
+
graphemeIndex: line.endGraphemeIndex,
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function stepLineRange(
|
|
233
|
+
prepared: InternalPreparedTextWithSegments,
|
|
234
|
+
start: LayoutCursor,
|
|
235
|
+
maxWidth: number,
|
|
236
|
+
): LayoutLineRange | null {
|
|
237
|
+
const line = stepPreparedLineRange(prepared, start, maxWidth)
|
|
238
|
+
if (line === null) return null
|
|
239
|
+
return toLayoutLineRange(line)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function materializeLine(
|
|
243
|
+
prepared: InternalPreparedTextWithSegments,
|
|
244
|
+
line: LayoutLineRange,
|
|
245
|
+
): LayoutLine {
|
|
246
|
+
return createLayoutLine(
|
|
247
|
+
prepared,
|
|
248
|
+
getLineTextCache(prepared),
|
|
249
|
+
line.width,
|
|
250
|
+
line.start.segmentIndex,
|
|
251
|
+
line.start.graphemeIndex,
|
|
252
|
+
line.end.segmentIndex,
|
|
253
|
+
line.end.graphemeIndex,
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function materializeLineRange(
|
|
258
|
+
prepared: PublicPreparedTextWithSegments,
|
|
259
|
+
line: LayoutLineRange,
|
|
260
|
+
): LayoutLine {
|
|
261
|
+
return materializeLine(getInternalPreparedWithSegments(prepared), line)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Batch low-level line geometry pass. Non-materializing counterpart to
|
|
265
|
+
// layoutWithLines(), useful for shrinkwrap and other aggregate geometry work.
|
|
266
|
+
export function walkLineRanges(
|
|
267
|
+
prepared: PublicPreparedTextWithSegments,
|
|
268
|
+
maxWidth: number,
|
|
269
|
+
onLine: (line: LayoutLineRange) => void,
|
|
270
|
+
): number {
|
|
271
|
+
const internal = getInternalPreparedWithSegments(prepared)
|
|
272
|
+
if (internal.widths.length === 0) return 0
|
|
273
|
+
|
|
274
|
+
return walkPreparedLines(internal, maxWidth, line => {
|
|
275
|
+
onLine(toLayoutLineRange(line))
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function measureLineGeometry(
|
|
280
|
+
prepared: PublicPreparedTextWithSegments,
|
|
281
|
+
maxWidth: number,
|
|
282
|
+
): LineGeometry {
|
|
283
|
+
return measurePreparedLineGeometry(getInternalPreparedWithSegments(prepared), maxWidth)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Intrinsic-width helper for rich/userland layout work. Returns the widest
|
|
287
|
+
// forced line when container width is unconstrained.
|
|
288
|
+
export function measureNaturalWidth(prepared: PublicPreparedTextWithSegments): number {
|
|
289
|
+
let maxWidth = 0
|
|
290
|
+
walkLineRanges(prepared, Number.POSITIVE_INFINITY, line => {
|
|
291
|
+
if (line.width > maxWidth) maxWidth = line.width
|
|
292
|
+
})
|
|
293
|
+
return maxWidth
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function layoutNextLine(
|
|
297
|
+
prepared: PublicPreparedTextWithSegments,
|
|
298
|
+
start: LayoutCursor,
|
|
299
|
+
maxWidth: number,
|
|
300
|
+
): LayoutLine | null {
|
|
301
|
+
const line = layoutNextLineRange(prepared, start, maxWidth)
|
|
302
|
+
if (line === null) return null
|
|
303
|
+
return materializeLineRange(prepared, line)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function layoutNextLineRange(
|
|
307
|
+
prepared: PublicPreparedTextWithSegments,
|
|
308
|
+
start: LayoutCursor,
|
|
309
|
+
maxWidth: number,
|
|
310
|
+
): LayoutLineRange | null {
|
|
311
|
+
return stepLineRange(getInternalPreparedWithSegments(prepared), start, maxWidth)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Rich layout API for callers that want the actual line contents and widths.
|
|
315
|
+
// lineHeight is optional: if omitted, read from the prepared handle's style.
|
|
316
|
+
export function layoutWithLines(
|
|
317
|
+
prepared: PublicPreparedTextWithSegments,
|
|
318
|
+
maxWidth: number,
|
|
319
|
+
lineHeight?: number,
|
|
320
|
+
): LayoutLinesResult {
|
|
321
|
+
const internal = getInternalPreparedWithSegments(prepared)
|
|
322
|
+
const lines: LayoutLine[] = []
|
|
323
|
+
if (internal.widths.length === 0) return { lineCount: 0, height: 0, lines }
|
|
324
|
+
|
|
325
|
+
const graphemeCache = getLineTextCache(internal)
|
|
326
|
+
const lineCount = walkPreparedLines(internal, maxWidth, line => {
|
|
327
|
+
lines.push(materializeLayoutLine(internal, graphemeCache, line))
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
const lh = resolveLineHeight(internal, lineHeight)
|
|
331
|
+
return { lineCount, height: lineCount * lh, lines }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- Inline-flow compatibility ---
|
|
335
|
+
|
|
336
|
+
// Re-export the public PreparedTextWithSegments under the alias the inline-flow
|
|
337
|
+
// module expects. The underlying type is the same opaque branded handle.
|
|
338
|
+
export type { PublicPreparedTextWithSegments as PreparedTextWithSegments }
|
|
339
|
+
export type { LayoutCursor, LayoutResult, LayoutLineRange, LayoutLine }
|
|
340
|
+
|
|
341
|
+
// --- Cache management ---
|
|
342
|
+
|
|
343
|
+
export function clearCache(): void {
|
|
344
|
+
clearAnalysisCaches()
|
|
345
|
+
clearGraphemeSegmenter()
|
|
346
|
+
sharedLineTextCaches = new WeakMap<InternalPreparedTextWithSegments, Map<number, string[]>>()
|
|
347
|
+
clearJSCache()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function setLocale(locale?: string): void {
|
|
351
|
+
setAnalysisLocale(locale)
|
|
352
|
+
clearCache()
|
|
353
|
+
}
|