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,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 }