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 ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-04-08
4
+
5
+ ### Added
6
+
7
+ - **obstacle-layout module** — `carveTextLineSlots`, `circleIntervalForBand`, `rectIntervalForBand`, `layoutColumn` for text reflow around obstacles
8
+ - **TextKit primary measurement** — `useTextHeight`, `useFlashListHeights`, `measureHeights` now use NSLayoutManager for pixel-perfect accuracy matching RN Text
9
+ - **8 demo screens** — Editorial Engine, Tight Bubbles, Accordion, Masonry, i18n, Markdown Chat, Justification Comparison, ASCII Art
10
+ - **`measureTextHeight` native function** — NSLayoutManager-based height measurement on iOS
11
+
12
+ ### Fixed
13
+
14
+ - CJK/Georgian/Mixed text accuracy — TextKit measurement matches RN Text exactly
15
+ - Intl.Segmenter fallback for Hermes — grapheme splitting works without polyfill
16
+ - System font detection — no false warnings for built-in iOS fonts
17
+ - iOS native module CFLocale type mismatch
18
+
19
+ ## 0.1.0 — 2026-04-05
20
+
21
+ ### Added
22
+
23
+ - Initial release of expo-pretext
24
+ - Core API: `prepare()`, `layout()`, `prepareWithSegments()`, `layoutWithLines()`, `layoutNextLine()`, `walkLineRanges()`, `measureNaturalWidth()`
25
+ - React hooks: `useTextHeight()`, `usePreparedText()`, `useFlashListHeights()`
26
+ - Rich inline: `prepareInlineFlow()`, `walkInlineFlowLines()`, `measureInlineFlow()`
27
+ - Batch: `measureHeights()`
28
+ - iOS native module (Swift) — CFStringTokenizer + CTLine measurement
29
+ - Android native module (Kotlin) — BreakIterator + TextPaint measurement
30
+ - Auto-batching, JS-side caching, incremental streaming extend
31
+ - Ported from Pretext v0.0.4 (chenglou/pretext)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Juba Kitiashvili
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # expo-pretext
2
+
3
+ DOM-free multiline text height prediction for React Native. Port of [Pretext](https://github.com/chenglou/pretext).
4
+
5
+ Predict text heights **before rendering** — no `onLayout`, no layout jumps, no guesswork. Works with FlashList, streaming AI chat, and any layout that needs text dimensions upfront.
6
+
7
+ ## Demos
8
+
9
+ <table>
10
+ <tr>
11
+ <td align="center"><strong>AI Chat</strong></td>
12
+ <td align="center"><strong>Accuracy</strong></td>
13
+ <td align="center"><strong>Editorial Engine</strong></td>
14
+ </tr>
15
+ <tr>
16
+ <td align="center">
17
+ <video src="https://github.com/JubaKitiashvili/expo-pretext/raw/main/assets/demos/ai-chat.mp4" width="240" />
18
+ </td>
19
+ <td align="center">
20
+ <video src="https://github.com/JubaKitiashvili/expo-pretext/raw/main/assets/demos/accuracy.mp4" width="240" />
21
+ </td>
22
+ <td align="center">
23
+ <video src="https://github.com/JubaKitiashvili/expo-pretext/raw/main/assets/demos/editorial-engine.mp4" width="240" />
24
+ </td>
25
+ </tr>
26
+ <tr>
27
+ <td align="center"><sub>FlashList + streaming + markdown</sub></td>
28
+ <td align="center"><sub>Predicted vs actual height</sub></td>
29
+ <td align="center"><sub>Text reflow around obstacles</sub></td>
30
+ </tr>
31
+ </table>
32
+
33
+ ## Installation
34
+
35
+ ```sh
36
+ npx expo install expo-pretext
37
+ ```
38
+
39
+ > Requires Expo SDK 52+ with a development build. Expo Go falls back to JS estimates.
40
+
41
+ ## Quick Start
42
+
43
+ ```tsx
44
+ import { useTextHeight } from 'expo-pretext'
45
+
46
+ function ChatBubble({ text }) {
47
+ const height = useTextHeight(text, {
48
+ fontFamily: 'Inter',
49
+ fontSize: 16,
50
+ lineHeight: 24,
51
+ }, maxWidth)
52
+
53
+ return <View style={{ height }}><Text>{text}</Text></View>
54
+ }
55
+ ```
56
+
57
+ ## FlashList Integration
58
+
59
+ ```tsx
60
+ import { useFlashListHeights } from 'expo-pretext'
61
+
62
+ function ChatScreen() {
63
+ const { estimatedItemSize, overrideItemLayout } = useFlashListHeights(
64
+ messages,
65
+ msg => msg.text,
66
+ { fontFamily: 'Inter', fontSize: 16, lineHeight: 24 },
67
+ containerWidth
68
+ )
69
+
70
+ return (
71
+ <FlashList
72
+ data={messages}
73
+ estimatedItemSize={estimatedItemSize}
74
+ overrideItemLayout={overrideItemLayout}
75
+ renderItem={renderMessage}
76
+ />
77
+ )
78
+ }
79
+ ```
80
+
81
+ ## Streaming AI Chat
82
+
83
+ ```tsx
84
+ function StreamingMessage({ text }) {
85
+ // Automatically detects append pattern, uses incremental measurement
86
+ // Native cache means most segments are instant cache hits
87
+ const height = useTextHeight(text, style, maxWidth)
88
+ return <View style={{ minHeight: height }}><Text>{text}</Text></View>
89
+ }
90
+ ```
91
+
92
+ ## Batch Measurement
93
+
94
+ ```tsx
95
+ import { measureHeights } from 'expo-pretext'
96
+
97
+ // One native call for all texts
98
+ const heights = measureHeights(
99
+ ['Hello world', 'Longer paragraph...', '短い文'],
100
+ { fontFamily: 'Inter', fontSize: 16, lineHeight: 24 },
101
+ 320
102
+ )
103
+ ```
104
+
105
+ ## API Reference
106
+
107
+ ### Simple API
108
+
109
+ | Function | Description |
110
+ |---|---|
111
+ | `useTextHeight(text, style, maxWidth)` | Returns height as number. Auto-optimizes for streaming. |
112
+ | `useFlashListHeights(data, getText, style, maxWidth)` | Returns `{ estimatedItemSize, overrideItemLayout }` for FlashList. |
113
+ | `usePreparedText(text, style)` | Returns PreparedText handle for manual layout. |
114
+ | `measureHeights(texts, style, maxWidth)` | Batch: texts in, heights out. |
115
+
116
+ ### Power API (Pretext-compatible)
117
+
118
+ | Function | Description |
119
+ |---|---|
120
+ | `prepare(text, style, options?)` | One-time measurement. Returns opaque PreparedText. |
121
+ | `layout(prepared, maxWidth)` | Pure arithmetic height calculation. ~0.0002ms. |
122
+ | `prepareWithSegments(text, style, options?)` | Rich variant with segment data. |
123
+ | `layoutWithLines(prepared, maxWidth)` | Returns `{ height, lineCount, lines }`. |
124
+ | `layoutNextLine(prepared, start, maxWidth)` | Iterator for variable-width layouts. |
125
+ | `walkLineRanges(prepared, maxWidth, onLine)` | Line walker without string materialization. |
126
+ | `measureNaturalWidth(prepared)` | Intrinsic width (widest forced line). |
127
+
128
+ ### Rich Inline API
129
+
130
+ | Function | Description |
131
+ |---|---|
132
+ | `prepareInlineFlow(items)` | Mixed fonts, @mention pills, chips. Returns opaque PreparedInlineFlow. |
133
+ | `walkInlineFlowLines(prepared, maxWidth, onLine)` | Line walker for inline fragments. Calls `onLine(fragments[], y, lineHeight)` per line. |
134
+ | `measureInlineFlow(prepared, maxWidth)` | Total height for inline fragment stream. |
135
+
136
+ ### Streaming API
137
+
138
+ For AI chat and real-time text append scenarios without hooks:
139
+
140
+ | Function | Description |
141
+ |---|---|
142
+ | `prepareStreaming(key, text, style, options?)` | Optimized prepare for growing text. Warms cache with new suffix, reuses previous segments. `key` is any object used to track state. |
143
+ | `clearStreamingState(key)` | Clean up streaming state when conversation resets. |
144
+
145
+ ### Obstacle Layout API
146
+
147
+ For flowing text around shapes (circles, rectangles) — editorial/magazine layouts:
148
+
149
+ | Function | Description |
150
+ |---|---|
151
+ | `layoutColumn(prepared, options)` | Flow text in a column with obstacles. Returns `{ lines, height }`. |
152
+ | `carveTextLineSlots(lineY, lineHeight, maxWidth, obstacles)` | Compute available text slots for a line, avoiding obstacles. |
153
+ | `circleIntervalForBand(circle, bandTop, bandBottom)` | Horizontal interval a circle occupies at a given vertical band. |
154
+ | `rectIntervalForBand(rect, bandTop, bandBottom)` | Horizontal interval a rectangle occupies at a given vertical band. |
155
+
156
+ ### Types
157
+
158
+ ```ts
159
+ type TextStyle = {
160
+ fontFamily: string
161
+ fontSize: number
162
+ lineHeight?: number
163
+ fontWeight?: '400' | '500' | '600' | '700' | 'bold' | 'normal'
164
+ fontStyle?: 'normal' | 'italic'
165
+ }
166
+
167
+ type PrepareOptions = {
168
+ whiteSpace?: 'normal' | 'pre-wrap'
169
+ locale?: string
170
+ accuracy?: 'fast' | 'exact'
171
+ }
172
+
173
+ type InlineFlowItem = {
174
+ text: string
175
+ style: TextStyle
176
+ atomic?: boolean // no breaking inside (pills, chips)
177
+ extraWidth?: number // padding/border chrome
178
+ }
179
+ ```
180
+
181
+ ### Obstacle Layout Types
182
+
183
+ ```ts
184
+ type CircleObstacle = { type: 'circle'; cx: number; cy: number; r: number }
185
+ type RectObstacle = { type: 'rect'; x: number; y: number; width: number; height: number }
186
+ type LayoutRegion = { x: number; width: number }
187
+ type PositionedLine = { text: string; x: number; y: number; width: number }
188
+ ```
189
+
190
+ ### Utilities
191
+
192
+ ```ts
193
+ clearCache() // Clear all measurement caches
194
+ setLocale(locale?: string) // Set locale for text segmentation
195
+ ```
196
+
197
+ ## How It Works
198
+
199
+ ```
200
+ prepare(text, style)
201
+ → Native: segment text + measure widths (one call)
202
+ → JS: analyze segments, build PreparedText
203
+ → Cache everything for next time
204
+
205
+ layout(prepared, maxWidth)
206
+ → Pure JS arithmetic on cached widths
207
+ → ~0.0002ms per text
208
+ → No native calls, no DOM, no layout reflow
209
+ ```
210
+
211
+ ### Performance
212
+
213
+ - **prepare()**: ~15ms for 500 texts (batch)
214
+ - **layout()**: ~0.0002ms per text (pure arithmetic)
215
+ - **Streaming**: ~2ms per token (mostly cache hits)
216
+ - **Native caching**: LRU 5000 segments/font, frequency-based eviction
217
+ - **JS caching**: skip native calls entirely when all segments are cached
218
+
219
+ ## Accuracy
220
+
221
+ expo-pretext uses native platform text measurement (iOS `NSString.size`, Android `TextPaint.measureText`) — the same engines that render your text. Two accuracy modes:
222
+
223
+ - **`fast`** (default): Sum individual segment widths. Sub-pixel kerning differences absorbed by tolerance.
224
+ - **`exact`**: Re-measure merged segments. Pixel-perfect at cost of one extra native call.
225
+
226
+ ## i18n Support
227
+
228
+ Full Unicode support via native OS segmenters:
229
+ - CJK (Chinese, Japanese, Korean) — per-character breaking + kinsoku rules
230
+ - Arabic, Hebrew — RTL with bidi metadata
231
+ - Thai, Lao, Khmer, Myanmar — dictionary-based word boundaries
232
+ - Georgian, Devanagari, and all other scripts
233
+ - Emoji — compound graphemes, flags, ZWJ sequences
234
+ - Mixed scripts in a single string
235
+
236
+ ## Inspiration & Credits
237
+
238
+ expo-pretext is a React Native / Expo port of [Pretext](https://github.com/chenglou/pretext) by Cheng Lou. The original Pretext is a web-based text measurement library — expo-pretext brings the same core idea (predict text dimensions before rendering) to the native mobile world, using iOS TextKit and Android TextPaint for measurement instead of DOM APIs.
239
+
240
+ Key differences from the original:
241
+ - **Native measurement** via Expo modules (iOS `NSString.size`, Android `TextPaint.measureText`)
242
+ - **React Native hooks** (`useTextHeight`, `useFlashListHeights`) for declarative usage
243
+ - **Streaming optimizations** for AI chat use cases
244
+ - **Rich inline flow** for mixed-font content (pills, badges, @mentions)
245
+
246
+ Pretext itself builds on Sebastian Markbage's [text-layout](https://github.com/nicolo-ribaudo/text-layout) research.
247
+
248
+ ## License
249
+
250
+ MIT
@@ -0,0 +1,354 @@
1
+ package expo.modules.pretext
2
+
3
+ import android.graphics.Paint
4
+ import android.graphics.Typeface
5
+ import android.text.TextPaint
6
+ import expo.modules.kotlin.modules.Module
7
+ import expo.modules.kotlin.modules.ModuleDefinition
8
+ import java.text.BreakIterator
9
+ import java.util.Locale
10
+
11
+ /**
12
+ * Data class for a cached measurement entry.
13
+ * Tracks the measured width and a hit counter for LRU eviction.
14
+ */
15
+ private data class CacheEntry(
16
+ val width: Double,
17
+ var hits: Int = 0
18
+ )
19
+
20
+ class ExpoPretextModule : Module() {
21
+
22
+ // ── Caches ──────────────────────────────────────────────────────────────
23
+
24
+ /** Font/TextPaint cache keyed by "family_size_weight_style" */
25
+ private val fontCache = mutableMapOf<String, TextPaint>()
26
+
27
+ /**
28
+ * Measurement cache: outer key = font key, inner key = segment text.
29
+ * Each entry records the measured width and a hit counter.
30
+ */
31
+ private val measureCache = mutableMapOf<String, MutableMap<String, CacheEntry>>()
32
+
33
+ /** Maximum number of segment entries per font in the measurement cache. */
34
+ private var maxCacheSize: Int = 5000
35
+
36
+ // ── Whitespace regexes (compiled once) ──────────────────────────────────
37
+
38
+ private val collapseWhitespaceRegex = Regex("\\s+")
39
+ private val lineEndingRegex = Regex("\\r\\n|\\r")
40
+
41
+ // ── Module definition ───────────────────────────────────────────────────
42
+
43
+ override fun definition() = ModuleDefinition {
44
+ Name("ExpoPretext")
45
+
46
+ // ── segmentAndMeasure ───────────────────────────────────────────
47
+ Function("segmentAndMeasure") { text: String, font: Map<String, Any>, options: Map<String, Any>? ->
48
+ segmentAndMeasureInternal(text, font, options)
49
+ }
50
+
51
+ // ── batchSegmentAndMeasure ──────────────────────────────────────
52
+ Function("batchSegmentAndMeasure") { texts: List<String>, font: Map<String, Any>, options: Map<String, Any>? ->
53
+ texts.map { text -> segmentAndMeasureInternal(text, font, options) }
54
+ }
55
+
56
+ // ── measureGraphemeWidths ───────────────────────────────────────
57
+ Function("measureGraphemeWidths") { segment: String, font: Map<String, Any> ->
58
+ measureGraphemeWidthsInternal(segment, font)
59
+ }
60
+
61
+ // ── remeasureMerged ─────────────────────────────────────────────
62
+ Function("remeasureMerged") { segments: List<String>, font: Map<String, Any> ->
63
+ remeasureMergedInternal(segments, font)
64
+ }
65
+
66
+ // ── segmentAndMeasureAsync ──────────────────────────────────────
67
+ AsyncFunction("segmentAndMeasureAsync") { text: String, font: Map<String, Any>, options: Map<String, Any>? ->
68
+ segmentAndMeasureInternal(text, font, options)
69
+ }
70
+
71
+ // ── clearNativeCache ────────────────────────────────────────────
72
+ Function("clearNativeCache") {
73
+ fontCache.clear()
74
+ measureCache.clear()
75
+ }
76
+
77
+ // ── setNativeCacheSize ──────────────────────────────────────────
78
+ Function("setNativeCacheSize") { size: Int ->
79
+ maxCacheSize = size
80
+ }
81
+ }
82
+
83
+ // ── Core implementation ─────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Segment text using BreakIterator.getWordInstance, measure each segment,
87
+ * and return segments / isWordLike / widths arrays.
88
+ */
89
+ private fun segmentAndMeasureInternal(
90
+ text: String,
91
+ fontMap: Map<String, Any>,
92
+ optionsMap: Map<String, Any>?
93
+ ): Map<String, Any> {
94
+ val whiteSpace = (optionsMap?.get("whiteSpace") as? String) ?: "normal"
95
+ val localeStr = optionsMap?.get("locale") as? String
96
+ val locale = if (localeStr != null) Locale.forLanguageTag(localeStr) else Locale.getDefault()
97
+
98
+ // Normalize whitespace
99
+ val normalized = normalizeWhitespace(text, whiteSpace)
100
+
101
+ // Resolve paint
102
+ val paint = getOrCreatePaint(fontMap)
103
+ val fontKey = fontKeyFrom(fontMap)
104
+
105
+ // Word-level segmentation
106
+ val rawSegments = wordSegment(normalized, locale)
107
+
108
+ // Build output arrays
109
+ val segments = mutableListOf<String>()
110
+ val isWordLike = mutableListOf<Boolean>()
111
+ val widths = mutableListOf<Double>()
112
+
113
+ for (seg in rawSegments) {
114
+ val wordLike = isWordLikeSegment(seg)
115
+
116
+ if (!wordLike && whiteSpace == "pre-wrap") {
117
+ // In pre-wrap mode, split non-word segments into individual characters
118
+ for (ch in seg) {
119
+ val s = ch.toString()
120
+ segments.add(s)
121
+ isWordLike.add(false)
122
+ widths.add(cachedMeasure(s, paint, fontKey))
123
+ }
124
+ } else {
125
+ segments.add(seg)
126
+ isWordLike.add(wordLike)
127
+ widths.add(cachedMeasure(seg, paint, fontKey))
128
+ }
129
+ }
130
+
131
+ return mapOf(
132
+ "segments" to segments,
133
+ "isWordLike" to isWordLike,
134
+ "widths" to widths
135
+ )
136
+ }
137
+
138
+ /**
139
+ * Measure individual grapheme widths within a segment
140
+ * using BreakIterator.getCharacterInstance.
141
+ */
142
+ private fun measureGraphemeWidthsInternal(
143
+ segment: String,
144
+ fontMap: Map<String, Any>
145
+ ): List<Double> {
146
+ val paint = getOrCreatePaint(fontMap)
147
+ val graphemes = graphemeSegment(segment)
148
+ return graphemes.map { g ->
149
+ paint.measureText(g).toDouble()
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Re-measure a list of pre-split segments with the given font.
155
+ * Returns a list of widths corresponding 1:1 with the input segments.
156
+ */
157
+ private fun remeasureMergedInternal(
158
+ segments: List<String>,
159
+ fontMap: Map<String, Any>
160
+ ): List<Double> {
161
+ val paint = getOrCreatePaint(fontMap)
162
+ val fontKey = fontKeyFrom(fontMap)
163
+ return segments.map { seg ->
164
+ cachedMeasure(seg, paint, fontKey)
165
+ }
166
+ }
167
+
168
+ // ── Segmentation helpers ────────────────────────────────────────────────
169
+
170
+ /**
171
+ * Segment text into word-level boundaries using ICU BreakIterator.
172
+ */
173
+ private fun wordSegment(text: String, locale: Locale): List<String> {
174
+ if (text.isEmpty()) return emptyList()
175
+
176
+ val bi = BreakIterator.getWordInstance(locale)
177
+ bi.setText(text)
178
+
179
+ val segments = mutableListOf<String>()
180
+ var start = bi.first()
181
+ var end = bi.next()
182
+
183
+ while (end != BreakIterator.DONE) {
184
+ segments.add(text.substring(start, end))
185
+ start = end
186
+ end = bi.next()
187
+ }
188
+
189
+ return segments
190
+ }
191
+
192
+ /**
193
+ * Segment text into grapheme clusters using BreakIterator.getCharacterInstance.
194
+ */
195
+ private fun graphemeSegment(text: String): List<String> {
196
+ if (text.isEmpty()) return emptyList()
197
+
198
+ val bi = BreakIterator.getCharacterInstance()
199
+ bi.setText(text)
200
+
201
+ val graphemes = mutableListOf<String>()
202
+ var start = bi.first()
203
+ var end = bi.next()
204
+
205
+ while (end != BreakIterator.DONE) {
206
+ graphemes.add(text.substring(start, end))
207
+ start = end
208
+ end = bi.next()
209
+ }
210
+
211
+ return graphemes
212
+ }
213
+
214
+ /**
215
+ * Determine if a segment is "word-like" — i.e., contains at least one
216
+ * letter or digit character.
217
+ */
218
+ private fun isWordLikeSegment(segment: String): Boolean {
219
+ return segment.any { Character.isLetterOrDigit(it) }
220
+ }
221
+
222
+ // ── Whitespace normalization ────────────────────────────────────────────
223
+
224
+ /**
225
+ * In "normal" mode: collapse runs of whitespace to a single space.
226
+ * In "pre-wrap" mode: normalize line endings (\r\n and \r -> \n) only.
227
+ */
228
+ private fun normalizeWhitespace(text: String, mode: String): String {
229
+ return when (mode) {
230
+ "pre-wrap" -> lineEndingRegex.replace(text, "\n")
231
+ else -> collapseWhitespaceRegex.replace(text, " ")
232
+ }
233
+ }
234
+
235
+ // ── Font / Paint helpers ────────────────────────────────────────────────
236
+
237
+ /**
238
+ * Build a stable cache key from the font descriptor map.
239
+ */
240
+ private fun fontKeyFrom(fontMap: Map<String, Any>): String {
241
+ val family = (fontMap["fontFamily"] as? String) ?: "sans-serif"
242
+ val size = fontMap["fontSize"]?.let { toDouble(it) } ?: 14.0
243
+ val weight = (fontMap["fontWeight"] as? String) ?: "400"
244
+ val style = (fontMap["fontStyle"] as? String) ?: "normal"
245
+ return "${family}_${size}_${weight}_${style}"
246
+ }
247
+
248
+ /**
249
+ * Get or create a TextPaint for the given font descriptor.
250
+ * Cached by fontKey to avoid repeated Typeface resolution and Paint creation.
251
+ */
252
+ private fun getOrCreatePaint(fontMap: Map<String, Any>): TextPaint {
253
+ val key = fontKeyFrom(fontMap)
254
+ return fontCache.getOrPut(key) {
255
+ val family = (fontMap["fontFamily"] as? String) ?: "sans-serif"
256
+ val size = fontMap["fontSize"]?.let { toDouble(it) }?.toFloat() ?: 14f
257
+ val weight = (fontMap["fontWeight"] as? String) ?: "400"
258
+ val style = (fontMap["fontStyle"] as? String) ?: "normal"
259
+
260
+ val typefaceStyle = resolveTypefaceStyle(weight, style)
261
+ val typeface = Typeface.create(family, typefaceStyle)
262
+
263
+ TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
264
+ this.typeface = typeface
265
+ this.textSize = size
266
+ }
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Map fontWeight + fontStyle to a Typeface style int.
272
+ * Android Typeface supports: NORMAL, BOLD, ITALIC, BOLD_ITALIC.
273
+ */
274
+ private fun resolveTypefaceStyle(weight: String, style: String): Int {
275
+ val isBold = when (weight) {
276
+ "bold", "700", "800", "900" -> true
277
+ else -> {
278
+ val numericWeight = weight.toIntOrNull() ?: 400
279
+ numericWeight >= 700
280
+ }
281
+ }
282
+ val isItalic = style == "italic"
283
+
284
+ return when {
285
+ isBold && isItalic -> Typeface.BOLD_ITALIC
286
+ isBold -> Typeface.BOLD
287
+ isItalic -> Typeface.ITALIC
288
+ else -> Typeface.NORMAL
289
+ }
290
+ }
291
+
292
+ // ── Measurement with LRU cache ──────────────────────────────────────────
293
+
294
+ /**
295
+ * Measure a segment with caching. Each font gets its own sub-map.
296
+ * When a sub-map exceeds maxCacheSize, the entry with the lowest hit
297
+ * count is evicted.
298
+ */
299
+ private fun cachedMeasure(segment: String, paint: TextPaint, fontKey: String): Double {
300
+ val fontMap = measureCache.getOrPut(fontKey) { mutableMapOf() }
301
+ val existing = fontMap[segment]
302
+
303
+ if (existing != null) {
304
+ existing.hits++
305
+ return existing.width
306
+ }
307
+
308
+ // Measure fresh
309
+ val width = paint.measureText(segment).toDouble()
310
+ fontMap[segment] = CacheEntry(width = width, hits = 1)
311
+
312
+ // LRU eviction: if over capacity, remove the least-hit entry
313
+ if (fontMap.size > maxCacheSize) {
314
+ evictLeastUsed(fontMap)
315
+ }
316
+
317
+ return width
318
+ }
319
+
320
+ /**
321
+ * Evict the entry with the lowest hit count from the given cache map.
322
+ */
323
+ private fun evictLeastUsed(cache: MutableMap<String, CacheEntry>) {
324
+ var minKey: String? = null
325
+ var minHits = Int.MAX_VALUE
326
+
327
+ for ((key, entry) in cache) {
328
+ if (entry.hits < minHits) {
329
+ minHits = entry.hits
330
+ minKey = key
331
+ }
332
+ }
333
+
334
+ if (minKey != null) {
335
+ cache.remove(minKey)
336
+ }
337
+ }
338
+
339
+ // ── Utility ─────────────────────────────────────────────────────────────
340
+
341
+ /**
342
+ * Safely convert Any to Double, handling Int/Long/Float/Double/String.
343
+ */
344
+ private fun toDouble(value: Any): Double {
345
+ return when (value) {
346
+ is Double -> value
347
+ is Float -> value.toDouble()
348
+ is Int -> value.toDouble()
349
+ is Long -> value.toDouble()
350
+ is String -> value.toDoubleOrNull() ?: 14.0
351
+ else -> 14.0
352
+ }
353
+ }
354
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["ios", "android"],
3
+ "ios": {
4
+ "modules": ["ExpoPretext"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.pretext.ExpoPretextModule"]
8
+ }
9
+ }
@@ -0,0 +1,20 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'ExpoPretext'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = package['author']
12
+ s.homepage = package['repository']['url']
13
+ s.platforms = { :ios => '15.1' }
14
+ s.source = { git: '' }
15
+ s.static_framework = true
16
+
17
+ s.dependency 'ExpoModulesCore'
18
+
19
+ s.source_files = '**/*.swift'
20
+ end