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,444 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
import CoreText
|
|
4
|
+
|
|
5
|
+
// MARK: - Cache Entry
|
|
6
|
+
|
|
7
|
+
private struct CacheEntry {
|
|
8
|
+
let width: Double
|
|
9
|
+
var hits: Int
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// MARK: - ExpoPretext Module
|
|
13
|
+
|
|
14
|
+
public class ExpoPretext: Module {
|
|
15
|
+
|
|
16
|
+
// Font cache: "family_size_weight_style" -> UIFont
|
|
17
|
+
private var fontCache: [String: UIFont] = [:]
|
|
18
|
+
|
|
19
|
+
// Measure cache: fontKey -> (segmentText -> CacheEntry)
|
|
20
|
+
private var measureCache: [String: [String: CacheEntry]] = [:]
|
|
21
|
+
|
|
22
|
+
// Max segments per font before LRU eviction
|
|
23
|
+
private var maxCacheSize: Int = 5000
|
|
24
|
+
|
|
25
|
+
// MARK: - Module Definition
|
|
26
|
+
|
|
27
|
+
public func definition() -> ModuleDefinition {
|
|
28
|
+
Name("ExpoPretext")
|
|
29
|
+
|
|
30
|
+
// Trim caches on memory warning
|
|
31
|
+
OnAppEntersBackground {
|
|
32
|
+
self.trimCaches(keepTop: 1000)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Register for memory warning notifications
|
|
36
|
+
OnStartObserving {
|
|
37
|
+
NotificationCenter.default.addObserver(
|
|
38
|
+
self,
|
|
39
|
+
selector: #selector(self.handleMemoryWarning),
|
|
40
|
+
name: UIApplication.didReceiveMemoryWarningNotification,
|
|
41
|
+
object: nil
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
OnStopObserving {
|
|
46
|
+
NotificationCenter.default.removeObserver(
|
|
47
|
+
self,
|
|
48
|
+
name: UIApplication.didReceiveMemoryWarningNotification,
|
|
49
|
+
object: nil
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// segmentAndMeasure(text, font, options?) -> { segments, isWordLike, widths }
|
|
54
|
+
Function("segmentAndMeasure") { (text: String, fontDesc: [String: Any], options: [String: Any]?) -> [String: Any] in
|
|
55
|
+
let font = self.resolveFont(fontDesc)
|
|
56
|
+
let fontKey = self.fontKey(fontDesc)
|
|
57
|
+
let whiteSpace = (options?["whiteSpace"] as? String) ?? "normal"
|
|
58
|
+
let locale = options?["locale"] as? String
|
|
59
|
+
return self.performSegmentAndMeasure(text: text, font: font, fontKey: fontKey, whiteSpace: whiteSpace, locale: locale)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// batchSegmentAndMeasure(texts, font, options?) -> [{ segments, isWordLike, widths }]
|
|
63
|
+
Function("batchSegmentAndMeasure") { (texts: [String], fontDesc: [String: Any], options: [String: Any]?) -> [[String: Any]] in
|
|
64
|
+
let font = self.resolveFont(fontDesc)
|
|
65
|
+
let fontKey = self.fontKey(fontDesc)
|
|
66
|
+
let whiteSpace = (options?["whiteSpace"] as? String) ?? "normal"
|
|
67
|
+
let locale = options?["locale"] as? String
|
|
68
|
+
return texts.map { text in
|
|
69
|
+
self.performSegmentAndMeasure(text: text, font: font, fontKey: fontKey, whiteSpace: whiteSpace, locale: locale)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// measureGraphemeWidths(segment, font) -> [Double]
|
|
74
|
+
Function("measureGraphemeWidths") { (segment: String, fontDesc: [String: Any]) -> [Double] in
|
|
75
|
+
let font = self.resolveFont(fontDesc)
|
|
76
|
+
var widths: [Double] = []
|
|
77
|
+
var index = segment.startIndex
|
|
78
|
+
while index < segment.endIndex {
|
|
79
|
+
let nextIndex = segment.index(after: index)
|
|
80
|
+
// Walk to next grapheme cluster boundary
|
|
81
|
+
let grapheme = String(segment[index..<nextIndex])
|
|
82
|
+
let attrStr = NSAttributedString(string: grapheme, attributes: [.font: font])
|
|
83
|
+
let ctLine = CTLineCreateWithAttributedString(attrStr)
|
|
84
|
+
widths.append(Double(CTLineGetTypographicBounds(ctLine, nil, nil, nil)))
|
|
85
|
+
index = nextIndex
|
|
86
|
+
}
|
|
87
|
+
return widths
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// remeasureMerged(segments, font) -> [Double]
|
|
91
|
+
Function("remeasureMerged") { (segments: [String], fontDesc: [String: Any]) -> [Double] in
|
|
92
|
+
let font = self.resolveFont(fontDesc)
|
|
93
|
+
return segments.map { segment in
|
|
94
|
+
let attrStr = NSAttributedString(string: segment, attributes: [.font: font])
|
|
95
|
+
let ctLine = CTLineCreateWithAttributedString(attrStr)
|
|
96
|
+
return Double(CTLineGetTypographicBounds(ctLine, nil, nil, nil))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// segmentAndMeasureAsync(text, font, options?) -> Promise<{ segments, isWordLike, widths }>
|
|
101
|
+
AsyncFunction("segmentAndMeasureAsync") { (text: String, fontDesc: [String: Any], options: [String: Any]?, promise: Promise) in
|
|
102
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
103
|
+
let font = self.resolveFont(fontDesc)
|
|
104
|
+
let fontKey = self.fontKey(fontDesc)
|
|
105
|
+
let whiteSpace = (options?["whiteSpace"] as? String) ?? "normal"
|
|
106
|
+
let locale = options?["locale"] as? String
|
|
107
|
+
let result = self.performSegmentAndMeasure(text: text, font: font, fontKey: fontKey, whiteSpace: whiteSpace, locale: locale)
|
|
108
|
+
promise.resolve(result)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// clearNativeCache() -> void
|
|
113
|
+
Function("clearNativeCache") {
|
|
114
|
+
self.fontCache.removeAll()
|
|
115
|
+
self.measureCache.removeAll()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// setNativeCacheSize(size) -> void
|
|
119
|
+
Function("setNativeCacheSize") { (size: Int) in
|
|
120
|
+
self.maxCacheSize = max(size, 100) // floor at 100 to avoid thrashing
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// measureTextHeight(text, font, maxWidth, lineHeight) -> { height, lineCount }
|
|
124
|
+
// Uses NSLayoutManager (TextKit) — same layout engine as RN Text
|
|
125
|
+
Function("measureTextHeight") {
|
|
126
|
+
(text: String, fontDesc: [String: Any], maxWidth: Double, lineHeight: Double) -> [String: Any] in
|
|
127
|
+
let font = self.resolveFont(fontDesc)
|
|
128
|
+
|
|
129
|
+
let textStorage = NSTextStorage(string: text, attributes: [
|
|
130
|
+
.font: font,
|
|
131
|
+
.paragraphStyle: {
|
|
132
|
+
let ps = NSMutableParagraphStyle()
|
|
133
|
+
ps.minimumLineHeight = CGFloat(lineHeight)
|
|
134
|
+
ps.maximumLineHeight = CGFloat(lineHeight)
|
|
135
|
+
return ps
|
|
136
|
+
}()
|
|
137
|
+
])
|
|
138
|
+
|
|
139
|
+
let layoutManager = NSLayoutManager()
|
|
140
|
+
let textContainer = NSTextContainer(size: CGSize(
|
|
141
|
+
width: CGFloat(maxWidth),
|
|
142
|
+
height: CGFloat.greatestFiniteMagnitude
|
|
143
|
+
))
|
|
144
|
+
textContainer.lineFragmentPadding = 0
|
|
145
|
+
|
|
146
|
+
layoutManager.addTextContainer(textContainer)
|
|
147
|
+
textStorage.addLayoutManager(layoutManager)
|
|
148
|
+
|
|
149
|
+
// Force layout
|
|
150
|
+
layoutManager.ensureLayout(for: textContainer)
|
|
151
|
+
|
|
152
|
+
let usedRect = layoutManager.usedRect(for: textContainer)
|
|
153
|
+
let glyphRange = layoutManager.glyphRange(for: textContainer)
|
|
154
|
+
|
|
155
|
+
// Count lines
|
|
156
|
+
var lineCount = 0
|
|
157
|
+
var index = glyphRange.location
|
|
158
|
+
while index < NSMaxRange(glyphRange) {
|
|
159
|
+
var lineRange = NSRange()
|
|
160
|
+
layoutManager.lineFragmentRect(forGlyphAt: index, effectiveRange: &lineRange)
|
|
161
|
+
lineCount += 1
|
|
162
|
+
index = NSMaxRange(lineRange)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return [
|
|
166
|
+
"height": Double(ceil(usedRect.height)),
|
|
167
|
+
"lineCount": lineCount
|
|
168
|
+
]
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// MARK: - Memory Warning
|
|
173
|
+
|
|
174
|
+
@objc private func handleMemoryWarning() {
|
|
175
|
+
trimCaches(keepTop: 200)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// MARK: - Cache Trimming
|
|
179
|
+
|
|
180
|
+
/// Trim each font's measure cache to the top N entries by hit count.
|
|
181
|
+
private func trimCaches(keepTop n: Int) {
|
|
182
|
+
for fontKey in measureCache.keys {
|
|
183
|
+
guard let entries = measureCache[fontKey], entries.count > n else { continue }
|
|
184
|
+
// Sort by hits descending, keep top n
|
|
185
|
+
let sorted = entries.sorted { $0.value.hits > $1.value.hits }
|
|
186
|
+
var trimmed: [String: CacheEntry] = [:]
|
|
187
|
+
trimmed.reserveCapacity(n)
|
|
188
|
+
for (i, pair) in sorted.enumerated() {
|
|
189
|
+
if i >= n { break }
|
|
190
|
+
trimmed[pair.key] = pair.value
|
|
191
|
+
}
|
|
192
|
+
measureCache[fontKey] = trimmed
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// MARK: - Font Resolution
|
|
197
|
+
|
|
198
|
+
/// Build a cache key from a font descriptor dictionary.
|
|
199
|
+
private func fontKey(_ desc: [String: Any]) -> String {
|
|
200
|
+
let family = (desc["fontFamily"] as? String) ?? "System"
|
|
201
|
+
let size = desc["fontSize"] as? Double ?? 14.0
|
|
202
|
+
let weight = (desc["fontWeight"] as? String) ?? "400"
|
|
203
|
+
let style = (desc["fontStyle"] as? String) ?? "normal"
|
|
204
|
+
return "\(family)_\(size)_\(weight)_\(style)"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// Resolve a font descriptor to a UIFont, using the font cache.
|
|
208
|
+
private func resolveFont(_ desc: [String: Any]) -> UIFont {
|
|
209
|
+
let key = fontKey(desc)
|
|
210
|
+
if let cached = fontCache[key] {
|
|
211
|
+
return cached
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let family = (desc["fontFamily"] as? String) ?? "System"
|
|
215
|
+
let size = CGFloat(desc["fontSize"] as? Double ?? 14.0)
|
|
216
|
+
let weightStr = (desc["fontWeight"] as? String) ?? "400"
|
|
217
|
+
let style = (desc["fontStyle"] as? String) ?? "normal"
|
|
218
|
+
|
|
219
|
+
let uiWeight = mapFontWeight(weightStr)
|
|
220
|
+
var font: UIFont
|
|
221
|
+
|
|
222
|
+
if family == "System" || family == "system" {
|
|
223
|
+
font = UIFont.systemFont(ofSize: size, weight: uiWeight)
|
|
224
|
+
} else {
|
|
225
|
+
// Try to create the font by family name
|
|
226
|
+
if let descriptorFont = UIFont(name: family, size: size) {
|
|
227
|
+
font = descriptorFont
|
|
228
|
+
} else {
|
|
229
|
+
// Fallback: use system font with the requested weight
|
|
230
|
+
font = UIFont.systemFont(ofSize: size, weight: uiWeight)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Apply bold weight via descriptor if needed
|
|
235
|
+
if uiWeight == .bold || uiWeight == .semibold || uiWeight == .heavy || uiWeight == .black {
|
|
236
|
+
if let descriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) {
|
|
237
|
+
font = UIFont(descriptor: descriptor, size: size)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Apply italic via symbolic trait
|
|
242
|
+
if style == "italic" {
|
|
243
|
+
if let descriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) {
|
|
244
|
+
font = UIFont(descriptor: descriptor, size: size)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
fontCache[key] = font
|
|
249
|
+
return font
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/// Map CSS-style font weight string to UIFont.Weight.
|
|
253
|
+
private func mapFontWeight(_ weight: String) -> UIFont.Weight {
|
|
254
|
+
switch weight {
|
|
255
|
+
case "100": return .ultraLight
|
|
256
|
+
case "200": return .thin
|
|
257
|
+
case "300": return .light
|
|
258
|
+
case "400", "normal": return .regular
|
|
259
|
+
case "500": return .medium
|
|
260
|
+
case "600": return .semibold
|
|
261
|
+
case "700", "bold": return .bold
|
|
262
|
+
case "800": return .heavy
|
|
263
|
+
case "900": return .black
|
|
264
|
+
default: return .regular
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// MARK: - Segmentation & Measurement
|
|
269
|
+
|
|
270
|
+
/// Core implementation: segment text and measure each segment's width.
|
|
271
|
+
private func performSegmentAndMeasure(
|
|
272
|
+
text: String,
|
|
273
|
+
font: UIFont,
|
|
274
|
+
fontKey: String,
|
|
275
|
+
whiteSpace: String,
|
|
276
|
+
locale: String?
|
|
277
|
+
) -> [String: Any] {
|
|
278
|
+
if text.isEmpty {
|
|
279
|
+
return [
|
|
280
|
+
"segments": [String](),
|
|
281
|
+
"isWordLike": [Bool](),
|
|
282
|
+
"widths": [Double]()
|
|
283
|
+
]
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let rawSegments = segmentText(text, whiteSpace: whiteSpace, locale: locale)
|
|
287
|
+
|
|
288
|
+
var segments: [String] = []
|
|
289
|
+
var isWordLike: [Bool] = []
|
|
290
|
+
var widths: [Double] = []
|
|
291
|
+
|
|
292
|
+
segments.reserveCapacity(rawSegments.count)
|
|
293
|
+
isWordLike.reserveCapacity(rawSegments.count)
|
|
294
|
+
widths.reserveCapacity(rawSegments.count)
|
|
295
|
+
|
|
296
|
+
// Ensure font key has a cache bucket
|
|
297
|
+
if measureCache[fontKey] == nil {
|
|
298
|
+
measureCache[fontKey] = [:]
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for segment in rawSegments {
|
|
302
|
+
segments.append(segment)
|
|
303
|
+
isWordLike.append(classifyWordLike(segment))
|
|
304
|
+
|
|
305
|
+
// Check measurement cache
|
|
306
|
+
if var entry = measureCache[fontKey]?[segment] {
|
|
307
|
+
entry.hits += 1
|
|
308
|
+
measureCache[fontKey]?[segment] = entry
|
|
309
|
+
widths.append(entry.width)
|
|
310
|
+
} else {
|
|
311
|
+
// Measure the segment using CTLine (Core Text) — this is what
|
|
312
|
+
// RN Text's underlying layout engine uses for glyph measurement
|
|
313
|
+
let attrStr = NSAttributedString(string: segment, attributes: [.font: font])
|
|
314
|
+
let ctLine = CTLineCreateWithAttributedString(attrStr)
|
|
315
|
+
let width = Double(CTLineGetTypographicBounds(ctLine, nil, nil, nil))
|
|
316
|
+
measureCache[fontKey]?[segment] = CacheEntry(width: width, hits: 1)
|
|
317
|
+
widths.append(width)
|
|
318
|
+
|
|
319
|
+
// Check if we need LRU eviction
|
|
320
|
+
if let count = measureCache[fontKey]?.count, count > maxCacheSize {
|
|
321
|
+
evictLRU(fontKey: fontKey)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return [
|
|
327
|
+
"segments": segments,
|
|
328
|
+
"isWordLike": isWordLike,
|
|
329
|
+
"widths": widths
|
|
330
|
+
]
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/// Segment text using CFStringTokenizer.
|
|
334
|
+
/// In pre-wrap mode, whitespace characters are split into individual segments.
|
|
335
|
+
/// In normal mode, whitespace runs are collapsed into a single space.
|
|
336
|
+
private func segmentText(_ text: String, whiteSpace: String, locale: String?) -> [String] {
|
|
337
|
+
let cfText = text as CFString
|
|
338
|
+
let fullRange = CFRangeMake(0, CFStringGetLength(cfText))
|
|
339
|
+
|
|
340
|
+
// Set up locale for tokenizer
|
|
341
|
+
let cfLocale: CFLocale?
|
|
342
|
+
if let locale = locale {
|
|
343
|
+
let loc = Locale(identifier: locale)
|
|
344
|
+
cfLocale = loc as CFLocale
|
|
345
|
+
} else {
|
|
346
|
+
cfLocale = CFLocaleCopyCurrent()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let tokenizer = CFStringTokenizerCreate(
|
|
350
|
+
nil,
|
|
351
|
+
cfText,
|
|
352
|
+
fullRange,
|
|
353
|
+
kCFStringTokenizerUnitWordBoundary,
|
|
354
|
+
cfLocale
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
var segments: [String] = []
|
|
358
|
+
let nsText = text as NSString
|
|
359
|
+
var currentPos: CFIndex = 0
|
|
360
|
+
let length = CFStringGetLength(cfText)
|
|
361
|
+
|
|
362
|
+
// Walk through all tokens
|
|
363
|
+
var tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer)
|
|
364
|
+
|
|
365
|
+
while currentPos < length {
|
|
366
|
+
if tokenType != CFStringTokenizerTokenType(rawValue: 0) {
|
|
367
|
+
let tokenRange = CFStringTokenizerGetCurrentTokenRange(tokenizer)
|
|
368
|
+
|
|
369
|
+
// Handle any gap before this token (whitespace between tokens)
|
|
370
|
+
if tokenRange.location > currentPos {
|
|
371
|
+
let gapRange = NSRange(location: currentPos, length: tokenRange.location - currentPos)
|
|
372
|
+
let gap = nsText.substring(with: gapRange)
|
|
373
|
+
let whitespaceSegments = processWhitespace(gap, mode: whiteSpace)
|
|
374
|
+
segments.append(contentsOf: whitespaceSegments)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Add the token itself
|
|
378
|
+
let tokenNSRange = NSRange(location: tokenRange.location, length: tokenRange.length)
|
|
379
|
+
let token = nsText.substring(with: tokenNSRange)
|
|
380
|
+
segments.append(token)
|
|
381
|
+
|
|
382
|
+
currentPos = tokenRange.location + tokenRange.length
|
|
383
|
+
tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer)
|
|
384
|
+
} else {
|
|
385
|
+
// No more tokens; process remaining text as whitespace/trailing content
|
|
386
|
+
let remainingRange = NSRange(location: currentPos, length: length - currentPos)
|
|
387
|
+
let remaining = nsText.substring(with: remainingRange)
|
|
388
|
+
let whitespaceSegments = processWhitespace(remaining, mode: whiteSpace)
|
|
389
|
+
segments.append(contentsOf: whitespaceSegments)
|
|
390
|
+
currentPos = length
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return segments
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/// Process a whitespace (or non-token gap) string based on white-space mode.
|
|
398
|
+
/// - normal: collapse runs of whitespace to a single space " ".
|
|
399
|
+
/// - pre-wrap: split each whitespace character into its own segment.
|
|
400
|
+
private func processWhitespace(_ gap: String, mode: String) -> [String] {
|
|
401
|
+
if gap.isEmpty { return [] }
|
|
402
|
+
|
|
403
|
+
if mode == "pre-wrap" {
|
|
404
|
+
// Each character becomes its own segment
|
|
405
|
+
return gap.map { String($0) }
|
|
406
|
+
} else {
|
|
407
|
+
// Normal mode: collapse any whitespace run to a single space
|
|
408
|
+
// But if the gap contains no whitespace, return it as-is
|
|
409
|
+
let trimmed = gap.replacingOccurrences(
|
|
410
|
+
of: "\\s+",
|
|
411
|
+
with: " ",
|
|
412
|
+
options: .regularExpression
|
|
413
|
+
)
|
|
414
|
+
if trimmed.isEmpty { return [] }
|
|
415
|
+
return [trimmed]
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/// Classify whether a segment is "word-like" (contains at least one alphanumeric/letter character).
|
|
420
|
+
private func classifyWordLike(_ segment: String) -> Bool {
|
|
421
|
+
for scalar in segment.unicodeScalars {
|
|
422
|
+
if CharacterSet.alphanumerics.contains(scalar) {
|
|
423
|
+
return true
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return false
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// MARK: - LRU Eviction
|
|
430
|
+
|
|
431
|
+
/// Evict the least-hit entries from a font's measure cache, keeping the top maxCacheSize * 0.8 entries.
|
|
432
|
+
private func evictLRU(fontKey: String) {
|
|
433
|
+
guard let entries = measureCache[fontKey] else { return }
|
|
434
|
+
let keepCount = Int(Double(maxCacheSize) * 0.8)
|
|
435
|
+
let sorted = entries.sorted { $0.value.hits > $1.value.hits }
|
|
436
|
+
var trimmed: [String: CacheEntry] = [:]
|
|
437
|
+
trimmed.reserveCapacity(keepCount)
|
|
438
|
+
for (i, pair) in sorted.enumerated() {
|
|
439
|
+
if i >= keepCount { break }
|
|
440
|
+
trimmed[pair.key] = pair.value
|
|
441
|
+
}
|
|
442
|
+
measureCache[fontKey] = trimmed
|
|
443
|
+
}
|
|
444
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "expo-pretext",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DOM-free multiline text height prediction for React Native. Port of Pretext.",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Juba Kitiashvili",
|
|
9
|
+
"homepage": "https://github.com/JubaKitiashvili/expo-pretext#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/JubaKitiashvili/expo-pretext"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/JubaKitiashvili/expo-pretext/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"react-native",
|
|
19
|
+
"expo",
|
|
20
|
+
"text-measurement",
|
|
21
|
+
"text-layout",
|
|
22
|
+
"text-height",
|
|
23
|
+
"virtualization",
|
|
24
|
+
"flashlist",
|
|
25
|
+
"pretext",
|
|
26
|
+
"ai-chat",
|
|
27
|
+
"streaming"
|
|
28
|
+
],
|
|
29
|
+
"files": [
|
|
30
|
+
"src/",
|
|
31
|
+
"ios/",
|
|
32
|
+
"android/",
|
|
33
|
+
"expo-module.config.json",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"CHANGELOG.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "bun test src/__tests__/",
|
|
40
|
+
"test:all": "bun test",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"prepublishOnly": "bun test src/__tests__/"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18.0.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"expo": ">=52.0.0",
|
|
49
|
+
"react": ">=18.0.0",
|
|
50
|
+
"react-native": ">=0.76.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/react": "^19.2.14",
|
|
54
|
+
"@types/react-native": "^0.73.0",
|
|
55
|
+
"expo": "~53.0.0",
|
|
56
|
+
"expo-module-scripts": "~4.0.0",
|
|
57
|
+
"typescript": "~5.8.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// src/ExpoPretext.ts
|
|
2
|
+
// JS binding to the native Expo module.
|
|
3
|
+
// All native calls go through this file.
|
|
4
|
+
|
|
5
|
+
import { NativeModule, requireNativeModule } from 'expo-modules-core'
|
|
6
|
+
import type { FontDescriptor, NativeSegmentResult } from './types'
|
|
7
|
+
|
|
8
|
+
type MeasureNativeOptions = {
|
|
9
|
+
whiteSpace?: string
|
|
10
|
+
locale?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ExpoPretextNativeModule extends InstanceType<typeof NativeModule> {
|
|
14
|
+
segmentAndMeasure(
|
|
15
|
+
text: string,
|
|
16
|
+
font: FontDescriptor,
|
|
17
|
+
options?: MeasureNativeOptions
|
|
18
|
+
): NativeSegmentResult
|
|
19
|
+
|
|
20
|
+
batchSegmentAndMeasure(
|
|
21
|
+
texts: string[],
|
|
22
|
+
font: FontDescriptor,
|
|
23
|
+
options?: MeasureNativeOptions
|
|
24
|
+
): NativeSegmentResult[]
|
|
25
|
+
|
|
26
|
+
measureGraphemeWidths(
|
|
27
|
+
segment: string,
|
|
28
|
+
font: FontDescriptor
|
|
29
|
+
): number[]
|
|
30
|
+
|
|
31
|
+
remeasureMerged(
|
|
32
|
+
segments: string[],
|
|
33
|
+
font: FontDescriptor
|
|
34
|
+
): number[]
|
|
35
|
+
|
|
36
|
+
segmentAndMeasureAsync(
|
|
37
|
+
text: string,
|
|
38
|
+
font: FontDescriptor,
|
|
39
|
+
options?: MeasureNativeOptions
|
|
40
|
+
): Promise<NativeSegmentResult>
|
|
41
|
+
|
|
42
|
+
measureTextHeight(
|
|
43
|
+
text: string,
|
|
44
|
+
font: FontDescriptor,
|
|
45
|
+
maxWidth: number,
|
|
46
|
+
lineHeight: number
|
|
47
|
+
): { height: number; lineCount: number }
|
|
48
|
+
|
|
49
|
+
clearNativeCache(): void
|
|
50
|
+
|
|
51
|
+
setNativeCacheSize(size: number): void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let nativeModule: ExpoPretextNativeModule | null = null
|
|
55
|
+
|
|
56
|
+
export function getNativeModule(): ExpoPretextNativeModule | null {
|
|
57
|
+
if (nativeModule !== null) return nativeModule
|
|
58
|
+
try {
|
|
59
|
+
nativeModule = requireNativeModule<ExpoPretextNativeModule>('ExpoPretext')
|
|
60
|
+
return nativeModule
|
|
61
|
+
} catch {
|
|
62
|
+
if (__DEV__) {
|
|
63
|
+
console.warn(
|
|
64
|
+
'[expo-pretext] Native module not available. ' +
|
|
65
|
+
'Using JS estimates. Use a development build for accurate measurements.'
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
getCachedWidth,
|
|
4
|
+
setCachedWidth,
|
|
5
|
+
cacheNativeResult,
|
|
6
|
+
tryResolveAllFromCache,
|
|
7
|
+
clearJSCache,
|
|
8
|
+
} from '../cache'
|
|
9
|
+
|
|
10
|
+
describe('JS-side width cache', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
clearJSCache()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('set and get single width', () => {
|
|
16
|
+
setCachedWidth('Inter_16_400_normal', 'Hello', 42.5)
|
|
17
|
+
expect(getCachedWidth('Inter_16_400_normal', 'Hello')).toBe(42.5)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('returns undefined for cache miss', () => {
|
|
21
|
+
expect(getCachedWidth('Inter_16_400_normal', 'missing')).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('returns undefined for unknown font key', () => {
|
|
25
|
+
setCachedWidth('Inter_16_400_normal', 'Hello', 42.5)
|
|
26
|
+
expect(getCachedWidth('Arial_16_400_normal', 'Hello')).toBeUndefined()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('cacheNativeResult stores all segments', () => {
|
|
30
|
+
cacheNativeResult('Inter_16_400_normal', ['Hello', ' ', 'world'], [42.5, 4.2, 38.1])
|
|
31
|
+
expect(getCachedWidth('Inter_16_400_normal', 'Hello')).toBe(42.5)
|
|
32
|
+
expect(getCachedWidth('Inter_16_400_normal', ' ')).toBe(4.2)
|
|
33
|
+
expect(getCachedWidth('Inter_16_400_normal', 'world')).toBe(38.1)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('tryResolveAllFromCache returns widths when all cached', () => {
|
|
37
|
+
cacheNativeResult('Inter_16_400_normal', ['Hello', ' ', 'world'], [42.5, 4.2, 38.1])
|
|
38
|
+
const result = tryResolveAllFromCache('Inter_16_400_normal', ['Hello', ' ', 'world'])
|
|
39
|
+
expect(result).toEqual([42.5, 4.2, 38.1])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('tryResolveAllFromCache returns null on partial miss', () => {
|
|
43
|
+
cacheNativeResult('Inter_16_400_normal', ['Hello', ' '], [42.5, 4.2])
|
|
44
|
+
const result = tryResolveAllFromCache('Inter_16_400_normal', ['Hello', ' ', 'world'])
|
|
45
|
+
expect(result).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('tryResolveAllFromCache returns null for unknown font', () => {
|
|
49
|
+
const result = tryResolveAllFromCache('Unknown_16_400_normal', ['Hello'])
|
|
50
|
+
expect(result).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('clearJSCache clears everything', () => {
|
|
54
|
+
cacheNativeResult('Inter_16_400_normal', ['Hello'], [42.5])
|
|
55
|
+
clearJSCache()
|
|
56
|
+
expect(getCachedWidth('Inter_16_400_normal', 'Hello')).toBeUndefined()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('multiple font keys are independent', () => {
|
|
60
|
+
setCachedWidth('Inter_16_400_normal', 'Hello', 42.5)
|
|
61
|
+
setCachedWidth('Inter_16_700_normal', 'Hello', 44.0)
|
|
62
|
+
expect(getCachedWidth('Inter_16_400_normal', 'Hello')).toBe(42.5)
|
|
63
|
+
expect(getCachedWidth('Inter_16_700_normal', 'Hello')).toBe(44.0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('overwriting a cached value', () => {
|
|
67
|
+
setCachedWidth('Inter_16_400_normal', 'Hello', 42.5)
|
|
68
|
+
setCachedWidth('Inter_16_400_normal', 'Hello', 43.0)
|
|
69
|
+
expect(getCachedWidth('Inter_16_400_normal', 'Hello')).toBe(43.0)
|
|
70
|
+
})
|
|
71
|
+
})
|