@openeditor/react-native-prose-editor 0.0.7 β†’ 0.0.9

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.
Files changed (68) hide show
  1. package/android/build.gradle +35 -0
  2. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
  4. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
  5. package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
  9. package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
  10. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
  12. package/dist/EditorToolbar.d.ts +1 -1
  13. package/dist/EditorToolbar.js +19 -0
  14. package/dist/NativeEditorBridge.d.ts +62 -35
  15. package/dist/NativeEditorBridge.js +122 -79
  16. package/dist/NativeProseViewer.d.ts +13 -34
  17. package/dist/NativeProseViewer.js +40 -465
  18. package/dist/NativeRichTextEditor.d.ts +10 -1
  19. package/dist/NativeRichTextEditor.js +72 -34
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +2 -1
  22. package/dist/schemas.d.ts +11 -0
  23. package/dist/schemas.js +149 -0
  24. package/dist/useNativeEditor.d.ts +3 -3
  25. package/dist/useNativeEditor.js +3 -3
  26. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  27. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  28. package/ios/Generated_editor_core.swift +97 -2
  29. package/ios/NativeBlockEditorSurface.swift +1445 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +31 -46
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +352 -786
  35. package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
  36. package/package.json +1 -1
  37. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  38. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  39. package/rust/android/x86_64/libeditor_core.so +0 -0
  40. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
  41. package/android/src/androidTest/AndroidManifest.xml +0 -8
  42. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
  43. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
  44. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
  45. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
  46. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
  47. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
  48. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
  49. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
  50. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
  51. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
  52. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
  53. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
  54. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
  55. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
  56. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
  57. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
  58. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
  59. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
  60. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
  61. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
  62. package/ios/EditorLayoutManager.swift +0 -774
  63. package/ios/NativeProseViewerExpoView.swift +0 -276
  64. package/ios/RenderBridge.swift +0 -1708
  65. package/ios/Tests/NativePerformanceTests.swift +0 -687
  66. package/ios/Tests/PositionBridgeTests.swift +0 -706
  67. package/ios/Tests/RenderBridgeTests.swift +0 -2236
  68. package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
@@ -1,706 +0,0 @@
1
- import XCTest
2
-
3
- // MARK: - PositionBridge Tests
4
-
5
- final class PositionBridgeTests: XCTestCase {
6
-
7
- private func makeTextView(with attributedText: NSAttributedString) -> UITextView {
8
- let layoutManager = EditorLayoutManager()
9
- let textContainer = NSTextContainer(size: .zero)
10
- let textStorage = NSTextStorage(attributedString: attributedText)
11
- layoutManager.addTextContainer(textContainer)
12
- textStorage.addLayoutManager(layoutManager)
13
- return UITextView(frame: .zero, textContainer: textContainer)
14
- }
15
-
16
- private func assertConversionsMatch(
17
- _ patched: UITextView,
18
- _ rebuilt: UITextView,
19
- file: StaticString = #filePath,
20
- line: UInt = #line
21
- ) {
22
- XCTAssertEqual(patched.textStorage.length, rebuilt.textStorage.length, file: file, line: line)
23
- for offset in 0...patched.textStorage.length {
24
- XCTAssertEqual(
25
- PositionBridge.utf16OffsetToScalar(offset, in: patched),
26
- PositionBridge.utf16OffsetToScalar(offset, in: rebuilt),
27
- "UTF-16 offset \(offset) should match a fresh conversion-table rebuild",
28
- file: file,
29
- line: line
30
- )
31
- }
32
-
33
- let maxScalar = PositionBridge.utf16OffsetToScalar(patched.textStorage.length, in: rebuilt)
34
- XCTAssertEqual(
35
- maxScalar,
36
- PositionBridge.utf16OffsetToScalar(patched.textStorage.length, in: patched),
37
- file: file,
38
- line: line
39
- )
40
- for scalar in 0...Int(maxScalar) {
41
- XCTAssertEqual(
42
- PositionBridge.scalarToUtf16Offset(UInt32(scalar), in: patched),
43
- PositionBridge.scalarToUtf16Offset(UInt32(scalar), in: rebuilt),
44
- "Scalar \(scalar) should map to the same UTF-16 offset after cache patching",
45
- file: file,
46
- line: line
47
- )
48
- }
49
- }
50
-
51
- // MARK: - UTF-16 -> Scalar: ASCII
52
-
53
- /// ASCII characters are 1 UTF-16 code unit = 1 scalar each.
54
- func testUtf16ToScalar_asciiOnly() {
55
- let text = "Hello"
56
- // "Hello" = 5 UTF-16 code units = 5 scalars
57
- XCTAssertEqual(
58
- PositionBridge.utf16OffsetToScalar(0, in: text), 0,
59
- "Offset 0 should map to scalar 0"
60
- )
61
- XCTAssertEqual(
62
- PositionBridge.utf16OffsetToScalar(1, in: text), 1,
63
- "Offset 1 in ASCII should map to scalar 1"
64
- )
65
- XCTAssertEqual(
66
- PositionBridge.utf16OffsetToScalar(5, in: text), 5,
67
- "Offset 5 (end of 'Hello') should map to scalar 5"
68
- )
69
- }
70
-
71
- /// Empty string edge case.
72
- func testUtf16ToScalar_emptyString() {
73
- let text = ""
74
- XCTAssertEqual(
75
- PositionBridge.utf16OffsetToScalar(0, in: text), 0,
76
- "Empty string at offset 0 should return scalar 0"
77
- )
78
- }
79
-
80
- func testUtf16ToScalar_listParagraphAddsVirtualMarkerScalars() {
81
- let json = """
82
- [
83
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
84
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
85
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
86
- {"type": "textRun", "text": "Item", "marks": []},
87
- {"type": "blockEnd"},
88
- {"type": "blockEnd"}
89
- ]
90
- """
91
- let attributed = RenderBridge.renderElements(
92
- fromJSON: json,
93
- baseFont: .systemFont(ofSize: 16),
94
- textColor: .label
95
- )
96
- let textView = makeTextView(with: attributed)
97
-
98
- XCTAssertEqual(PositionBridge.utf16OffsetToScalar(0, in: textView), 2)
99
- XCTAssertEqual(PositionBridge.utf16OffsetToScalar(4, in: textView), 6)
100
- }
101
-
102
- func testScalarToUtf16_listMarkerPrefixMapsToParagraphStart() {
103
- let json = """
104
- [
105
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
106
- "listContext": {"ordered": true, "index": 12, "total": 1, "start": 12, "isFirst": true, "isLast": true}},
107
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
108
- {"type": "textRun", "text": "Item", "marks": []},
109
- {"type": "blockEnd"},
110
- {"type": "blockEnd"}
111
- ]
112
- """
113
- let attributed = RenderBridge.renderElements(
114
- fromJSON: json,
115
- baseFont: .systemFont(ofSize: 16),
116
- textColor: .label
117
- )
118
- let textView = makeTextView(with: attributed)
119
-
120
- XCTAssertEqual(PositionBridge.scalarToUtf16Offset(1, in: textView), 0)
121
- XCTAssertEqual(PositionBridge.scalarToUtf16Offset(4, in: textView), 0)
122
- XCTAssertEqual(PositionBridge.scalarToUtf16Offset(5, in: textView), 1)
123
- }
124
-
125
- func testUtf16ToScalar_secondParagraphInListItemDoesNotAddAnotherVirtualMarker() {
126
- let json = """
127
- [
128
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
129
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
130
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
131
- {"type": "textRun", "text": "A", "marks": []},
132
- {"type": "blockEnd"},
133
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
134
- {"type": "textRun", "text": "\\u200B", "marks": []},
135
- {"type": "blockEnd"},
136
- {"type": "blockEnd"}
137
- ]
138
- """
139
- let attributed = RenderBridge.renderElements(
140
- fromJSON: json,
141
- baseFont: .systemFont(ofSize: 16),
142
- textColor: .label
143
- )
144
- let textView = makeTextView(with: attributed)
145
-
146
- XCTAssertEqual(
147
- PositionBridge.utf16OffsetToScalar(2, in: textView), 4,
148
- "Only the first paragraph in a list item should contribute virtual marker scalars"
149
- )
150
- }
151
-
152
- func testUtf16ToScalar_hardBreakInsideListParagraphDoesNotAddAnotherVirtualMarker() {
153
- let json = """
154
- [
155
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
156
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
157
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
158
- {"type": "textRun", "text": "A", "marks": []},
159
- {"type": "voidInline", "nodeType": "hardBreak", "docPos": 4},
160
- {"type": "textRun", "text": "B", "marks": []},
161
- {"type": "blockEnd"},
162
- {"type": "blockEnd"}
163
- ]
164
- """
165
- let attributed = RenderBridge.renderElements(
166
- fromJSON: json,
167
- baseFont: .systemFont(ofSize: 16),
168
- textColor: .label
169
- )
170
- let textView = makeTextView(with: attributed)
171
-
172
- XCTAssertEqual(
173
- PositionBridge.utf16OffsetToScalar(2, in: textView), 4,
174
- "A hardBreak inside a list paragraph should not create a second virtual marker"
175
- )
176
- XCTAssertTrue(
177
- EditorLayoutManager.isParagraphStartCreatedByHardBreak(2, in: textView.textStorage),
178
- "The visual line after a hardBreak should be recognized as a synthetic paragraph start"
179
- )
180
- }
181
-
182
- func testUtf16ToScalar_taskListParagraphContributesVirtualCheckboxMarker() {
183
- let json = """
184
- [
185
- {"type": "blockStart", "nodeType": "taskItem", "depth": 1,
186
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true, "kind": "task", "checked": false}},
187
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
188
- {"type": "textRun", "text": "A", "marks": []},
189
- {"type": "blockEnd"},
190
- {"type": "blockEnd"}
191
- ]
192
- """
193
- let attributed = RenderBridge.renderElements(
194
- fromJSON: json,
195
- baseFont: .systemFont(ofSize: 16),
196
- textColor: .label
197
- )
198
- let textView = makeTextView(with: attributed)
199
-
200
- XCTAssertEqual(
201
- PositionBridge.utf16OffsetToScalar(0, in: textView), 2,
202
- "A task item paragraph should contribute its checkbox marker as virtual scalar prefix"
203
- )
204
- XCTAssertEqual(
205
- PositionBridge.utf16OffsetToScalar(1, in: textView), 3,
206
- "Text inside a task item should be offset by the checkbox marker length"
207
- )
208
- }
209
-
210
- func testUtf16ToScalar_trailingHardBreakPlaceholderDoesNotCountAsScalar() {
211
- let json = """
212
- [
213
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
214
- {"type": "textRun", "text": "A", "marks": []},
215
- {"type": "voidInline", "nodeType": "hardBreak", "docPos": 2},
216
- {"type": "blockEnd"}
217
- ]
218
- """
219
- let attributed = RenderBridge.renderElements(
220
- fromJSON: json,
221
- baseFont: .systemFont(ofSize: 16),
222
- textColor: .label
223
- )
224
- let textView = makeTextView(with: attributed)
225
-
226
- XCTAssertEqual(textView.text, "A\n\u{200B}")
227
- XCTAssertEqual(PositionBridge.utf16OffsetToScalar(2, in: textView), 2)
228
- XCTAssertEqual(
229
- PositionBridge.utf16OffsetToScalar(3, in: textView),
230
- 2,
231
- "The synthetic trailing hardBreak placeholder should not count as content"
232
- )
233
- }
234
-
235
- func testApplyAttributedPatch_handlesSyntheticPlaceholderAdjustment() {
236
- let initialJSON = """
237
- [
238
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
239
- {"type": "textRun", "text": "A", "marks": []},
240
- {"type": "blockEnd"}
241
- ]
242
- """
243
- let replacementJSON = """
244
- [
245
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
246
- {"type": "textRun", "text": "A", "marks": []},
247
- {"type": "voidInline", "nodeType": "hardBreak", "docPos": 2},
248
- {"type": "blockEnd"}
249
- ]
250
- """
251
-
252
- let initial = RenderBridge.renderElements(
253
- fromJSON: initialJSON,
254
- baseFont: .systemFont(ofSize: 16),
255
- textColor: .label
256
- )
257
- let replacement = RenderBridge.renderElements(
258
- fromJSON: replacementJSON,
259
- baseFont: .systemFont(ofSize: 16),
260
- textColor: .label
261
- )
262
-
263
- let patched = makeTextView(with: initial)
264
- _ = PositionBridge.utf16OffsetToScalar(patched.textStorage.length, in: patched)
265
- let replaceRange = NSRange(location: 0, length: patched.textStorage.length)
266
- patched.textStorage.beginEditing()
267
- patched.textStorage.replaceCharacters(in: replaceRange, with: replacement)
268
- patched.textStorage.endEditing()
269
-
270
- XCTAssertTrue(
271
- PositionBridge.applyAttributedPatchIfPossible(
272
- for: patched,
273
- replaceRange: replaceRange,
274
- replacement: replacement
275
- )
276
- )
277
-
278
- let rebuilt = makeTextView(with: replacement)
279
- assertConversionsMatch(patched, rebuilt)
280
- }
281
-
282
- func testApplyAttributedPatch_handlesVirtualListMarkerAdjustment() {
283
- let initialJSON = """
284
- [
285
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
286
- {"type": "textRun", "text": "Item", "marks": []},
287
- {"type": "blockEnd"}
288
- ]
289
- """
290
- let replacementJSON = """
291
- [
292
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
293
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
294
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
295
- {"type": "textRun", "text": "Item", "marks": []},
296
- {"type": "blockEnd"},
297
- {"type": "blockEnd"}
298
- ]
299
- """
300
-
301
- let initial = RenderBridge.renderElements(
302
- fromJSON: initialJSON,
303
- baseFont: .systemFont(ofSize: 16),
304
- textColor: .label
305
- )
306
- let replacement = RenderBridge.renderElements(
307
- fromJSON: replacementJSON,
308
- baseFont: .systemFont(ofSize: 16),
309
- textColor: .label
310
- )
311
-
312
- let patched = makeTextView(with: initial)
313
- _ = PositionBridge.utf16OffsetToScalar(patched.textStorage.length, in: patched)
314
- let replaceRange = NSRange(location: 0, length: patched.textStorage.length)
315
- patched.textStorage.beginEditing()
316
- patched.textStorage.replaceCharacters(in: replaceRange, with: replacement)
317
- patched.textStorage.endEditing()
318
-
319
- XCTAssertTrue(
320
- PositionBridge.applyAttributedPatchIfPossible(
321
- for: patched,
322
- replaceRange: replaceRange,
323
- replacement: replacement
324
- )
325
- )
326
-
327
- let rebuilt = makeTextView(with: replacement)
328
- assertConversionsMatch(patched, rebuilt)
329
- }
330
-
331
- func testApplyPlainTextPatch_handlesPartialPlainTextReplacement() {
332
- let initial = NSAttributedString(
333
- string: "Alpha Beta Gamma",
334
- attributes: [.font: UIFont.systemFont(ofSize: 16)]
335
- )
336
- let replacementText = "Beta\n"
337
- let replaceRange = NSRange(location: 6, length: 4)
338
-
339
- let patched = makeTextView(with: initial)
340
- _ = PositionBridge.utf16OffsetToScalar(patched.textStorage.length, in: patched)
341
- patched.textStorage.beginEditing()
342
- patched.textStorage.replaceCharacters(
343
- in: replaceRange,
344
- with: NSAttributedString(
345
- string: replacementText,
346
- attributes: [.font: UIFont.systemFont(ofSize: 16)]
347
- )
348
- )
349
- patched.textStorage.endEditing()
350
-
351
- XCTAssertTrue(
352
- PositionBridge.applyPlainTextPatchIfPossible(
353
- for: patched,
354
- replaceRange: replaceRange,
355
- replacementText: replacementText
356
- )
357
- )
358
-
359
- let rebuilt = makeTextView(
360
- with: NSAttributedString(
361
- string: "Alpha Beta\n Gamma",
362
- attributes: [.font: UIFont.systemFont(ofSize: 16)]
363
- )
364
- )
365
- assertConversionsMatch(patched, rebuilt)
366
- }
367
-
368
- // MARK: - UTF-16 -> Scalar: BMP Emoji (1 scalar, 1 UTF-16)
369
-
370
- /// Simple emoji in the BMP (e.g. U+263A, white smiley face) are 1 UTF-16 code unit.
371
- func testUtf16ToScalar_bmpCharacters() {
372
- // U+00E9 (e-acute) is 1 UTF-16 code unit, 1 scalar
373
- let text = "caf\u{00E9}" // "cafe" with accent
374
- XCTAssertEqual(
375
- PositionBridge.utf16OffsetToScalar(4, in: text), 4,
376
- "BMP character: UTF-16 offset 4 should be scalar 4"
377
- )
378
- }
379
-
380
- // MARK: - UTF-16 -> Scalar: Surrogate Pairs (1 scalar, 2 UTF-16)
381
-
382
- /// Characters above U+FFFF (supplementary plane) are encoded as
383
- /// surrogate pairs in UTF-16: 2 code units per scalar.
384
- func testUtf16ToScalar_surrogatePair() {
385
- // U+1F600 (grinning face) = 2 UTF-16 code units, 1 scalar
386
- let text = "A\u{1F600}B"
387
- // UTF-16: A(1) + U+1F600(2) + B(1) = 4 code units
388
- // Scalars: A(1) + U+1F600(1) + B(1) = 3 scalars
389
-
390
- XCTAssertEqual(
391
- PositionBridge.utf16OffsetToScalar(0, in: text), 0,
392
- "Before 'A'"
393
- )
394
- XCTAssertEqual(
395
- PositionBridge.utf16OffsetToScalar(1, in: text), 1,
396
- "After 'A', before emoji"
397
- )
398
- XCTAssertEqual(
399
- PositionBridge.utf16OffsetToScalar(3, in: text), 2,
400
- "After emoji (UTF-16 offset 3 = scalar 2)"
401
- )
402
- XCTAssertEqual(
403
- PositionBridge.utf16OffsetToScalar(4, in: text), 3,
404
- "After 'B' (end of string)"
405
- )
406
- }
407
-
408
- /// The family emoji πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ is composed of multiple scalars joined by ZWJ.
409
- /// U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466
410
- /// = 7 scalars, 11 UTF-16 code units (4 surrogates + 3 ZWJ)
411
- /// But it renders as 1 grapheme cluster.
412
- func testUtf16ToScalar_familyEmoji() {
413
- let text = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}"
414
- let expectedUtf16Count = 11
415
- let expectedScalarCount: UInt32 = 7
416
-
417
- let nsString = text as NSString
418
- XCTAssertEqual(
419
- nsString.length, expectedUtf16Count,
420
- "Family emoji should be \(expectedUtf16Count) UTF-16 code units"
421
- )
422
- XCTAssertEqual(
423
- UInt32(text.unicodeScalars.count), expectedScalarCount,
424
- "Family emoji should be \(expectedScalarCount) Unicode scalars"
425
- )
426
- XCTAssertEqual(
427
- PositionBridge.utf16OffsetToScalar(expectedUtf16Count, in: text),
428
- expectedScalarCount,
429
- "Full family emoji: UTF-16 end should map to scalar end"
430
- )
431
- }
432
-
433
- // MARK: - UTF-16 -> Scalar: CJK
434
-
435
- /// CJK characters (Chinese/Japanese/Korean) are in the BMP: 1 UTF-16 = 1 scalar.
436
- func testUtf16ToScalar_cjk() {
437
- let text = "\u{4F60}\u{597D}" // δ½ ε₯½
438
- // UTF-16: 2 code units, 2 scalars
439
- XCTAssertEqual(
440
- PositionBridge.utf16OffsetToScalar(1, in: text), 1,
441
- "CJK character at offset 1 should be scalar 1"
442
- )
443
- XCTAssertEqual(
444
- PositionBridge.utf16OffsetToScalar(2, in: text), 2,
445
- "End of CJK string should be scalar 2"
446
- )
447
- }
448
-
449
- /// CJK extension B characters (U+20000+) are in the supplementary plane.
450
- func testUtf16ToScalar_cjkExtensionB() {
451
- // U+20000 (CJK Unified Ideographs Extension B) = 2 UTF-16 code units, 1 scalar
452
- let text = "A\u{20000}B"
453
- // UTF-16: A(1) + U+20000(2) + B(1) = 4
454
- // Scalars: A(1) + U+20000(1) + B(1) = 3
455
- XCTAssertEqual(
456
- PositionBridge.utf16OffsetToScalar(3, in: text), 2,
457
- "After CJK Extension B character"
458
- )
459
- }
460
-
461
- // MARK: - UTF-16 -> Scalar: Mixed Content
462
-
463
- /// Mixed ASCII, emoji, and CJK in one string.
464
- func testUtf16ToScalar_mixedContent() {
465
- let text = "Hi\u{1F600}\u{4F60}\u{597D}!"
466
- // UTF-16: H(1) + i(1) + U+1F600(2) + U+4F60(1) + U+597D(1) + !(1) = 7
467
- // Scalars: H(1) + i(1) + U+1F600(1) + U+4F60(1) + U+597D(1) + !(1) = 6
468
-
469
- XCTAssertEqual(
470
- PositionBridge.utf16OffsetToScalar(2, in: text), 2,
471
- "After 'Hi'"
472
- )
473
- XCTAssertEqual(
474
- PositionBridge.utf16OffsetToScalar(4, in: text), 3,
475
- "After emoji (UTF-16 offset 4 = scalar 3)"
476
- )
477
- XCTAssertEqual(
478
- PositionBridge.utf16OffsetToScalar(7, in: text), 6,
479
- "End of mixed string"
480
- )
481
- }
482
-
483
- // MARK: - Scalar -> UTF-16: ASCII
484
-
485
- func testScalarToUtf16_asciiOnly() {
486
- let text = "Hello"
487
- XCTAssertEqual(
488
- PositionBridge.scalarToUtf16Offset(0, in: text), 0,
489
- "Scalar 0 should map to UTF-16 offset 0"
490
- )
491
- XCTAssertEqual(
492
- PositionBridge.scalarToUtf16Offset(3, in: text), 3,
493
- "Scalar 3 in ASCII should map to UTF-16 offset 3"
494
- )
495
- XCTAssertEqual(
496
- PositionBridge.scalarToUtf16Offset(5, in: text), 5,
497
- "Scalar 5 (end) should map to UTF-16 offset 5"
498
- )
499
- }
500
-
501
- // MARK: - Scalar -> UTF-16: Surrogate Pairs
502
-
503
- func testScalarToUtf16_surrogatePair() {
504
- let text = "A\u{1F600}B"
505
- // Scalar 0 = A -> UTF-16 offset 0
506
- // Scalar 1 = U+1F600 -> UTF-16 offset 1
507
- // Scalar 2 = B -> UTF-16 offset 3 (after 2 UTF-16 code units for emoji)
508
- XCTAssertEqual(
509
- PositionBridge.scalarToUtf16Offset(1, in: text), 1,
510
- "Scalar 1 (emoji start) should be UTF-16 offset 1"
511
- )
512
- XCTAssertEqual(
513
- PositionBridge.scalarToUtf16Offset(2, in: text), 3,
514
- "Scalar 2 (after emoji) should be UTF-16 offset 3"
515
- )
516
- XCTAssertEqual(
517
- PositionBridge.scalarToUtf16Offset(3, in: text), 4,
518
- "Scalar 3 (after B) should be UTF-16 offset 4"
519
- )
520
- }
521
-
522
- // MARK: - Scalar -> UTF-16: Family Emoji
523
-
524
- func testScalarToUtf16_familyEmoji() {
525
- let text = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}"
526
- // 7 scalars, 11 UTF-16 code units
527
- // Scalar 7 (end) should map to UTF-16 offset 11
528
- XCTAssertEqual(
529
- PositionBridge.scalarToUtf16Offset(7, in: text), 11,
530
- "End of family emoji: scalar 7 -> UTF-16 offset 11"
531
- )
532
- }
533
-
534
- // MARK: - Roundtrip Consistency
535
-
536
- /// Verify that utf16->scalar->utf16 roundtrips correctly for various inputs.
537
- func testRoundtrip_utf16ToScalarAndBack() {
538
- let testCases: [(String, String)] = [
539
- ("Hello, world!", "ASCII"),
540
- ("A\u{1F600}B", "surrogate pair"),
541
- ("\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}", "family emoji"),
542
- ("\u{4F60}\u{597D}\u{4E16}\u{754C}", "CJK"),
543
- ("abc\u{1F600}\u{4F60}xyz", "mixed"),
544
- ("\u{0041}\u{0301}", "combining character (A + combining acute)"),
545
- ]
546
-
547
- for (text, label) in testCases {
548
- let nsString = text as NSString
549
- // Test at every valid UTF-16 offset.
550
- for utf16Offset in 0...nsString.length {
551
- let scalar = PositionBridge.utf16OffsetToScalar(utf16Offset, in: text)
552
- let backToUtf16 = PositionBridge.scalarToUtf16Offset(scalar, in: text)
553
- // The roundtrip may not be exact if utf16Offset lands in the middle
554
- // of a surrogate pair. But at surrogate boundaries it should match.
555
- // We verify that the back-converted offset is >= the original
556
- // (snapping forward past any mid-surrogate position).
557
- XCTAssertGreaterThanOrEqual(
558
- backToUtf16, utf16Offset - 1,
559
- "Roundtrip for '\(label)' at UTF-16 offset \(utf16Offset): " +
560
- "scalar=\(scalar), back=\(backToUtf16)"
561
- )
562
- }
563
- }
564
- }
565
-
566
- /// Verify that scalar->utf16->scalar roundtrips exactly.
567
- func testRoundtrip_scalarToUtf16AndBack() {
568
- let testCases: [(String, String)] = [
569
- ("Hello, world!", "ASCII"),
570
- ("A\u{1F600}B", "surrogate pair"),
571
- ("\u{4F60}\u{597D}", "CJK"),
572
- ("abc\u{1F600}\u{4F60}xyz", "mixed"),
573
- ]
574
-
575
- for (text, label) in testCases {
576
- let scalarCount = UInt32(text.unicodeScalars.count)
577
- for scalarOffset in 0...scalarCount {
578
- let utf16 = PositionBridge.scalarToUtf16Offset(scalarOffset, in: text)
579
- let backToScalar = PositionBridge.utf16OffsetToScalar(utf16, in: text)
580
- XCTAssertEqual(
581
- backToScalar, scalarOffset,
582
- "Roundtrip for '\(label)' at scalar \(scalarOffset): " +
583
- "utf16=\(utf16), back=\(backToScalar) - should equal original scalar"
584
- )
585
- }
586
- }
587
- }
588
-
589
- // MARK: - Grapheme Boundary Snapping
590
-
591
- /// Snapping at an already-valid boundary returns the same offset.
592
- func testSnapToGraphemeBoundary_alreadyOnBoundary() {
593
- let text = "Hello"
594
- for i in 0...5 {
595
- XCTAssertEqual(
596
- PositionBridge.snapToGraphemeBoundary(i, in: text), i,
597
- "ASCII offset \(i) is already on a grapheme boundary"
598
- )
599
- }
600
- }
601
-
602
- /// Snapping in the middle of a surrogate pair snaps to the end of the grapheme cluster.
603
- func testSnapToGraphemeBoundary_midSurrogatePair() {
604
- let text = "A\u{1F600}B"
605
- // UTF-16: A(offset 0), emoji high surrogate(offset 1), low surrogate(offset 2), B(offset 3)
606
- // Offset 2 is mid-surrogate. The grapheme cluster for the emoji is offsets 1-2.
607
- // Snapping offset 2 should go to 3 (end of emoji grapheme).
608
- let snapped = PositionBridge.snapToGraphemeBoundary(2, in: text)
609
- XCTAssertEqual(
610
- snapped, 3,
611
- "Mid-surrogate offset 2 should snap to 3 (end of emoji grapheme)"
612
- )
613
- }
614
-
615
- /// Snapping in the middle of a family emoji snaps to the end.
616
- func testSnapToGraphemeBoundary_midFamilyEmoji() {
617
- let text = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}"
618
- // The entire sequence is 1 grapheme cluster = 11 UTF-16 code units.
619
- // Any offset 1..10 should snap to 11.
620
- for midOffset in 1...10 {
621
- let snapped = PositionBridge.snapToGraphemeBoundary(midOffset, in: text)
622
- XCTAssertEqual(
623
- snapped, 11,
624
- "Mid-family-emoji offset \(midOffset) should snap to 11 (end of grapheme)"
625
- )
626
- }
627
- }
628
-
629
- /// Snapping in the middle of a combining character sequence.
630
- func testSnapToGraphemeBoundary_combiningCharacter() {
631
- // "e" + combining acute accent = "e\u{0301}" = "e" visually with accent
632
- // This is 2 UTF-16 code units but 1 grapheme cluster.
633
- let text = "e\u{0301}"
634
- let snapped = PositionBridge.snapToGraphemeBoundary(1, in: text)
635
- XCTAssertEqual(
636
- snapped, 2,
637
- "Between base and combining character should snap to end of grapheme"
638
- )
639
- }
640
-
641
- /// Snapping on empty string always returns 0.
642
- func testSnapToGraphemeBoundary_emptyString() {
643
- XCTAssertEqual(
644
- PositionBridge.snapToGraphemeBoundary(0, in: ""), 0,
645
- "Empty string should always return 0"
646
- )
647
- XCTAssertEqual(
648
- PositionBridge.snapToGraphemeBoundary(5, in: ""), 0,
649
- "Empty string with out-of-range offset should return 0"
650
- )
651
- }
652
-
653
- /// Snapping at the end of string returns the end offset.
654
- func testSnapToGraphemeBoundary_atEnd() {
655
- let text = "Hello"
656
- XCTAssertEqual(
657
- PositionBridge.snapToGraphemeBoundary(5, in: text), 5,
658
- "At end of string should return end offset"
659
- )
660
- }
661
-
662
- /// Snapping with negative offset clamps to 0.
663
- func testSnapToGraphemeBoundary_negativeOffset() {
664
- let text = "Hello"
665
- XCTAssertEqual(
666
- PositionBridge.snapToGraphemeBoundary(-1, in: text), 0,
667
- "Negative offset should clamp to 0"
668
- )
669
- }
670
-
671
- // MARK: - Flag Emoji (Regional Indicators)
672
-
673
- /// Flag emoji are composed of two regional indicator symbols.
674
- /// e.g. πŸ‡ΊπŸ‡Έ = U+1F1FA U+1F1F8 = 4 UTF-16 code units, 2 scalars, 1 grapheme.
675
- func testUtf16ToScalar_flagEmoji() {
676
- let text = "A\u{1F1FA}\u{1F1F8}B"
677
- // UTF-16: A(1) + U+1F1FA(2) + U+1F1F8(2) + B(1) = 6
678
- // Scalars: A(1) + U+1F1FA(1) + U+1F1F8(1) + B(1) = 4
679
-
680
- XCTAssertEqual(
681
- PositionBridge.utf16OffsetToScalar(1, in: text), 1,
682
- "After 'A'"
683
- )
684
- XCTAssertEqual(
685
- PositionBridge.utf16OffsetToScalar(5, in: text), 3,
686
- "After flag emoji (UTF-16 offset 5 = scalar 3)"
687
- )
688
- XCTAssertEqual(
689
- PositionBridge.utf16OffsetToScalar(6, in: text), 4,
690
- "After 'B'"
691
- )
692
- }
693
-
694
- func testSnapToGraphemeBoundary_flagEmoji() {
695
- let text = "\u{1F1FA}\u{1F1F8}"
696
- // 4 UTF-16 code units, 1 grapheme cluster.
697
- // Offsets 1, 2, 3 should snap to 4.
698
- for midOffset in 1...3 {
699
- let snapped = PositionBridge.snapToGraphemeBoundary(midOffset, in: text)
700
- XCTAssertEqual(
701
- snapped, 4,
702
- "Mid-flag-emoji offset \(midOffset) should snap to 4"
703
- )
704
- }
705
- }
706
- }