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