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,193 @@
|
|
|
1
|
+
// obstacle-layout.ts
|
|
2
|
+
// High-level text layout around obstacles (circles, rectangles).
|
|
3
|
+
// Used by editorial engine, dynamic layout, and any reflow-around-obstacles demo.
|
|
4
|
+
//
|
|
5
|
+
// Core idea: for each text line Y, compute blocked intervals from obstacles,
|
|
6
|
+
// carve free slots, and fill each slot with layoutNextLine().
|
|
7
|
+
// prepareWithSegments() is called ONCE. layoutNextLine() is called N times per frame.
|
|
8
|
+
|
|
9
|
+
import { layoutNextLine, type LayoutCursor, type PreparedTextWithSegments } from './layout'
|
|
10
|
+
import type { LayoutLine } from './types'
|
|
11
|
+
|
|
12
|
+
// --- Types ---
|
|
13
|
+
|
|
14
|
+
export type Interval = {
|
|
15
|
+
left: number
|
|
16
|
+
right: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type CircleObstacle = {
|
|
20
|
+
cx: number
|
|
21
|
+
cy: number
|
|
22
|
+
r: number
|
|
23
|
+
hPad?: number
|
|
24
|
+
vPad?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type RectObstacle = {
|
|
28
|
+
x: number
|
|
29
|
+
y: number
|
|
30
|
+
w: number
|
|
31
|
+
h: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type LayoutRegion = {
|
|
35
|
+
x: number
|
|
36
|
+
y: number
|
|
37
|
+
width: number
|
|
38
|
+
height: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type PositionedLine = {
|
|
42
|
+
x: number
|
|
43
|
+
y: number
|
|
44
|
+
width: number
|
|
45
|
+
text: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type LayoutColumnResult = {
|
|
49
|
+
lines: PositionedLine[]
|
|
50
|
+
cursor: LayoutCursor
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- Interval arithmetic ---
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compute the horizontal interval blocked by a circle at a given line band.
|
|
57
|
+
* Returns null if the circle doesn't intersect this band.
|
|
58
|
+
*/
|
|
59
|
+
export function circleIntervalForBand(
|
|
60
|
+
cx: number,
|
|
61
|
+
cy: number,
|
|
62
|
+
r: number,
|
|
63
|
+
bandTop: number,
|
|
64
|
+
bandBottom: number,
|
|
65
|
+
hPad: number = 0,
|
|
66
|
+
vPad: number = 0,
|
|
67
|
+
): Interval | null {
|
|
68
|
+
const top = bandTop - vPad
|
|
69
|
+
const bottom = bandBottom + vPad
|
|
70
|
+
if (top >= cy + r || bottom <= cy - r) return null
|
|
71
|
+
const minDy = cy >= top && cy <= bottom ? 0 : cy < top ? top - cy : cy - bottom
|
|
72
|
+
if (minDy >= r) return null
|
|
73
|
+
const maxDx = Math.sqrt(r * r - minDy * minDy)
|
|
74
|
+
return { left: cx - maxDx - hPad, right: cx + maxDx + hPad }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compute the horizontal interval blocked by a rectangle at a given line band.
|
|
79
|
+
* Returns null if the rect doesn't intersect this band.
|
|
80
|
+
*/
|
|
81
|
+
export function rectIntervalForBand(
|
|
82
|
+
rect: RectObstacle,
|
|
83
|
+
bandTop: number,
|
|
84
|
+
bandBottom: number,
|
|
85
|
+
): Interval | null {
|
|
86
|
+
if (bandBottom <= rect.y || bandTop >= rect.y + rect.h) return null
|
|
87
|
+
return { left: rect.x, right: rect.x + rect.w }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Carve a base interval into free text slots by subtracting blocked intervals.
|
|
92
|
+
* Returns slots wider than minSlotWidth (default 30px).
|
|
93
|
+
*/
|
|
94
|
+
export function carveTextLineSlots(
|
|
95
|
+
base: Interval,
|
|
96
|
+
blocked: Interval[],
|
|
97
|
+
minSlotWidth: number = 30,
|
|
98
|
+
): Interval[] {
|
|
99
|
+
let slots = [base]
|
|
100
|
+
for (let i = 0; i < blocked.length; i++) {
|
|
101
|
+
const interval = blocked[i]!
|
|
102
|
+
const next: Interval[] = []
|
|
103
|
+
for (let j = 0; j < slots.length; j++) {
|
|
104
|
+
const slot = slots[j]!
|
|
105
|
+
if (interval.right <= slot.left || interval.left >= slot.right) {
|
|
106
|
+
next.push(slot)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
if (interval.left > slot.left) next.push({ left: slot.left, right: interval.left })
|
|
110
|
+
if (interval.right < slot.right) next.push({ left: interval.right, right: slot.right })
|
|
111
|
+
}
|
|
112
|
+
slots = next
|
|
113
|
+
}
|
|
114
|
+
return slots.filter(s => s.right - s.left >= minSlotWidth)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Layout text in a rectangular region, flowing around circle and rect obstacles.
|
|
119
|
+
* Text fills all free slots on each line (both sides of obstacles).
|
|
120
|
+
*
|
|
121
|
+
* prepare() is called ONCE before this. layoutNextLine() is called per slot per line.
|
|
122
|
+
* This is Pretext's core "editorial engine" pattern.
|
|
123
|
+
*/
|
|
124
|
+
export function layoutColumn(
|
|
125
|
+
prepared: PreparedTextWithSegments,
|
|
126
|
+
startCursor: LayoutCursor,
|
|
127
|
+
region: LayoutRegion,
|
|
128
|
+
lineHeight: number,
|
|
129
|
+
circleObstacles: CircleObstacle[] = [],
|
|
130
|
+
rectObstacles: RectObstacle[] = [],
|
|
131
|
+
singleSlotOnly: boolean = false,
|
|
132
|
+
): LayoutColumnResult {
|
|
133
|
+
let cursor: LayoutCursor = startCursor
|
|
134
|
+
let lineTop = region.y
|
|
135
|
+
const lines: PositionedLine[] = []
|
|
136
|
+
let textExhausted = false
|
|
137
|
+
|
|
138
|
+
while (lineTop + lineHeight <= region.y + region.height && !textExhausted) {
|
|
139
|
+
const bandTop = lineTop
|
|
140
|
+
const bandBottom = lineTop + lineHeight
|
|
141
|
+
const blocked: Interval[] = []
|
|
142
|
+
|
|
143
|
+
for (const circle of circleObstacles) {
|
|
144
|
+
const interval = circleIntervalForBand(
|
|
145
|
+
circle.cx, circle.cy, circle.r,
|
|
146
|
+
bandTop, bandBottom,
|
|
147
|
+
circle.hPad ?? 0, circle.vPad ?? 0,
|
|
148
|
+
)
|
|
149
|
+
if (interval) blocked.push(interval)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const rect of rectObstacles) {
|
|
153
|
+
const interval = rectIntervalForBand(rect, bandTop, bandBottom)
|
|
154
|
+
if (interval) blocked.push(interval)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const base: Interval = { left: region.x, right: region.x + region.width }
|
|
158
|
+
const slots = carveTextLineSlots(base, blocked)
|
|
159
|
+
|
|
160
|
+
if (slots.length === 0) {
|
|
161
|
+
lineTop += lineHeight
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// If singleSlotOnly, pick the widest slot
|
|
166
|
+
const orderedSlots = singleSlotOnly
|
|
167
|
+
? [slots.reduce((best, slot) =>
|
|
168
|
+
(slot.right - slot.left) > (best.right - best.left) ? slot : best
|
|
169
|
+
)]
|
|
170
|
+
: [...slots].sort((a, b) => a.left - b.left)
|
|
171
|
+
|
|
172
|
+
for (const slot of orderedSlots) {
|
|
173
|
+
if (textExhausted) break
|
|
174
|
+
const slotWidth = slot.right - slot.left
|
|
175
|
+
const line = layoutNextLine(prepared, cursor, slotWidth)
|
|
176
|
+
if (!line) {
|
|
177
|
+
textExhausted = true
|
|
178
|
+
break
|
|
179
|
+
}
|
|
180
|
+
lines.push({
|
|
181
|
+
x: Math.round(slot.left),
|
|
182
|
+
y: Math.round(lineTop),
|
|
183
|
+
text: line.text,
|
|
184
|
+
width: line.width,
|
|
185
|
+
})
|
|
186
|
+
cursor = line.end
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
lineTop += lineHeight
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { lines, cursor }
|
|
193
|
+
}
|
package/src/prepare.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { getNativeModule } from './ExpoPretext'
|
|
2
|
+
import { analyzeText, type AnalysisProfile } from './analysis'
|
|
3
|
+
import {
|
|
4
|
+
buildPreparedText,
|
|
5
|
+
buildPreparedTextWithSegments,
|
|
6
|
+
type PrepareOptions as LayoutPrepareOptions,
|
|
7
|
+
} from './build'
|
|
8
|
+
import { layout } from './layout'
|
|
9
|
+
import { cacheNativeResult, clearJSCache } from './cache'
|
|
10
|
+
import { textStyleToFontDescriptor, getFontKey, getLineHeight, warnIfFontNotLoaded } from './font-utils'
|
|
11
|
+
import { getEngineProfile } from './engine-profile'
|
|
12
|
+
import type {
|
|
13
|
+
TextStyle,
|
|
14
|
+
PreparedText,
|
|
15
|
+
PreparedTextWithSegments,
|
|
16
|
+
PrepareOptions,
|
|
17
|
+
NativeSegmentResult,
|
|
18
|
+
LayoutResult,
|
|
19
|
+
} from './types'
|
|
20
|
+
|
|
21
|
+
// --- Analysis profile bridge ---
|
|
22
|
+
|
|
23
|
+
function getAnalysisProfile(): AnalysisProfile {
|
|
24
|
+
const engine = getEngineProfile()
|
|
25
|
+
return { carryCJKAfterClosingQuote: engine.carryCJKAfterClosingQuote }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// --- Auto-batch scheduler ---
|
|
29
|
+
|
|
30
|
+
type PendingItem = {
|
|
31
|
+
text: string
|
|
32
|
+
style: TextStyle
|
|
33
|
+
options?: PrepareOptions
|
|
34
|
+
resolve: (result: NativeSegmentResult) => void
|
|
35
|
+
reject: (error: Error) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let pendingItems: PendingItem[] = []
|
|
39
|
+
let flushScheduled = false
|
|
40
|
+
|
|
41
|
+
function scheduleFlush(): void {
|
|
42
|
+
if (flushScheduled) return
|
|
43
|
+
flushScheduled = true
|
|
44
|
+
queueMicrotask(flushPending)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function flushPending(): void {
|
|
48
|
+
flushScheduled = false
|
|
49
|
+
const items = pendingItems
|
|
50
|
+
pendingItems = []
|
|
51
|
+
if (items.length === 0) return
|
|
52
|
+
|
|
53
|
+
const native = getNativeModule()
|
|
54
|
+
if (!native) {
|
|
55
|
+
for (const item of items) {
|
|
56
|
+
item.resolve(estimateSegments(item.text, item.style))
|
|
57
|
+
}
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Group by font key for efficient batching
|
|
62
|
+
const groups = new Map<string, PendingItem[]>()
|
|
63
|
+
for (const item of items) {
|
|
64
|
+
const key = getFontKey(item.style)
|
|
65
|
+
let group = groups.get(key)
|
|
66
|
+
if (!group) {
|
|
67
|
+
group = []
|
|
68
|
+
groups.set(key, group)
|
|
69
|
+
}
|
|
70
|
+
group.push(item)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const [, group] of groups) {
|
|
74
|
+
const font = textStyleToFontDescriptor(group[0]!.style)
|
|
75
|
+
const opts = group[0]!.options
|
|
76
|
+
const nativeOpts = opts
|
|
77
|
+
? { whiteSpace: opts.whiteSpace, locale: opts.locale }
|
|
78
|
+
: undefined
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const results = native.batchSegmentAndMeasure(
|
|
82
|
+
group.map(g => g.text),
|
|
83
|
+
font,
|
|
84
|
+
nativeOpts
|
|
85
|
+
)
|
|
86
|
+
const fontKey = getFontKey(group[0]!.style)
|
|
87
|
+
for (let i = 0; i < group.length; i++) {
|
|
88
|
+
const result = results[i]!
|
|
89
|
+
cacheNativeResult(fontKey, result.segments, result.widths)
|
|
90
|
+
group[i]!.resolve(result)
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
for (const item of group) {
|
|
94
|
+
item.reject(err instanceof Error ? err : new Error(String(err)))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Native call with cache check ---
|
|
101
|
+
|
|
102
|
+
function segmentAndMeasureWithCache(
|
|
103
|
+
text: string,
|
|
104
|
+
style: TextStyle,
|
|
105
|
+
options?: PrepareOptions
|
|
106
|
+
): NativeSegmentResult {
|
|
107
|
+
const native = getNativeModule()
|
|
108
|
+
if (!native) {
|
|
109
|
+
return estimateSegments(text, style)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const font = textStyleToFontDescriptor(style)
|
|
113
|
+
const nativeOptions = options
|
|
114
|
+
? { whiteSpace: options.whiteSpace, locale: options.locale }
|
|
115
|
+
: undefined
|
|
116
|
+
|
|
117
|
+
const result = native.segmentAndMeasure(text, font, nativeOptions)
|
|
118
|
+
|
|
119
|
+
const fontKey = getFontKey(style)
|
|
120
|
+
cacheNativeResult(fontKey, result.segments, result.widths)
|
|
121
|
+
|
|
122
|
+
// Exact mode: re-measure merged segments after analysis
|
|
123
|
+
if (options?.accuracy === 'exact') {
|
|
124
|
+
const profile = getAnalysisProfile()
|
|
125
|
+
const analysis = analyzeText(
|
|
126
|
+
result.segments,
|
|
127
|
+
result.isWordLike,
|
|
128
|
+
profile,
|
|
129
|
+
options?.whiteSpace,
|
|
130
|
+
)
|
|
131
|
+
const mergedWidths = native.remeasureMerged(analysis.texts, font)
|
|
132
|
+
return {
|
|
133
|
+
segments: analysis.texts,
|
|
134
|
+
isWordLike: analysis.isWordLike,
|
|
135
|
+
widths: mergedWidths,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return result
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- Fallback estimate when native is unavailable ---
|
|
143
|
+
|
|
144
|
+
function estimateSegments(text: string, style: TextStyle): NativeSegmentResult {
|
|
145
|
+
const words = text.split(/(\s+)/)
|
|
146
|
+
const charWidth = style.fontSize * 0.55
|
|
147
|
+
return {
|
|
148
|
+
segments: words,
|
|
149
|
+
isWordLike: words.map(w => !/^\s+$/.test(w)),
|
|
150
|
+
widths: words.map(w => w.length * charWidth),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- Build width map from native result ---
|
|
155
|
+
|
|
156
|
+
function buildWidthMap(result: NativeSegmentResult): Map<string, number> {
|
|
157
|
+
const map = new Map<string, number>()
|
|
158
|
+
for (let i = 0; i < result.segments.length; i++) {
|
|
159
|
+
map.set(result.segments[i]!, result.widths[i]!)
|
|
160
|
+
}
|
|
161
|
+
return map
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Bridge PrepareOptions (types.ts) to LayoutPrepareOptions (layout.ts) ---
|
|
165
|
+
|
|
166
|
+
function toLayoutOptions(options?: PrepareOptions): LayoutPrepareOptions | undefined {
|
|
167
|
+
if (!options) return undefined
|
|
168
|
+
return { whiteSpace: options.whiteSpace }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- Public API ---
|
|
172
|
+
|
|
173
|
+
export function prepare(
|
|
174
|
+
text: string,
|
|
175
|
+
style: TextStyle,
|
|
176
|
+
options?: PrepareOptions
|
|
177
|
+
): PreparedText {
|
|
178
|
+
warnIfFontNotLoaded(style)
|
|
179
|
+
if (!text) {
|
|
180
|
+
const profile = getAnalysisProfile()
|
|
181
|
+
const analysis = analyzeText([], [], profile, options?.whiteSpace)
|
|
182
|
+
return buildPreparedText(analysis, new Map(), style, toLayoutOptions(options))
|
|
183
|
+
}
|
|
184
|
+
const result = segmentAndMeasureWithCache(text, style, options)
|
|
185
|
+
const profile = getAnalysisProfile()
|
|
186
|
+
const analysis = analyzeText(
|
|
187
|
+
result.segments,
|
|
188
|
+
result.isWordLike,
|
|
189
|
+
profile,
|
|
190
|
+
options?.whiteSpace,
|
|
191
|
+
)
|
|
192
|
+
const widthMap = buildWidthMap(result)
|
|
193
|
+
return buildPreparedText(analysis, widthMap, style, toLayoutOptions(options))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function prepareWithSegments(
|
|
197
|
+
text: string,
|
|
198
|
+
style: TextStyle,
|
|
199
|
+
options?: PrepareOptions
|
|
200
|
+
): PreparedTextWithSegments {
|
|
201
|
+
warnIfFontNotLoaded(style)
|
|
202
|
+
if (!text) {
|
|
203
|
+
const profile = getAnalysisProfile()
|
|
204
|
+
const analysis = analyzeText([], [], profile, options?.whiteSpace)
|
|
205
|
+
return buildPreparedTextWithSegments(analysis, new Map(), style, toLayoutOptions(options))
|
|
206
|
+
}
|
|
207
|
+
const result = segmentAndMeasureWithCache(text, style, options)
|
|
208
|
+
const profile = getAnalysisProfile()
|
|
209
|
+
const analysis = analyzeText(
|
|
210
|
+
result.segments,
|
|
211
|
+
result.isWordLike,
|
|
212
|
+
profile,
|
|
213
|
+
options?.whiteSpace,
|
|
214
|
+
)
|
|
215
|
+
const widthMap = buildWidthMap(result)
|
|
216
|
+
return buildPreparedTextWithSegments(analysis, widthMap, style, toLayoutOptions(options))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function measureHeights(
|
|
220
|
+
texts: string[],
|
|
221
|
+
style: TextStyle,
|
|
222
|
+
maxWidth: number
|
|
223
|
+
): number[] {
|
|
224
|
+
const native = getNativeModule()
|
|
225
|
+
if (!native) {
|
|
226
|
+
return texts.map(t => {
|
|
227
|
+
const p = prepare(t, style)
|
|
228
|
+
return layout(p, maxWidth).height
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Primary: TextKit for pixel-perfect height
|
|
233
|
+
const font = textStyleToFontDescriptor(style)
|
|
234
|
+
const lh = getLineHeight(style)
|
|
235
|
+
return texts.map(text => {
|
|
236
|
+
try {
|
|
237
|
+
return native.measureTextHeight(text, font, maxWidth, lh).height
|
|
238
|
+
} catch {
|
|
239
|
+
// Fallback to segment-based
|
|
240
|
+
const p = prepare(text, style)
|
|
241
|
+
return layout(p, maxWidth).height
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export { clearJSCache }
|