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.
@@ -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
+ }