@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,2236 +0,0 @@
1
- import XCTest
2
- import CoreText
3
-
4
- // MARK: - RenderBridge Tests
5
-
6
- final class RenderBridgeTests: XCTestCase {
7
-
8
- // MARK: - Test Fixtures
9
-
10
- private let baseFont = UIFont.systemFont(ofSize: 16)
11
- private let textColor = UIColor.black
12
-
13
- func testViewerEmptyCollapseDetectsDocumentsWithOnlyEmptyTopLevelParagraphs() {
14
- let json = """
15
- [
16
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
17
- {"type": "textRun", "text": "\\u200B", "marks": []},
18
- {"type": "blockEnd"},
19
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
20
- {"type": "textRun", "text": "", "marks": []},
21
- {"type": "blockEnd"}
22
- ]
23
- """
24
-
25
- XCTAssertTrue(NativeProseViewerExpoView.renderJsonContainsOnlyEmptyParagraphs(json))
26
- }
27
-
28
- func testViewerEmptyCollapseKeepsVisibleRenderedContentMeasurable() {
29
- let json = """
30
- [
31
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
32
- {"type": "textRun", "text": "Hello", "marks": []},
33
- {"type": "blockEnd"}
34
- ]
35
- """
36
-
37
- XCTAssertFalse(NativeProseViewerExpoView.renderJsonContainsOnlyEmptyParagraphs(json))
38
- }
39
-
40
- func testViewerEmptyCollapseKeepsNonParagraphRenderedBlocksMeasurable() {
41
- let json = """
42
- [
43
- {"type": "voidBlock", "nodeType": "image", "docPos": 1, "attrs": {}}
44
- ]
45
- """
46
-
47
- XCTAssertFalse(NativeProseViewerExpoView.renderJsonContainsOnlyEmptyParagraphs(json))
48
- }
49
-
50
- // MARK: - Plain Text Rendering
51
-
52
- /// A single paragraph with unstyled text should produce the text with base font.
53
- func testRender_plainParagraph() {
54
- let json = """
55
- [
56
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
57
- {"type": "textRun", "text": "Hello, world!", "marks": []},
58
- {"type": "blockEnd"}
59
- ]
60
- """
61
- let result = RenderBridge.renderElements(
62
- fromJSON: json,
63
- baseFont: baseFont,
64
- textColor: textColor
65
- )
66
-
67
- XCTAssertEqual(
68
- result.string, "Hello, world!",
69
- "Plain paragraph should render as the text content"
70
- )
71
-
72
- // Verify the font is the base font.
73
- let attrs = result.attributes(at: 0, effectiveRange: nil)
74
- let font = attrs[.font] as? UIFont
75
- XCTAssertNotNil(font, "Should have a font attribute")
76
- XCTAssertEqual(
77
- font?.pointSize, baseFont.pointSize,
78
- "Font size should match base font"
79
- )
80
- }
81
-
82
- func testRender_leadingTopLevelChildMetadataCoversWholeEmoji() {
83
- let blocks: [[[String: Any]]] = [[
84
- ["type": "blockStart", "nodeType": "paragraph", "depth": 0],
85
- ["type": "textRun", "text": "😀", "marks": []],
86
- ["type": "blockEnd"],
87
- ]]
88
-
89
- let result = RenderBridge.renderBlocks(
90
- fromArray: blocks,
91
- baseFont: baseFont,
92
- textColor: textColor
93
- )
94
- let firstComposedRange = (result.string as NSString)
95
- .rangeOfComposedCharacterSequence(at: 0)
96
- var effectiveRange = NSRange(location: NSNotFound, length: 0)
97
- let value = result.attribute(
98
- RenderBridgeAttributes.topLevelChildIndex,
99
- at: 0,
100
- longestEffectiveRange: &effectiveRange,
101
- in: NSRange(location: 0, length: result.length)
102
- ) as? NSNumber
103
-
104
- XCTAssertEqual(result.string, "😀")
105
- XCTAssertGreaterThan(firstComposedRange.length, 1, "test must cover a surrogate-pair emoji")
106
- XCTAssertEqual(value?.intValue, 0)
107
- XCTAssertEqual(
108
- effectiveRange,
109
- firstComposedRange,
110
- "top-level metadata must not split a leading emoji surrogate pair into separate attribute runs"
111
- )
112
- }
113
-
114
- // MARK: - Bold Text Rendering
115
-
116
- /// Bold mark should produce a font with the bold trait.
117
- func testRender_boldText() {
118
- let json = """
119
- [
120
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
121
- {"type": "textRun", "text": "bold text", "marks": ["bold"]},
122
- {"type": "blockEnd"}
123
- ]
124
- """
125
- let result = RenderBridge.renderElements(
126
- fromJSON: json,
127
- baseFont: baseFont,
128
- textColor: textColor
129
- )
130
-
131
- XCTAssertEqual(result.string, "bold text")
132
-
133
- let attrs = result.attributes(at: 0, effectiveRange: nil)
134
- let font = attrs[.font] as? UIFont
135
- XCTAssertNotNil(font, "Should have a font attribute")
136
- XCTAssertTrue(
137
- font?.fontDescriptor.symbolicTraits.contains(.traitBold) ?? false,
138
- "Font should have bold trait. Got font: \(String(describing: font))"
139
- )
140
- }
141
-
142
- // MARK: - Italic Text Rendering
143
-
144
- func testRender_italicText() {
145
- let json = """
146
- [
147
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
148
- {"type": "textRun", "text": "italic text", "marks": ["italic"]},
149
- {"type": "blockEnd"}
150
- ]
151
- """
152
- let result = RenderBridge.renderElements(
153
- fromJSON: json,
154
- baseFont: baseFont,
155
- textColor: textColor
156
- )
157
-
158
- XCTAssertEqual(result.string, "italic text")
159
-
160
- let attrs = result.attributes(at: 0, effectiveRange: nil)
161
- let font = attrs[.font] as? UIFont
162
- XCTAssertNotNil(font, "Should have a font attribute")
163
- XCTAssertTrue(
164
- font?.fontDescriptor.symbolicTraits.contains(.traitItalic) ?? false,
165
- "Font should have italic trait. Got font: \(String(describing: font))"
166
- )
167
- }
168
-
169
- // MARK: - Bold + Italic Combined
170
-
171
- func testRender_boldItalic() {
172
- let json = """
173
- [
174
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
175
- {"type": "textRun", "text": "bold italic", "marks": ["bold", "italic"]},
176
- {"type": "blockEnd"}
177
- ]
178
- """
179
- let result = RenderBridge.renderElements(
180
- fromJSON: json,
181
- baseFont: baseFont,
182
- textColor: textColor
183
- )
184
-
185
- let attrs = result.attributes(at: 0, effectiveRange: nil)
186
- let font = attrs[.font] as? UIFont
187
- XCTAssertNotNil(font, "Should have a font attribute")
188
-
189
- let traits = font?.fontDescriptor.symbolicTraits ?? []
190
- XCTAssertTrue(
191
- traits.contains(.traitBold),
192
- "Font should have bold trait. Traits: \(traits)"
193
- )
194
- XCTAssertTrue(
195
- traits.contains(.traitItalic),
196
- "Font should have italic trait. Traits: \(traits)"
197
- )
198
- }
199
-
200
- // MARK: - Underline
201
-
202
- func testRender_underline() {
203
- let json = """
204
- [
205
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
206
- {"type": "textRun", "text": "underlined", "marks": ["underline"]},
207
- {"type": "blockEnd"}
208
- ]
209
- """
210
- let result = RenderBridge.renderElements(
211
- fromJSON: json,
212
- baseFont: baseFont,
213
- textColor: textColor
214
- )
215
-
216
- let attrs = result.attributes(at: 0, effectiveRange: nil)
217
- let underline = attrs[.underlineStyle] as? Int
218
- XCTAssertNotNil(underline, "Should have underline style attribute")
219
- XCTAssertEqual(
220
- underline, NSUnderlineStyle.single.rawValue,
221
- "Underline should be single. Got: \(String(describing: underline))"
222
- )
223
- }
224
-
225
- // MARK: - Strikethrough
226
-
227
- func testRender_strikethrough() {
228
- let json = """
229
- [
230
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
231
- {"type": "textRun", "text": "struck", "marks": ["strike"]},
232
- {"type": "blockEnd"}
233
- ]
234
- """
235
- let result = RenderBridge.renderElements(
236
- fromJSON: json,
237
- baseFont: baseFont,
238
- textColor: textColor
239
- )
240
-
241
- let attrs = result.attributes(at: 0, effectiveRange: nil)
242
- let strikethrough = attrs[.strikethroughStyle] as? Int
243
- XCTAssertNotNil(strikethrough, "Should have strikethrough style attribute")
244
- XCTAssertEqual(
245
- strikethrough, NSUnderlineStyle.single.rawValue,
246
- "Strikethrough should be single. Got: \(String(describing: strikethrough))"
247
- )
248
- }
249
-
250
- func testRender_linkMarkObjectAppliesVisualLinkStylingWithoutInteractiveAttribute() {
251
- let json = """
252
- [
253
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
254
- {"type": "textRun", "text": "OpenAI", "marks": [{"type": "link", "href": "https://openai.com"}]},
255
- {"type": "blockEnd"}
256
- ]
257
- """
258
- let result = RenderBridge.renderElements(
259
- fromJSON: json,
260
- baseFont: baseFont,
261
- textColor: textColor
262
- )
263
-
264
- let attrs = result.attributes(at: 0, effectiveRange: nil)
265
- XCTAssertEqual(
266
- attrs[.underlineStyle] as? Int,
267
- NSUnderlineStyle.single.rawValue
268
- )
269
- XCTAssertEqual(attrs[.foregroundColor] as? UIColor, UIColor.systemBlue)
270
- XCTAssertNil(attrs[.link])
271
- XCTAssertEqual(
272
- attrs[RenderBridgeAttributes.linkHref] as? String,
273
- "https://openai.com"
274
- )
275
- }
276
-
277
- func testRender_linkMarkUsesThemeOverrides() {
278
- let json = """
279
- [
280
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
281
- {"type": "textRun", "text": "OpenAI", "marks": [{"type": "link", "href": "https://openai.com"}]},
282
- {"type": "blockEnd"}
283
- ]
284
- """
285
- let result = RenderBridge.renderElements(
286
- fromJSON: json,
287
- baseFont: baseFont,
288
- textColor: textColor,
289
- theme: EditorTheme(dictionary: [
290
- "links": [
291
- "color": "#445566",
292
- "backgroundColor": "#eef6ff",
293
- "fontSize": 18,
294
- "fontWeight": "700",
295
- "fontStyle": "italic",
296
- "underline": false,
297
- ],
298
- ])
299
- )
300
-
301
- let attrs = result.attributes(at: 0, effectiveRange: nil)
302
- let font = attrs[.font] as? UIFont
303
- XCTAssertEqual(attrs[.foregroundColor] as? UIColor, EditorTheme.color(from: "#445566"))
304
- XCTAssertEqual(attrs[.backgroundColor] as? UIColor, EditorTheme.color(from: "#eef6ff"))
305
- XCTAssertNil(attrs[.underlineStyle])
306
- XCTAssertEqual(font?.pointSize, 18)
307
- XCTAssertTrue(font?.fontDescriptor.symbolicTraits.contains(.traitBold) == true)
308
- XCTAssertTrue(font?.fontDescriptor.symbolicTraits.contains(.traitItalic) == true)
309
- XCTAssertEqual(
310
- attrs[RenderBridgeAttributes.linkHref] as? String,
311
- "https://openai.com"
312
- )
313
- }
314
-
315
- func testRenderBlocks_withLeadingSeparatorDoesNotDuplicateTopLevelChildIndexOnContent() {
316
- let blocks: [[[String: Any]]] = [[
317
- ["type": "blockStart", "nodeType": "paragraph", "depth": 0],
318
- ["type": "textRun", "text": "Hello", "marks": []],
319
- ["type": "blockEnd"],
320
- ]]
321
-
322
- let result = RenderBridge.renderBlocks(
323
- fromArray: blocks,
324
- startIndex: 3,
325
- includeLeadingInterBlockSeparator: true,
326
- baseFont: baseFont,
327
- textColor: textColor
328
- )
329
-
330
- XCTAssertEqual(result.string, "\nHello")
331
- XCTAssertEqual(
332
- (result.attribute(RenderBridgeAttributes.topLevelChildIndex, at: 0, effectiveRange: nil)
333
- as? NSNumber)?.intValue,
334
- 3
335
- )
336
- XCTAssertNil(
337
- result.attribute(RenderBridgeAttributes.topLevelChildIndex, at: 1, effectiveRange: nil),
338
- "Leading content should not duplicate the separator's top-level child index"
339
- )
340
- }
341
-
342
- // MARK: - Code Mark (Monospace)
343
-
344
- func testRender_codeInline() {
345
- let json = """
346
- [
347
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
348
- {"type": "textRun", "text": "code", "marks": ["code"]},
349
- {"type": "blockEnd"}
350
- ]
351
- """
352
- let result = RenderBridge.renderElements(
353
- fromJSON: json,
354
- baseFont: baseFont,
355
- textColor: textColor
356
- )
357
-
358
- let attrs = result.attributes(at: 0, effectiveRange: nil)
359
- let font = attrs[.font] as? UIFont
360
- XCTAssertNotNil(font, "Should have a font attribute")
361
- XCTAssertTrue(
362
- font?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) ?? false,
363
- "Code mark should produce monospace font. Got font: \(String(describing: font))"
364
- )
365
- }
366
-
367
- // MARK: - Hard Break (Void Inline)
368
-
369
- /// A hardBreak void inline should render as a newline character.
370
- func testRender_hardBreak() {
371
- let json = """
372
- [
373
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
374
- {"type": "textRun", "text": "Line 1", "marks": []},
375
- {"type": "voidInline", "nodeType": "hardBreak", "docPos": 7},
376
- {"type": "textRun", "text": "Line 2", "marks": []},
377
- {"type": "blockEnd"}
378
- ]
379
- """
380
- let result = RenderBridge.renderElements(
381
- fromJSON: json,
382
- baseFont: baseFont,
383
- textColor: textColor
384
- )
385
-
386
- XCTAssertEqual(
387
- result.string, "Line 1\nLine 2",
388
- "Hard break should render as newline. Got: '\(result.string)'"
389
- )
390
-
391
- // Verify the newline character has the void attribute.
392
- let newlineIndex = 6 // "Line 1" = 6 chars, newline at index 6
393
- let attrs = result.attributes(at: newlineIndex, effectiveRange: nil)
394
- let voidType = attrs[RenderBridgeAttributes.voidNodeType] as? String
395
- XCTAssertEqual(
396
- voidType, "hardBreak",
397
- "Newline should have voidNodeType='hardBreak' attribute. Got: \(String(describing: voidType))"
398
- )
399
- let docPos = attrs[RenderBridgeAttributes.docPos] as? UInt32
400
- XCTAssertEqual(
401
- docPos, 7,
402
- "Newline should have docPos=7. Got: \(String(describing: docPos))"
403
- )
404
- }
405
-
406
- func testRender_hardBreakDoesNotKeepParagraphSpacingBetweenVisualLines() {
407
- let json = """
408
- [
409
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
410
- {"type": "textRun", "text": "Line 1", "marks": []},
411
- {"type": "voidInline", "nodeType": "hardBreak", "docPos": 7},
412
- {"type": "textRun", "text": "Line 2", "marks": []},
413
- {"type": "blockEnd"}
414
- ]
415
- """
416
- let theme = EditorTheme(dictionary: [
417
- "paragraph": [
418
- "spacingAfter": 14,
419
- ],
420
- ])
421
- let result = RenderBridge.renderElements(
422
- fromJSON: json,
423
- baseFont: baseFont,
424
- textColor: textColor,
425
- theme: theme
426
- )
427
-
428
- let leadingStyle = result.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle
429
- let newlineStyle = result.attribute(.paragraphStyle, at: 6, effectiveRange: nil) as? NSParagraphStyle
430
-
431
- XCTAssertEqual(leadingStyle?.paragraphSpacing ?? -1, 0, accuracy: 0.1)
432
- XCTAssertEqual(newlineStyle?.paragraphSpacing ?? -1, 0, accuracy: 0.1)
433
- }
434
-
435
- func testRender_trailingHardBreakAppendsSyntheticPlaceholder() {
436
- let json = """
437
- [
438
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
439
- {"type": "textRun", "text": "A", "marks": []},
440
- {"type": "voidInline", "nodeType": "hardBreak", "docPos": 2},
441
- {"type": "blockEnd"}
442
- ]
443
- """
444
- let result = RenderBridge.renderElements(
445
- fromJSON: json,
446
- baseFont: baseFont,
447
- textColor: textColor
448
- )
449
-
450
- XCTAssertEqual(result.string, "A\n\u{200B}")
451
- let placeholderIndex = (result.string as NSString).length - 1
452
- XCTAssertEqual(
453
- result.attribute(RenderBridgeAttributes.syntheticPlaceholder, at: placeholderIndex, effectiveRange: nil) as? Bool,
454
- true
455
- )
456
- }
457
-
458
- func testRender_trailingHardBreakPlaceholderKeepsBlockquoteBorderAttributes() {
459
- let json = """
460
- [
461
- {"type": "blockStart", "nodeType": "blockquote", "depth": 0},
462
- {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
463
- {"type": "textRun", "text": "A", "marks": []},
464
- {"type": "voidInline", "nodeType": "hardBreak", "docPos": 2},
465
- {"type": "blockEnd"},
466
- {"type": "blockEnd"}
467
- ]
468
- """
469
- let result = RenderBridge.renderElements(
470
- fromJSON: json,
471
- baseFont: baseFont,
472
- textColor: textColor
473
- )
474
-
475
- XCTAssertEqual(result.string, "A\n\u{200B}")
476
-
477
- let placeholderIndex = (result.string as NSString).length - 1
478
- XCTAssertEqual(
479
- result.attribute(RenderBridgeAttributes.syntheticPlaceholder, at: placeholderIndex, effectiveRange: nil) as? Bool,
480
- true
481
- )
482
- XCTAssertNotNil(
483
- result.attribute(RenderBridgeAttributes.blockquoteBorderColor, at: placeholderIndex, effectiveRange: nil),
484
- "trailing hard-break placeholder inside a blockquote should keep blockquote styling"
485
- )
486
- }
487
-
488
- // MARK: - Horizontal Rule (Void Block)
489
-
490
- /// A horizontalRule should render as U+FFFC with an NSTextAttachment.
491
- func testRender_horizontalRule() {
492
- let json = """
493
- [
494
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
495
- {"type": "textRun", "text": "Above", "marks": []},
496
- {"type": "blockEnd"},
497
- {"type": "voidBlock", "nodeType": "horizontalRule", "docPos": 7},
498
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
499
- {"type": "textRun", "text": "Below", "marks": []},
500
- {"type": "blockEnd"}
501
- ]
502
- """
503
- let result = RenderBridge.renderElements(
504
- fromJSON: json,
505
- baseFont: baseFont,
506
- textColor: textColor
507
- )
508
-
509
- // The expected structure is: "Above" + "\n" + U+FFFC + "\n" + "Below"
510
- // The newlines are inter-block separators.
511
- let string = result.string
512
- XCTAssertTrue(
513
- string.contains("\u{FFFC}"),
514
- "Horizontal rule should contain object replacement character. Got: '\(string)'"
515
- )
516
-
517
- // Find the FFFC character and check its attributes.
518
- if let fffcRange = string.range(of: "\u{FFFC}") {
519
- let nsRange = NSRange(fffcRange, in: string)
520
- let attrs = result.attributes(at: nsRange.location, effectiveRange: nil)
521
-
522
- let voidType = attrs[RenderBridgeAttributes.voidNodeType] as? String
523
- XCTAssertEqual(
524
- voidType, "horizontalRule",
525
- "FFFC should have voidNodeType='horizontalRule'. Got: \(String(describing: voidType))"
526
- )
527
-
528
- let attachment = attrs[.attachment] as? NSTextAttachment
529
- XCTAssertNotNil(
530
- attachment,
531
- "FFFC should have an NSTextAttachment"
532
- )
533
- XCTAssertTrue(
534
- attachment is HorizontalRuleAttachment,
535
- "Attachment should be HorizontalRuleAttachment. Got: \(String(describing: type(of: attachment)))"
536
- )
537
- } else {
538
- XCTFail("Could not find FFFC character in rendered string")
539
- }
540
- }
541
-
542
- func testRender_horizontalRuleCollapsesAdjacentParagraphSpacing() {
543
- let json = """
544
- [
545
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
546
- {"type": "textRun", "text": "Above", "marks": []},
547
- {"type": "blockEnd"},
548
- {"type": "voidBlock", "nodeType": "horizontalRule", "docPos": 7},
549
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
550
- {"type": "textRun", "text": "Below", "marks": []},
551
- {"type": "blockEnd"}
552
- ]
553
- """
554
- let theme = EditorTheme(dictionary: [
555
- "paragraph": [
556
- "spacingAfter": 14,
557
- ],
558
- "horizontalRule": [
559
- "verticalMargin": 10,
560
- ],
561
- ])
562
-
563
- let result = RenderBridge.renderElements(
564
- fromJSON: json,
565
- baseFont: baseFont,
566
- textColor: textColor,
567
- theme: theme
568
- )
569
-
570
- let nsString = result.string as NSString
571
- let aboveRange = nsString.range(of: "Above")
572
- let hrRange = nsString.range(of: "\u{FFFC}")
573
- guard aboveRange.location != NSNotFound, hrRange.location != NSNotFound else {
574
- XCTFail("expected both paragraph text and horizontal rule in rendered output")
575
- return
576
- }
577
-
578
- let aboveParagraphStyle = result.attribute(.paragraphStyle, at: aboveRange.location, effectiveRange: nil)
579
- as? NSParagraphStyle
580
- let separatorParagraphStyle = result.attribute(
581
- .paragraphStyle,
582
- at: hrRange.location + hrRange.length,
583
- effectiveRange: nil
584
- ) as? NSParagraphStyle
585
- let attachment = result.attribute(.attachment, at: hrRange.location, effectiveRange: nil)
586
- as? HorizontalRuleAttachment
587
-
588
- XCTAssertEqual(attachment?.verticalPadding ?? 0, 10, accuracy: 0.1)
589
- XCTAssertEqual(aboveParagraphStyle?.paragraphSpacing ?? -1, 4, accuracy: 0.1)
590
- XCTAssertEqual(separatorParagraphStyle?.paragraphSpacing ?? -1, 4, accuracy: 0.1)
591
- }
592
-
593
- // MARK: - Multiple Paragraphs
594
-
595
- /// Two consecutive paragraphs should be separated by a newline.
596
- func testRender_multipleParagraphs() {
597
- let json = """
598
- [
599
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
600
- {"type": "textRun", "text": "First", "marks": []},
601
- {"type": "blockEnd"},
602
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
603
- {"type": "textRun", "text": "Second", "marks": []},
604
- {"type": "blockEnd"}
605
- ]
606
- """
607
- let result = RenderBridge.renderElements(
608
- fromJSON: json,
609
- baseFont: baseFont,
610
- textColor: textColor
611
- )
612
-
613
- XCTAssertEqual(
614
- result.string, "First\nSecond",
615
- "Two paragraphs should be separated by a newline"
616
- )
617
- }
618
-
619
- // MARK: - Mixed Marks in Same Paragraph
620
-
621
- /// A paragraph with mixed styled runs should produce the correct combined string
622
- /// with different attributes at different ranges.
623
- func testRender_mixedMarksInParagraph() {
624
- let json = """
625
- [
626
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
627
- {"type": "textRun", "text": "normal ", "marks": []},
628
- {"type": "textRun", "text": "bold", "marks": ["bold"]},
629
- {"type": "textRun", "text": " end", "marks": []},
630
- {"type": "blockEnd"}
631
- ]
632
- """
633
- let result = RenderBridge.renderElements(
634
- fromJSON: json,
635
- baseFont: baseFont,
636
- textColor: textColor
637
- )
638
-
639
- XCTAssertEqual(result.string, "normal bold end")
640
-
641
- // Check "normal " (offset 0) has base font, not bold.
642
- let normalAttrs = result.attributes(at: 0, effectiveRange: nil)
643
- let normalFont = normalAttrs[.font] as? UIFont
644
- XCTAssertFalse(
645
- normalFont?.fontDescriptor.symbolicTraits.contains(.traitBold) ?? true,
646
- "'normal' should not be bold"
647
- )
648
-
649
- // Check "bold" (offset 7) has bold font.
650
- let boldAttrs = result.attributes(at: 7, effectiveRange: nil)
651
- let boldFont = boldAttrs[.font] as? UIFont
652
- XCTAssertTrue(
653
- boldFont?.fontDescriptor.symbolicTraits.contains(.traitBold) ?? false,
654
- "'bold' should have bold trait"
655
- )
656
-
657
- // Check " end" (offset 11) has base font, not bold.
658
- let endAttrs = result.attributes(at: 11, effectiveRange: nil)
659
- let endFont = endAttrs[.font] as? UIFont
660
- XCTAssertFalse(
661
- endFont?.fontDescriptor.symbolicTraits.contains(.traitBold) ?? true,
662
- "'end' should not be bold"
663
- )
664
- }
665
-
666
- // MARK: - Ordered List
667
-
668
- /// Ordered list items should reserve gutter space without injecting marker text.
669
- func testRender_orderedListItem() {
670
- let json = """
671
- [
672
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
673
- "listContext": {"ordered": true, "index": 1, "total": 2, "start": 1, "isFirst": true, "isLast": false}},
674
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
675
- {"type": "textRun", "text": "First item", "marks": []},
676
- {"type": "blockEnd"},
677
- {"type": "blockEnd"},
678
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
679
- "listContext": {"ordered": true, "index": 2, "total": 2, "start": 1, "isFirst": false, "isLast": true}},
680
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
681
- {"type": "textRun", "text": "Second item", "marks": []},
682
- {"type": "blockEnd"},
683
- {"type": "blockEnd"}
684
- ]
685
- """
686
- let result = RenderBridge.renderElements(
687
- fromJSON: json,
688
- baseFont: baseFont,
689
- textColor: textColor
690
- )
691
-
692
- XCTAssertEqual(result.string, "First item\nSecond item")
693
-
694
- let firstAttrs = result.attributes(at: 0, effectiveRange: nil)
695
- let firstStyle = firstAttrs[.paragraphStyle] as? NSParagraphStyle
696
- XCTAssertNotNil(firstAttrs[RenderBridgeAttributes.listContext])
697
- XCTAssertEqual(firstStyle?.firstLineHeadIndent, 68.0)
698
- XCTAssertEqual(firstStyle?.headIndent, 68.0)
699
- }
700
-
701
- // MARK: - Unordered List
702
-
703
- func testRender_unorderedListItem() {
704
- let json = """
705
- [
706
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
707
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
708
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
709
- {"type": "textRun", "text": "Bullet item", "marks": []},
710
- {"type": "blockEnd"},
711
- {"type": "blockEnd"}
712
- ]
713
- """
714
- let result = RenderBridge.renderElements(
715
- fromJSON: json,
716
- baseFont: baseFont,
717
- textColor: textColor
718
- )
719
-
720
- XCTAssertEqual(result.string, "Bullet item")
721
- XCTAssertNotNil(result.attribute(RenderBridgeAttributes.listContext, at: 0, effectiveRange: nil))
722
- }
723
-
724
- func testRender_unorderedListMarkerUsesLargerFontThanItemText() {
725
- let json = """
726
- [
727
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
728
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
729
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
730
- {"type": "textRun", "text": "Bullet item", "marks": []},
731
- {"type": "blockEnd"},
732
- {"type": "blockEnd"}
733
- ]
734
- """
735
- let result = RenderBridge.renderElements(
736
- fromJSON: json,
737
- baseFont: baseFont,
738
- textColor: textColor
739
- )
740
-
741
- let textFont = result.attribute(.font, at: 0, effectiveRange: nil) as? UIFont
742
- XCTAssertEqual(textFont?.pointSize, baseFont.pointSize)
743
- XCTAssertNotNil(result.attribute(RenderBridgeAttributes.listContext, at: 0, effectiveRange: nil))
744
- }
745
-
746
- func testRender_emptyUnorderedListItemDoesNotInsertParagraphNewlineAfterMarker() {
747
- let json = """
748
- [
749
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
750
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
751
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
752
- {"type": "textRun", "text": "\\u200B", "marks": []},
753
- {"type": "blockEnd"},
754
- {"type": "blockEnd"}
755
- ]
756
- """
757
- let result = RenderBridge.renderElements(
758
- fromJSON: json,
759
- baseFont: baseFont,
760
- textColor: textColor
761
- )
762
-
763
- XCTAssertEqual(
764
- result.string, "\u{200B}",
765
- "An empty list item should render only its placeholder text. Got: '\(result.string)'"
766
- )
767
- XCTAssertNotNil(result.attribute(RenderBridgeAttributes.listContext, at: 0, effectiveRange: nil))
768
- }
769
-
770
- func testRender_emptyParagraphAfterListUsesItsOwnParagraphStyle() {
771
- let json = """
772
- [
773
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
774
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
775
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
776
- {"type": "textRun", "text": "A", "marks": []},
777
- {"type": "blockEnd"},
778
- {"type": "blockEnd"},
779
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
780
- {"type": "textRun", "text": "\\u200B", "marks": []},
781
- {"type": "blockEnd"}
782
- ]
783
- """
784
- let result = RenderBridge.renderElements(
785
- fromJSON: json,
786
- baseFont: baseFont,
787
- textColor: textColor
788
- )
789
-
790
- XCTAssertEqual(result.string, "A\n\u{200B}")
791
-
792
- let placeholderIndex = (result.string as NSString).length - 1
793
- let placeholderStyle = result.attribute(
794
- .paragraphStyle,
795
- at: placeholderIndex,
796
- effectiveRange: nil
797
- ) as? NSParagraphStyle
798
-
799
- XCTAssertNotNil(placeholderStyle, "Empty paragraph placeholder should carry paragraph style")
800
- XCTAssertEqual(placeholderStyle?.firstLineHeadIndent, 0)
801
- XCTAssertEqual(placeholderStyle?.headIndent, 0)
802
- }
803
-
804
- func testRender_secondParagraphInListItemDoesNotGetListMarkerContext() {
805
- let json = """
806
- [
807
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
808
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
809
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
810
- {"type": "textRun", "text": "A", "marks": []},
811
- {"type": "blockEnd"},
812
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
813
- {"type": "textRun", "text": "\\u200B", "marks": []},
814
- {"type": "blockEnd"},
815
- {"type": "blockEnd"}
816
- ]
817
- """
818
- let result = RenderBridge.renderElements(
819
- fromJSON: json,
820
- baseFont: baseFont,
821
- textColor: textColor
822
- )
823
-
824
- XCTAssertNotNil(
825
- result.attribute(RenderBridgeAttributes.listMarkerContext, at: 0, effectiveRange: nil),
826
- "The first paragraph in a list item should keep its marker context"
827
- )
828
- XCTAssertNil(
829
- result.attribute(RenderBridgeAttributes.listMarkerContext, at: 2, effectiveRange: nil),
830
- "The second paragraph in a list item should not render a separate list marker"
831
- )
832
- }
833
-
834
- // MARK: - Invalid JSON
835
-
836
- func testRender_invalidJSON() {
837
- let result = RenderBridge.renderElements(
838
- fromJSON: "not valid json",
839
- baseFont: baseFont,
840
- textColor: textColor
841
- )
842
-
843
- XCTAssertEqual(
844
- result.string, "",
845
- "Invalid JSON should produce empty attributed string"
846
- )
847
- }
848
-
849
- func testRender_emptyArray() {
850
- let result = RenderBridge.renderElements(
851
- fromJSON: "[]",
852
- baseFont: baseFont,
853
- textColor: textColor
854
- )
855
-
856
- XCTAssertEqual(
857
- result.string, "",
858
- "Empty array should produce empty attributed string"
859
- )
860
- }
861
-
862
- // MARK: - Mark Attributes Isolated Tests
863
-
864
- /// Test attributesForMarks directly to verify all mark combinations.
865
- func testAttributesForMarks_noMarks() {
866
- let attrs = RenderBridge.attributesForMarks([], baseFont: baseFont, textColor: textColor)
867
- let font = attrs[.font] as? UIFont
868
- XCTAssertEqual(font, baseFont, "No marks should use base font")
869
- XCTAssertNil(attrs[.underlineStyle], "No marks should have no underline")
870
- XCTAssertNil(attrs[.strikethroughStyle], "No marks should have no strikethrough")
871
- }
872
-
873
- func testAttributesForMarks_strongAlias() {
874
- // "strong" is an alias for "bold"
875
- let attrs = RenderBridge.attributesForMarks(
876
- ["strong"],
877
- baseFont: baseFont,
878
- textColor: textColor
879
- )
880
- let font = attrs[.font] as? UIFont
881
- XCTAssertTrue(
882
- font?.fontDescriptor.symbolicTraits.contains(.traitBold) ?? false,
883
- "'strong' should produce bold font"
884
- )
885
- }
886
-
887
- func testAttributesForMarks_emAlias() {
888
- // "em" is an alias for "italic"
889
- let attrs = RenderBridge.attributesForMarks(
890
- ["em"],
891
- baseFont: baseFont,
892
- textColor: textColor
893
- )
894
- let font = attrs[.font] as? UIFont
895
- XCTAssertTrue(
896
- font?.fontDescriptor.symbolicTraits.contains(.traitItalic) ?? false,
897
- "'em' should produce italic font"
898
- )
899
- }
900
-
901
- func testAttributesForMarks_strikethroughAlias() {
902
- // "strikethrough" is an alias for "strike"
903
- let attrs = RenderBridge.attributesForMarks(
904
- ["strikethrough"],
905
- baseFont: baseFont,
906
- textColor: textColor
907
- )
908
- let strikethrough = attrs[.strikethroughStyle] as? Int
909
- XCTAssertEqual(
910
- strikethrough, NSUnderlineStyle.single.rawValue,
911
- "'strikethrough' should produce strikethrough style"
912
- )
913
- }
914
-
915
- func testAttributesForMarks_allCombined() {
916
- let attrs = RenderBridge.attributesForMarks(
917
- ["bold", "italic", "underline", "strike"],
918
- baseFont: baseFont,
919
- textColor: textColor
920
- )
921
- let font = attrs[.font] as? UIFont
922
- let traits = font?.fontDescriptor.symbolicTraits ?? []
923
- XCTAssertTrue(traits.contains(.traitBold), "Should have bold")
924
- XCTAssertTrue(traits.contains(.traitItalic), "Should have italic")
925
- XCTAssertEqual(
926
- attrs[.underlineStyle] as? Int,
927
- NSUnderlineStyle.single.rawValue,
928
- "Should have underline"
929
- )
930
- XCTAssertEqual(
931
- attrs[.strikethroughStyle] as? Int,
932
- NSUnderlineStyle.single.rawValue,
933
- "Should have strikethrough"
934
- )
935
- }
936
-
937
- func testAttributesForMarks_unknownMarkIgnored() {
938
- let attrs = RenderBridge.attributesForMarks(
939
- ["unknownMark"],
940
- baseFont: baseFont,
941
- textColor: textColor
942
- )
943
- let font = attrs[.font] as? UIFont
944
- XCTAssertEqual(
945
- font, baseFont,
946
- "Unknown marks should be ignored, producing base font"
947
- )
948
- }
949
-
950
- // MARK: - Paragraph Style Tests
951
-
952
- func testParagraphStyle_depth0() {
953
- let ctx = BlockContext(nodeType: "paragraph", depth: 0, listContext: nil)
954
- let style = RenderBridge.paragraphStyleForBlock(ctx, blockStack: [ctx])
955
- XCTAssertEqual(
956
- style.firstLineHeadIndent, 0,
957
- "Depth 0 paragraph should have 0 indentation"
958
- )
959
- XCTAssertEqual(
960
- style.headIndent, 0,
961
- "Depth 0 paragraph should have 0 head indent"
962
- )
963
- }
964
-
965
- func testParagraphStyle_depth2() {
966
- let ctx = BlockContext(nodeType: "paragraph", depth: 2, listContext: nil)
967
- let style = RenderBridge.paragraphStyleForBlock(ctx, blockStack: [ctx])
968
- let expectedIndent: CGFloat = 2 * 24.0 // 2 * indentPerDepth
969
- XCTAssertEqual(
970
- style.firstLineHeadIndent, expectedIndent,
971
- "Depth 2 paragraph should have \(expectedIndent) first line indent"
972
- )
973
- }
974
-
975
- func testParagraphStyle_listItem() {
976
- let listCtx: [String: Any] = [
977
- "ordered": true,
978
- "index": 1,
979
- "total": 3,
980
- "start": 1,
981
- "isFirst": true,
982
- "isLast": false,
983
- ]
984
- let ctx = BlockContext(nodeType: "listItem", depth: 1, listContext: listCtx)
985
- let style = RenderBridge.paragraphStyleForBlock(ctx, blockStack: [ctx])
986
-
987
- let baseIndent: CGFloat = 1 * 24.0 // depth * indentPerDepth
988
- XCTAssertEqual(
989
- style.firstLineHeadIndent, baseIndent + 20.0,
990
- "List item first line indent should reserve marker width"
991
- )
992
- XCTAssertEqual(
993
- style.headIndent, baseIndent + 20.0, // + listMarkerWidth
994
- "List item head indent should include marker width"
995
- )
996
- }
997
-
998
- func testParagraphStyle_listBaseIndentMultiplierCanCollapseTopLevelIndent() {
999
- let listCtx: [String: Any] = [
1000
- "ordered": false,
1001
- "index": 1,
1002
- "total": 1,
1003
- "start": 1,
1004
- "isFirst": true,
1005
- "isLast": true,
1006
- ]
1007
- let topLevelCtx = BlockContext(nodeType: "paragraph", depth: 1, listContext: listCtx)
1008
- let nestedCtx = BlockContext(nodeType: "paragraph", depth: 2, listContext: listCtx)
1009
- let theme = EditorTheme(dictionary: [
1010
- "list": [
1011
- "indent": 24,
1012
- "baseIndentMultiplier": 0,
1013
- ],
1014
- ])
1015
-
1016
- let topLevelStyle = RenderBridge.paragraphStyleForBlock(
1017
- topLevelCtx,
1018
- blockStack: [topLevelCtx],
1019
- theme: theme,
1020
- baseFont: baseFont
1021
- )
1022
- let nestedStyle = RenderBridge.paragraphStyleForBlock(
1023
- nestedCtx,
1024
- blockStack: [nestedCtx],
1025
- theme: theme,
1026
- baseFont: baseFont
1027
- )
1028
-
1029
- XCTAssertEqual(
1030
- topLevelStyle.firstLineHeadIndent,
1031
- LayoutConstants.listMarkerWidth,
1032
- accuracy: 0.1,
1033
- "Top-level list items should be flush-left apart from the marker gutter"
1034
- )
1035
- XCTAssertEqual(
1036
- topLevelStyle.headIndent,
1037
- LayoutConstants.listMarkerWidth,
1038
- accuracy: 0.1,
1039
- "Wrapped lines should align with the marker gutter when the base indent multiplier is zero"
1040
- )
1041
- XCTAssertEqual(
1042
- nestedStyle.headIndent - topLevelStyle.headIndent,
1043
- 24,
1044
- accuracy: 0.1,
1045
- "Nested list levels should still add one indent unit each"
1046
- )
1047
- }
1048
-
1049
- func testParagraphStyle_unorderedMarkerScaleDoesNotWidenTextGutter() {
1050
- let baseContext = BlockContext(
1051
- nodeType: "listItem",
1052
- depth: 1,
1053
- listContext: [
1054
- "ordered": false,
1055
- "index": 1,
1056
- "total": 1,
1057
- "start": 1,
1058
- "isFirst": true,
1059
- "isLast": true,
1060
- ]
1061
- )
1062
- let baseTheme = EditorTheme(dictionary: [
1063
- "list": [
1064
- "indent": 24,
1065
- "markerScale": 1,
1066
- ],
1067
- ])
1068
- let scaledTheme = EditorTheme(dictionary: [
1069
- "list": [
1070
- "indent": 24,
1071
- "markerScale": 2,
1072
- ],
1073
- ])
1074
-
1075
- let largeBaseFont = UIFont.systemFont(ofSize: 40)
1076
- let baseStyle = RenderBridge.paragraphStyleForBlock(
1077
- baseContext,
1078
- blockStack: [baseContext],
1079
- theme: baseTheme,
1080
- baseFont: largeBaseFont
1081
- )
1082
- let scaledStyle = RenderBridge.paragraphStyleForBlock(
1083
- baseContext,
1084
- blockStack: [baseContext],
1085
- theme: scaledTheme,
1086
- baseFont: largeBaseFont
1087
- )
1088
-
1089
- XCTAssertEqual(baseStyle.headIndent, scaledStyle.headIndent, accuracy: 0.1)
1090
- XCTAssertEqual(baseStyle.firstLineHeadIndent, scaledStyle.firstLineHeadIndent, accuracy: 0.1)
1091
- }
1092
-
1093
- func testParagraphStyle_blockquoteUsesQuoteIndent() {
1094
- let quote = BlockContext(nodeType: "blockquote", depth: 0, listContext: nil)
1095
- let paragraph = BlockContext(nodeType: "paragraph", depth: 1, listContext: nil)
1096
- let theme = EditorTheme(dictionary: [
1097
- "blockquote": [
1098
- "indent": 20,
1099
- "borderColor": "#aa5500",
1100
- "borderWidth": 4,
1101
- "markerGap": 10,
1102
- ],
1103
- ])
1104
-
1105
- let style = RenderBridge.paragraphStyleForBlock(
1106
- paragraph,
1107
- blockStack: [quote, paragraph],
1108
- theme: theme,
1109
- baseFont: baseFont
1110
- )
1111
-
1112
- XCTAssertEqual(style.firstLineHeadIndent, 20, accuracy: 0.1)
1113
- XCTAssertEqual(style.headIndent, 20, accuracy: 0.1)
1114
- }
1115
-
1116
- func testParagraphStyle_nestedListItemInsideBlockquoteAddsListIndent() {
1117
- let quote = BlockContext(nodeType: "blockquote", depth: 0, listContext: nil)
1118
- let parentListItem = BlockContext(
1119
- nodeType: "listItem",
1120
- depth: 1,
1121
- listContext: ["ordered": false, "index": 1, "total": 2, "start": 1, "isFirst": true, "isLast": false]
1122
- )
1123
- let parentParagraph = BlockContext(nodeType: "paragraph", depth: 2, listContext: nil)
1124
- let nestedListItem = BlockContext(
1125
- nodeType: "listItem",
1126
- depth: 2,
1127
- listContext: ["ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true]
1128
- )
1129
- let nestedParagraph = BlockContext(nodeType: "paragraph", depth: 3, listContext: nil)
1130
-
1131
- let parentStyle = RenderBridge.paragraphStyleForBlock(
1132
- parentParagraph,
1133
- blockStack: [quote, parentListItem, parentParagraph],
1134
- theme: nil,
1135
- baseFont: baseFont
1136
- )
1137
- let nestedStyle = RenderBridge.paragraphStyleForBlock(
1138
- nestedParagraph,
1139
- blockStack: [quote, parentListItem, nestedListItem, nestedParagraph],
1140
- theme: nil,
1141
- baseFont: baseFont
1142
- )
1143
-
1144
- XCTAssertGreaterThan(
1145
- nestedStyle.headIndent,
1146
- parentStyle.headIndent,
1147
- "nested list item inside a blockquote should indent more than its parent item"
1148
- )
1149
- XCTAssertGreaterThan(
1150
- nestedStyle.firstLineHeadIndent,
1151
- parentStyle.firstLineHeadIndent,
1152
- "nested list marker should also move inward inside a blockquote"
1153
- )
1154
- }
1155
-
1156
- func testParagraphStyle_firstLevelListInsideBlockquoteAddsListIndentInsideQuote() {
1157
- let json = """
1158
- [
1159
- {"type": "blockStart", "nodeType": "blockquote", "depth": 0},
1160
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
1161
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1162
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1163
- {"type": "textRun", "text": "Quoted item", "marks": []},
1164
- {"type": "blockEnd"},
1165
- {"type": "blockEnd"},
1166
- {"type": "blockEnd"}
1167
- ]
1168
- """
1169
- let result = RenderBridge.renderElements(
1170
- fromJSON: json,
1171
- baseFont: baseFont,
1172
- textColor: textColor
1173
- )
1174
- let style = result.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle
1175
- let quote = BlockContext(nodeType: "blockquote", depth: 0, listContext: nil)
1176
- let quotedParagraph = BlockContext(nodeType: "paragraph", depth: 1, listContext: nil)
1177
- let quotedListParagraph = BlockContext(
1178
- nodeType: "paragraph",
1179
- depth: 2,
1180
- listContext: ["ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true]
1181
- )
1182
- let plainQuotedStyle = RenderBridge.paragraphStyleForBlock(
1183
- quotedParagraph,
1184
- blockStack: [quote, quotedParagraph],
1185
- theme: nil,
1186
- baseFont: baseFont
1187
- )
1188
- let expectedStyle = RenderBridge.paragraphStyleForBlock(
1189
- quotedListParagraph,
1190
- blockStack: [quote, quotedListParagraph],
1191
- theme: nil,
1192
- baseFont: baseFont
1193
- )
1194
-
1195
- XCTAssertEqual(
1196
- style?.headIndent ?? -1,
1197
- expectedStyle.headIndent,
1198
- accuracy: 0.1,
1199
- "first-level list paragraphs inside a blockquote should keep their extra list indent"
1200
- )
1201
- XCTAssertEqual(
1202
- style?.firstLineHeadIndent ?? -1,
1203
- expectedStyle.firstLineHeadIndent,
1204
- accuracy: 0.1,
1205
- "first-level quoted list markers should keep their extra list indent"
1206
- )
1207
- XCTAssertGreaterThan(
1208
- style?.headIndent ?? -1,
1209
- plainQuotedStyle.headIndent,
1210
- "quoted list text should indent further than plain quoted text"
1211
- )
1212
- XCTAssertGreaterThan(
1213
- style?.firstLineHeadIndent ?? -1,
1214
- plainQuotedStyle.firstLineHeadIndent,
1215
- "quoted list marker gutter should indent further than plain quoted text"
1216
- )
1217
- }
1218
-
1219
- func testRender_blockquoteAppliesBorderAttributesAndTextTheme() {
1220
- let json = """
1221
- [
1222
- {"type": "blockStart", "nodeType": "blockquote", "depth": 0},
1223
- {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1224
- {"type": "textRun", "text": "Quoted", "marks": []},
1225
- {"type": "blockEnd"},
1226
- {"type": "blockEnd"}
1227
- ]
1228
- """
1229
- let result = RenderBridge.renderElements(
1230
- fromJSON: json,
1231
- baseFont: baseFont,
1232
- textColor: textColor,
1233
- theme: EditorTheme(dictionary: [
1234
- "blockquote": [
1235
- "indent": 20,
1236
- "borderColor": "#aa5500",
1237
- "borderWidth": 4,
1238
- "markerGap": 10,
1239
- "text": [
1240
- "color": "#334455",
1241
- ],
1242
- ],
1243
- ])
1244
- )
1245
- let expectedTextColor = UIColor(
1246
- red: 51.0 / 255.0,
1247
- green: 68.0 / 255.0,
1248
- blue: 85.0 / 255.0,
1249
- alpha: 1
1250
- )
1251
- let expectedBorderColor = UIColor(
1252
- red: 170.0 / 255.0,
1253
- green: 85.0 / 255.0,
1254
- blue: 0.0,
1255
- alpha: 1
1256
- )
1257
- var foundStyledRun = false
1258
- result.enumerateAttributes(
1259
- in: NSRange(location: 0, length: result.length),
1260
- options: []
1261
- ) { attrs, _, stop in
1262
- guard attrs[RenderBridgeAttributes.blockquoteBorderColor] != nil else { return }
1263
- XCTAssertEqual(attrs[.foregroundColor] as? UIColor, expectedTextColor)
1264
- XCTAssertEqual(attrs[RenderBridgeAttributes.blockquoteBorderColor] as? UIColor, expectedBorderColor)
1265
- XCTAssertEqual(
1266
- (attrs[RenderBridgeAttributes.blockquoteBorderWidth] as? NSNumber)?.doubleValue ?? 0,
1267
- 4,
1268
- accuracy: 0.1
1269
- )
1270
- XCTAssertEqual(
1271
- (attrs[RenderBridgeAttributes.blockquoteMarkerGap] as? NSNumber)?.doubleValue ?? 0,
1272
- 10,
1273
- accuracy: 0.1
1274
- )
1275
- foundStyledRun = true
1276
- stop.pointee = true
1277
- }
1278
-
1279
- XCTAssertTrue(foundStyledRun, "Expected a rendered run carrying blockquote border attributes")
1280
- }
1281
-
1282
- func testRender_blockquoteDoesNotInsertExtraLeadingParagraphBreak() {
1283
- let json = """
1284
- [
1285
- {"type": "blockStart", "nodeType": "blockquote", "depth": 0},
1286
- {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1287
- {"type": "textRun", "text": "Hello", "marks": []},
1288
- {"type": "blockEnd"},
1289
- {"type": "blockEnd"},
1290
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1291
- {"type": "textRun", "text": "World", "marks": []},
1292
- {"type": "blockEnd"}
1293
- ]
1294
- """
1295
- let result = RenderBridge.renderElements(
1296
- fromJSON: json,
1297
- baseFont: baseFont,
1298
- textColor: textColor
1299
- )
1300
-
1301
- XCTAssertEqual(result.string, "Hello\nWorld")
1302
- }
1303
-
1304
- // MARK: - List Marker Generation
1305
-
1306
- func testListMarker_ordered() {
1307
- let ctx: [String: Any] = ["ordered": true, "index": 3]
1308
- let marker = RenderBridge.listMarkerString(listContext: ctx)
1309
- XCTAssertEqual(marker, "3. ", "Ordered list item 3 should produce '3. '")
1310
- }
1311
-
1312
- func testListMarker_unordered() {
1313
- let ctx: [String: Any] = ["ordered": false, "index": 1]
1314
- let marker = RenderBridge.listMarkerString(listContext: ctx)
1315
- XCTAssertEqual(marker, "\u{2022} ", "Unordered list should produce bullet + space")
1316
- }
1317
-
1318
- func testListMarker_taskUnchecked() {
1319
- let ctx: [String: Any] = ["kind": "task", "checked": false]
1320
- let marker = RenderBridge.listMarkerString(listContext: ctx)
1321
- XCTAssertEqual(marker, "\u{2610} ", "Unchecked task list should produce ballot box + space")
1322
- }
1323
-
1324
- // MARK: - Opaque Atoms
1325
-
1326
- func testRender_opaqueInlineAtom() {
1327
- let json = """
1328
- [
1329
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1330
- {"type": "textRun", "text": "before ", "marks": []},
1331
- {"type": "opaqueInlineAtom", "label": "widget", "docPos": 8},
1332
- {"type": "textRun", "text": " after", "marks": []},
1333
- {"type": "blockEnd"}
1334
- ]
1335
- """
1336
- let result = RenderBridge.renderElements(
1337
- fromJSON: json,
1338
- baseFont: baseFont,
1339
- textColor: textColor
1340
- )
1341
-
1342
- XCTAssertTrue(
1343
- result.string.contains("[widget]"),
1344
- "Opaque inline atom should render as '[widget]'. Got: '\(result.string)'"
1345
- )
1346
- }
1347
-
1348
- func testRender_mentionInlineAtomUsesVisibleLabelAndTheme() {
1349
- let json = """
1350
- [
1351
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1352
- {"type": "textRun", "text": "Hello ", "marks": []},
1353
- {"type": "opaqueInlineAtom", "nodeType": "mention", "label": "@Alice", "docPos": 7},
1354
- {"type": "textRun", "text": "!", "marks": []},
1355
- {"type": "blockEnd"}
1356
- ]
1357
- """
1358
- let theme = EditorTheme(dictionary: [
1359
- "mentions": [
1360
- "textColor": "#112233",
1361
- "backgroundColor": "#ddeeff",
1362
- "fontWeight": "bold",
1363
- ],
1364
- ])
1365
- let result = RenderBridge.renderElements(
1366
- fromJSON: json,
1367
- baseFont: baseFont,
1368
- textColor: textColor,
1369
- theme: theme
1370
- )
1371
-
1372
- XCTAssertTrue(
1373
- result.string.contains("@Alice"),
1374
- "Mention inline atom should render its visible label. Got: '\(result.string)'"
1375
- )
1376
- XCTAssertFalse(
1377
- result.string.contains("[@Alice]"),
1378
- "Mention inline atom should not render using generic opaque brackets. Got: '\(result.string)'"
1379
- )
1380
-
1381
- let mentionRange = (result.string as NSString).range(of: "@Alice")
1382
- XCTAssertNotEqual(mentionRange.location, NSNotFound)
1383
-
1384
- let attrs = result.attributes(at: mentionRange.location, effectiveRange: nil)
1385
- XCTAssertEqual(
1386
- attrs[.foregroundColor] as? UIColor,
1387
- UIColor(
1388
- red: 0x11 as CGFloat / 255.0,
1389
- green: 0x22 as CGFloat / 255.0,
1390
- blue: 0x33 as CGFloat / 255.0,
1391
- alpha: 1.0
1392
- )
1393
- )
1394
- XCTAssertEqual(
1395
- attrs[.backgroundColor] as? UIColor,
1396
- UIColor(
1397
- red: 0xdd as CGFloat / 255.0,
1398
- green: 0xee as CGFloat / 255.0,
1399
- blue: 0xff as CGFloat / 255.0,
1400
- alpha: 1.0
1401
- )
1402
- )
1403
- let font = attrs[.font] as? UIFont
1404
- XCTAssertTrue(
1405
- font?.fontDescriptor.symbolicTraits.contains(.traitBold) ?? false,
1406
- "Mention theme should be able to request a bold font"
1407
- )
1408
- }
1409
-
1410
- func testRender_mentionInlineAtomMergesElementMentionThemeOverride() {
1411
- let json = """
1412
- [
1413
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1414
- {
1415
- "type": "opaqueInlineAtom",
1416
- "nodeType": "mention",
1417
- "label": "@Alice",
1418
- "docPos": 1,
1419
- "mentionTheme": {"textColor": "#445566"}
1420
- },
1421
- {"type": "blockEnd"}
1422
- ]
1423
- """
1424
- let theme = EditorTheme(dictionary: [
1425
- "mentions": [
1426
- "textColor": "#112233",
1427
- "backgroundColor": "#ddeeff",
1428
- "fontWeight": "bold",
1429
- ],
1430
- ])
1431
- let result = RenderBridge.renderElements(
1432
- fromJSON: json,
1433
- baseFont: baseFont,
1434
- textColor: textColor,
1435
- theme: theme
1436
- )
1437
-
1438
- XCTAssertEqual(result.string, "@Alice")
1439
-
1440
- let attrs = result.attributes(at: 0, effectiveRange: nil)
1441
- XCTAssertEqual(
1442
- attrs[.foregroundColor] as? UIColor,
1443
- UIColor(
1444
- red: 0x44 as CGFloat / 255.0,
1445
- green: 0x55 as CGFloat / 255.0,
1446
- blue: 0x66 as CGFloat / 255.0,
1447
- alpha: 1.0
1448
- )
1449
- )
1450
- XCTAssertEqual(
1451
- attrs[.backgroundColor] as? UIColor,
1452
- UIColor(
1453
- red: 0xdd as CGFloat / 255.0,
1454
- green: 0xee as CGFloat / 255.0,
1455
- blue: 0xff as CGFloat / 255.0,
1456
- alpha: 1.0
1457
- )
1458
- )
1459
- let font = attrs[.font] as? UIFont
1460
- XCTAssertTrue(
1461
- font?.fontDescriptor.symbolicTraits.contains(.traitBold) ?? false,
1462
- "Mention override should preserve global bold styling. Got: \(String(describing: font))"
1463
- )
1464
- }
1465
-
1466
- func testRender_opaqueBlockAtom() {
1467
- let json = """
1468
- [
1469
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1470
- {"type": "textRun", "text": "Above", "marks": []},
1471
- {"type": "blockEnd"},
1472
- {"type": "opaqueBlockAtom", "label": "widgetBlock", "docPos": 7}
1473
- ]
1474
- """
1475
- let result = RenderBridge.renderElements(
1476
- fromJSON: json,
1477
- baseFont: baseFont,
1478
- textColor: textColor
1479
- )
1480
-
1481
- XCTAssertTrue(
1482
- result.string.contains("[widgetBlock]"),
1483
- "Opaque block atom should render as '[widgetBlock]'. Got: '\(result.string)'"
1484
- )
1485
- }
1486
-
1487
- // MARK: - Theme Rendering
1488
-
1489
- func testRender_themeOverridesParagraphTypography() {
1490
- let json = """
1491
- [
1492
- {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1493
- {"type": "textRun", "text": "Styled", "marks": []},
1494
- {"type": "blockEnd"}
1495
- ]
1496
- """
1497
- let theme = EditorTheme(dictionary: [
1498
- "text": [
1499
- "fontFamily": "Courier",
1500
- "fontSize": 18,
1501
- "color": "#112233",
1502
- ],
1503
- "paragraph": [
1504
- "lineHeight": 28,
1505
- "spacingAfter": 14,
1506
- ],
1507
- ])
1508
-
1509
- let result = RenderBridge.renderElements(
1510
- fromJSON: json,
1511
- baseFont: baseFont,
1512
- textColor: textColor,
1513
- theme: theme
1514
- )
1515
-
1516
- let attrs = result.attributes(at: 0, effectiveRange: nil)
1517
- let font = attrs[.font] as? UIFont
1518
- let color = attrs[.foregroundColor] as? UIColor
1519
- let paragraphStyle = attrs[.paragraphStyle] as? NSParagraphStyle
1520
-
1521
- XCTAssertEqual(font?.pointSize ?? 0, 18, accuracy: 0.1)
1522
- XCTAssertEqual(color, EditorTheme.color(from: "#112233"))
1523
- XCTAssertEqual(paragraphStyle?.minimumLineHeight ?? 0, 28, accuracy: 0.1)
1524
- XCTAssertEqual(paragraphStyle?.paragraphSpacing ?? 0, 14, accuracy: 0.1)
1525
- }
1526
-
1527
- func testRender_themeOverridesSpecificHeadingLevelTypography() {
1528
- let json = """
1529
- [
1530
- {"type": "blockStart", "nodeType": "h2", "depth": 0},
1531
- {"type": "textRun", "text": "Section title", "marks": []},
1532
- {"type": "blockEnd"}
1533
- ]
1534
- """
1535
- let theme = EditorTheme(dictionary: [
1536
- "text": [
1537
- "fontSize": 16,
1538
- "color": "#112233",
1539
- ],
1540
- "headings": [
1541
- "h2": [
1542
- "fontSize": 28,
1543
- "fontWeight": "700",
1544
- "color": "#445566",
1545
- "lineHeight": 34,
1546
- "spacingAfter": 12,
1547
- ],
1548
- "h4": [
1549
- "fontSize": 18,
1550
- "color": "#AA5500",
1551
- ],
1552
- ],
1553
- ])
1554
-
1555
- let result = RenderBridge.renderElements(
1556
- fromJSON: json,
1557
- baseFont: baseFont,
1558
- textColor: textColor,
1559
- theme: theme
1560
- )
1561
-
1562
- let attrs = result.attributes(at: 0, effectiveRange: nil)
1563
- let font = attrs[.font] as? UIFont
1564
- let color = attrs[.foregroundColor] as? UIColor
1565
- let paragraphStyle = attrs[.paragraphStyle] as? NSParagraphStyle
1566
-
1567
- XCTAssertEqual(font?.pointSize ?? 0, 28, accuracy: 0.1)
1568
- XCTAssertTrue(
1569
- font?.fontDescriptor.symbolicTraits.contains(.traitBold) ?? false,
1570
- "Configured h2 heading should resolve to a bold font"
1571
- )
1572
- XCTAssertEqual(color, EditorTheme.color(from: "#445566"))
1573
- XCTAssertEqual(paragraphStyle?.minimumLineHeight ?? 0, 34, accuracy: 0.1)
1574
- XCTAssertEqual(paragraphStyle?.paragraphSpacing ?? 0, 12, accuracy: 0.1)
1575
- }
1576
-
1577
- func testRender_listItemUsesListItemSpacingWhenParagraphSpacingUnset() {
1578
- let json = """
1579
- [
1580
- {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1581
- "listContext": {"ordered": false, "index": 1, "total": 2, "start": 1, "isFirst": true, "isLast": false}},
1582
- {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1583
- {"type": "textRun", "text": "First item", "marks": []},
1584
- {"type": "blockEnd"},
1585
- {"type": "blockEnd"},
1586
- {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1587
- "listContext": {"ordered": false, "index": 2, "total": 2, "start": 1, "isFirst": false, "isLast": true}},
1588
- {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1589
- {"type": "textRun", "text": "Second item", "marks": []},
1590
- {"type": "blockEnd"},
1591
- {"type": "blockEnd"}
1592
- ]
1593
- """
1594
- let theme = EditorTheme(dictionary: [
1595
- "list": [
1596
- "itemSpacing": 14,
1597
- ],
1598
- ])
1599
-
1600
- let result = RenderBridge.renderElements(
1601
- fromJSON: json,
1602
- baseFont: baseFont,
1603
- textColor: textColor,
1604
- theme: theme
1605
- )
1606
-
1607
- let attrs = result.attributes(at: 0, effectiveRange: nil)
1608
- let paragraphStyle = attrs[.paragraphStyle] as? NSParagraphStyle
1609
-
1610
- XCTAssertEqual(paragraphStyle?.paragraphSpacing ?? 0, 14, accuracy: 0.1)
1611
- }
1612
-
1613
- func testRender_listItemSpacingOverridesParagraphSpacingForSiblingListItems() {
1614
- let json = """
1615
- [
1616
- {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1617
- "listContext": {"ordered": false, "index": 1, "total": 2, "start": 1, "isFirst": true, "isLast": false}},
1618
- {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1619
- {"type": "textRun", "text": "First item", "marks": []},
1620
- {"type": "blockEnd"},
1621
- {"type": "blockEnd"},
1622
- {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1623
- "listContext": {"ordered": false, "index": 2, "total": 2, "start": 1, "isFirst": false, "isLast": true}},
1624
- {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1625
- {"type": "textRun", "text": "Second item", "marks": []},
1626
- {"type": "blockEnd"},
1627
- {"type": "blockEnd"}
1628
- ]
1629
- """
1630
- let theme = EditorTheme(dictionary: [
1631
- "paragraph": [
1632
- "spacingAfter": 14,
1633
- ],
1634
- "list": [
1635
- "itemSpacing": 6,
1636
- ],
1637
- ])
1638
-
1639
- let result = RenderBridge.renderElements(
1640
- fromJSON: json,
1641
- baseFont: baseFont,
1642
- textColor: textColor,
1643
- theme: theme
1644
- )
1645
-
1646
- let nsString = result.string as NSString
1647
- let firstRange = nsString.range(of: "First item")
1648
- XCTAssertNotEqual(firstRange.location, NSNotFound)
1649
-
1650
- let attrs = result.attributes(at: firstRange.location, effectiveRange: nil)
1651
- let paragraphStyle = attrs[.paragraphStyle] as? NSParagraphStyle
1652
-
1653
- XCTAssertEqual(paragraphStyle?.paragraphSpacing ?? -1, 6, accuracy: 0.1)
1654
- }
1655
-
1656
- func testRender_nestedFirstListItemDoesNotKeepParentParagraphSpacingWhenItemSpacingIsZero() {
1657
- let json = """
1658
- [
1659
- {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1660
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1661
- {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1662
- {"type": "textRun", "text": "Parent item", "marks": []},
1663
- {"type": "blockEnd"},
1664
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
1665
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1666
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1667
- {"type": "textRun", "text": "Nested item", "marks": []},
1668
- {"type": "blockEnd"},
1669
- {"type": "blockEnd"},
1670
- {"type": "blockEnd"}
1671
- ]
1672
- """
1673
- let theme = EditorTheme(dictionary: [
1674
- "paragraph": [
1675
- "spacingAfter": 14,
1676
- ],
1677
- "list": [
1678
- "itemSpacing": 0,
1679
- ],
1680
- ])
1681
-
1682
- let result = RenderBridge.renderElements(
1683
- fromJSON: json,
1684
- baseFont: baseFont,
1685
- textColor: textColor,
1686
- theme: theme
1687
- )
1688
-
1689
- let nsString = result.string as NSString
1690
- let parentRange = nsString.range(of: "Parent item")
1691
- XCTAssertNotEqual(parentRange.location, NSNotFound)
1692
-
1693
- let attrs = result.attributes(at: parentRange.location, effectiveRange: nil)
1694
- let paragraphStyle = attrs[.paragraphStyle] as? NSParagraphStyle
1695
-
1696
- XCTAssertEqual(paragraphStyle?.paragraphSpacing ?? -1, 0, accuracy: 0.1)
1697
- }
1698
-
1699
- func testRender_nestedSiblingListItemsUseListItemSpacingInsteadOfParagraphSpacing() {
1700
- let json = """
1701
- [
1702
- {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1703
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1704
- {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1705
- {"type": "textRun", "text": "Parent item", "marks": []},
1706
- {"type": "blockEnd"},
1707
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
1708
- "listContext": {"ordered": false, "index": 1, "total": 2, "start": 1, "isFirst": true, "isLast": false}},
1709
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1710
- {"type": "textRun", "text": "Child one", "marks": []},
1711
- {"type": "blockEnd"},
1712
- {"type": "blockEnd"},
1713
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
1714
- "listContext": {"ordered": false, "index": 2, "total": 2, "start": 1, "isFirst": false, "isLast": true}},
1715
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1716
- {"type": "textRun", "text": "Child two", "marks": []},
1717
- {"type": "blockEnd"},
1718
- {"type": "blockEnd"},
1719
- {"type": "blockEnd"}
1720
- ]
1721
- """
1722
- let theme = EditorTheme(dictionary: [
1723
- "paragraph": [
1724
- "spacingAfter": 14,
1725
- ],
1726
- "list": [
1727
- "itemSpacing": 6,
1728
- ],
1729
- ])
1730
-
1731
- let result = RenderBridge.renderElements(
1732
- fromJSON: json,
1733
- baseFont: baseFont,
1734
- textColor: textColor,
1735
- theme: theme
1736
- )
1737
-
1738
- let nsString = result.string as NSString
1739
- let childRange = nsString.range(of: "Child one")
1740
- XCTAssertNotEqual(childRange.location, NSNotFound)
1741
-
1742
- let attrs = result.attributes(at: childRange.location, effectiveRange: nil)
1743
- let paragraphStyle = attrs[.paragraphStyle] as? NSParagraphStyle
1744
-
1745
- XCTAssertEqual(paragraphStyle?.paragraphSpacing ?? -1, 6, accuracy: 0.1)
1746
- }
1747
-
1748
- func testRender_themeOverridesHorizontalRuleMetrics() {
1749
- let json = """
1750
- [
1751
- {"type": "voidBlock", "nodeType": "horizontalRule", "docPos": 0}
1752
- ]
1753
- """
1754
- let theme = EditorTheme(dictionary: [
1755
- "horizontalRule": [
1756
- "color": "#445566",
1757
- "thickness": 3,
1758
- "verticalMargin": 12,
1759
- ],
1760
- ])
1761
-
1762
- let result = RenderBridge.renderElements(
1763
- fromJSON: json,
1764
- baseFont: baseFont,
1765
- textColor: textColor,
1766
- theme: theme
1767
- )
1768
-
1769
- let attachment = result.attribute(.attachment, at: 0, effectiveRange: nil)
1770
- as? HorizontalRuleAttachment
1771
- XCTAssertEqual(attachment?.lineColor, EditorTheme.color(from: "#445566"))
1772
- XCTAssertEqual(attachment?.lineHeight ?? 0, 3, accuracy: 0.1)
1773
- XCTAssertEqual(attachment?.verticalPadding ?? 0, 12, accuracy: 0.1)
1774
- }
1775
-
1776
- func testListMarkerDrawingRectUsesParagraphLineBox() {
1777
- let markerFont = baseFont
1778
- let lineFragmentRect = CGRect(x: 24, y: 10, width: 160, height: 28)
1779
- let usedRect = CGRect(x: 24, y: 14, width: 160, height: 19)
1780
- let baselineY: CGFloat = 28.140625
1781
- let rect = EditorLayoutManager.markerDrawingRect(
1782
- usedRect: usedRect,
1783
- lineFragmentRect: lineFragmentRect,
1784
- markerWidth: 20,
1785
- baselineY: baselineY,
1786
- markerFont: markerFont,
1787
- origin: CGPoint(x: 0, y: 0)
1788
- )
1789
- let typographicHeight = markerFont.ascender - markerFont.descender
1790
- let leading = max(markerFont.lineHeight - typographicHeight, 0)
1791
- let expectedY = baselineY - markerFont.ascender - (leading / 2.0)
1792
-
1793
- XCTAssertEqual(rect.origin.x, 4, accuracy: 0.1)
1794
- XCTAssertEqual(rect.origin.y, expectedY, accuracy: 0.1)
1795
- XCTAssertEqual(rect.height, markerFont.lineHeight, accuracy: 0.1)
1796
- }
1797
-
1798
- func testListMarkerDrawingRectUsesFullLineFragmentWhenGlyphsUseShorterRect() {
1799
- let markerFont = baseFont.withSize(18)
1800
- let lineFragmentRect = CGRect(x: 24, y: 8, width: 160, height: 32)
1801
- let usedRect = CGRect(x: 24, y: 14, width: 160, height: 17)
1802
- let baselineY: CGFloat = 30.140625
1803
- let rect = EditorLayoutManager.markerDrawingRect(
1804
- usedRect: usedRect,
1805
- lineFragmentRect: lineFragmentRect,
1806
- markerWidth: 20,
1807
- baselineY: baselineY,
1808
- markerFont: markerFont,
1809
- origin: CGPoint(x: 0, y: 0)
1810
- )
1811
- let typographicHeight = markerFont.ascender - markerFont.descender
1812
- let leading = max(markerFont.lineHeight - typographicHeight, 0)
1813
- let expectedY = baselineY - markerFont.ascender - (leading / 2.0)
1814
-
1815
- XCTAssertEqual(rect.origin.x, 4, accuracy: 0.1)
1816
- XCTAssertEqual(rect.origin.y, expectedY, accuracy: 0.1)
1817
- XCTAssertEqual(rect.height, markerFont.lineHeight, accuracy: 0.1)
1818
- }
1819
-
1820
- func testListMarkerDrawingRectFallsBackToLineFragmentWhenUsedRectIsEmpty() {
1821
- let markerFont = baseFont
1822
- let lineFragmentRect = CGRect(x: 24, y: 10, width: 160, height: 28)
1823
- let rect = EditorLayoutManager.markerDrawingRect(
1824
- usedRect: CGRect(x: 24, y: 10, width: 160, height: 0),
1825
- lineFragmentRect: lineFragmentRect,
1826
- markerWidth: 20,
1827
- baselineY: 28.140625,
1828
- markerFont: markerFont,
1829
- origin: CGPoint(x: 0, y: 0)
1830
- )
1831
- let typographicHeight = markerFont.ascender - markerFont.descender
1832
- let leading = max(markerFont.lineHeight - typographicHeight, 0)
1833
- let expectedY = 28.140625 - markerFont.ascender - (leading / 2.0)
1834
-
1835
- XCTAssertEqual(rect.origin.x, 4, accuracy: 0.1)
1836
- XCTAssertEqual(rect.origin.y, expectedY, accuracy: 0.1)
1837
- }
1838
-
1839
- func testOrderedMarkerDrawingOriginAlignsToBaselineWithoutParagraphLineHeight() {
1840
- let markerFont = baseFont
1841
- let lineFragmentRect = CGRect(x: 24, y: 8, width: 160, height: 32)
1842
- let usedRect = CGRect(x: 24, y: 14, width: 160, height: 19)
1843
- let baselineY: CGFloat = 30.140625
1844
- let markerText = "12. "
1845
-
1846
- let point = EditorLayoutManager.orderedMarkerDrawingOrigin(
1847
- usedRect: usedRect,
1848
- lineFragmentRect: lineFragmentRect,
1849
- markerWidth: 20,
1850
- baselineY: baselineY,
1851
- markerFont: markerFont,
1852
- markerText: markerText,
1853
- origin: .zero
1854
- )
1855
- let markerWidth = ceil(("12." as NSString).size(withAttributes: [
1856
- .font: markerFont,
1857
- ]).width)
1858
-
1859
- XCTAssertEqual(point.x, usedRect.minX - 4.0 - markerWidth, accuracy: 0.1)
1860
- XCTAssertEqual(point.y, baselineY - markerFont.ascender, accuracy: 0.1)
1861
- }
1862
-
1863
- func testOrderedMarkerDrawingOriginIgnoresTrailingSpaceForHorizontalAlignment() {
1864
- let markerFont = baseFont
1865
- let lineFragmentRect = CGRect(x: 24, y: 8, width: 160, height: 32)
1866
- let usedRect = CGRect(x: 24, y: 14, width: 160, height: 19)
1867
- let baselineY: CGFloat = 30.140625
1868
- let markerText = "12. "
1869
-
1870
- let point = EditorLayoutManager.orderedMarkerDrawingOrigin(
1871
- usedRect: usedRect,
1872
- lineFragmentRect: lineFragmentRect,
1873
- markerWidth: 20,
1874
- baselineY: baselineY,
1875
- markerFont: markerFont,
1876
- markerText: markerText,
1877
- origin: .zero
1878
- )
1879
- let visibleWidth = ceil(("12." as NSString).size(withAttributes: [
1880
- .font: markerFont,
1881
- ]).width)
1882
- let fullWidth = ceil((markerText as NSString).size(withAttributes: [
1883
- .font: markerFont,
1884
- ]).width)
1885
-
1886
- XCTAssertEqual(point.x, usedRect.minX - 4.0 - visibleWidth, accuracy: 0.1)
1887
- XCTAssertNotEqual(point.x, usedRect.minX - 4.0 - fullWidth, accuracy: 0.1)
1888
- }
1889
-
1890
- func testListMarkerBaseFontUsesParagraphFontInsteadOfLeadingBoldRun() {
1891
- let json = """
1892
- [
1893
- {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1894
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1895
- {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1896
- {"type": "textRun", "text": "Bold", "marks": ["bold"]},
1897
- {"type": "textRun", "text": " start", "marks": []},
1898
- {"type": "blockEnd"},
1899
- {"type": "blockEnd"}
1900
- ]
1901
- """
1902
-
1903
- let result = RenderBridge.renderElements(
1904
- fromJSON: json,
1905
- baseFont: baseFont,
1906
- textColor: textColor
1907
- )
1908
-
1909
- let attrs = result.attributes(at: 0, effectiveRange: nil)
1910
- let textFont = attrs[.font] as? UIFont
1911
- let markerBaseFont = attrs[RenderBridgeAttributes.listMarkerBaseFont] as? UIFont
1912
-
1913
- XCTAssertTrue(
1914
- textFont?.fontDescriptor.symbolicTraits.contains(.traitBold) ?? false,
1915
- "First text run should still be bold"
1916
- )
1917
- XCTAssertNotNil(markerBaseFont, "List marker should carry its paragraph base font")
1918
- XCTAssertFalse(
1919
- markerBaseFont?.fontDescriptor.symbolicTraits.contains(.traitBold) ?? false,
1920
- "Marker base font should ignore inline bold marks on the first run"
1921
- )
1922
- XCTAssertEqual(markerBaseFont?.pointSize ?? 0, baseFont.pointSize, accuracy: 0.1)
1923
- }
1924
-
1925
- func testListMarkerParagraphStylePreservesThemedLineHeight() {
1926
- let sourceStyle = NSMutableParagraphStyle()
1927
- sourceStyle.minimumLineHeight = 28
1928
- sourceStyle.maximumLineHeight = 28
1929
-
1930
- let markerStyle = EditorLayoutManager.markerParagraphStyle(from: [
1931
- .paragraphStyle: sourceStyle,
1932
- ])
1933
-
1934
- XCTAssertEqual(markerStyle.minimumLineHeight, 28, accuracy: 0.1)
1935
- XCTAssertEqual(markerStyle.maximumLineHeight, 28, accuracy: 0.1)
1936
- XCTAssertEqual(markerStyle.alignment, .right)
1937
- XCTAssertEqual(markerStyle.lineBreakMode, .byClipping)
1938
- XCTAssertEqual(markerStyle.firstLineHeadIndent, 0, accuracy: 0.1)
1939
- XCTAssertEqual(markerStyle.headIndent, 0, accuracy: 0.1)
1940
- XCTAssertEqual(markerStyle.tailIndent, 0, accuracy: 0.1)
1941
- }
1942
-
1943
- func testUnorderedBulletDrawingRectCentersBulletOnTextMidline() {
1944
- let rect = EditorLayoutManager.unorderedBulletDrawingRect(
1945
- usedRect: CGRect(x: 24, y: 14, width: 160, height: 19),
1946
- lineFragmentRect: CGRect(x: 24, y: 8, width: 160, height: 32),
1947
- markerWidth: 20,
1948
- baselineY: 28.140625,
1949
- baseFont: baseFont,
1950
- markerScale: 2,
1951
- origin: .zero
1952
- )
1953
- let targetMidline = 28.140625 - ((baseFont.xHeight > 0 ? baseFont.xHeight : baseFont.capHeight) / 2.0)
1954
-
1955
- XCTAssertEqual(rect.midY, targetMidline, accuracy: 0.1)
1956
- XCTAssertGreaterThan(rect.width, 0)
1957
- XCTAssertGreaterThan(rect.height, 0)
1958
- }
1959
-
1960
- func testUnorderedBulletDrawingRectPreservesTextSideGapAcrossMarkerScales() {
1961
- let usedRect = CGRect(x: 24, y: 14, width: 160, height: 19)
1962
- let lineFragmentRect = CGRect(x: 24, y: 8, width: 160, height: 32)
1963
- let baselineY: CGFloat = 28.140625
1964
-
1965
- let normalRect = EditorLayoutManager.unorderedBulletDrawingRect(
1966
- usedRect: usedRect,
1967
- lineFragmentRect: lineFragmentRect,
1968
- markerWidth: 20,
1969
- baselineY: baselineY,
1970
- baseFont: baseFont,
1971
- markerScale: 1,
1972
- origin: .zero
1973
- )
1974
- let scaledRect = EditorLayoutManager.unorderedBulletDrawingRect(
1975
- usedRect: usedRect,
1976
- lineFragmentRect: lineFragmentRect,
1977
- markerWidth: 20,
1978
- baselineY: baselineY,
1979
- baseFont: baseFont,
1980
- markerScale: 2,
1981
- origin: .zero
1982
- )
1983
-
1984
- XCTAssertEqual(usedRect.minX - normalRect.maxX, usedRect.minX - scaledRect.maxX, accuracy: 0.1)
1985
- XCTAssertEqual(usedRect.minX - scaledRect.maxX, LayoutConstants.listMarkerTextGap, accuracy: 0.1)
1986
- }
1987
-
1988
- func testUnorderedBulletDrawingRectReproducesTallLineHeightListItem() {
1989
- let theme = EditorTheme(dictionary: [
1990
- "paragraph": [
1991
- "lineHeight": 32,
1992
- ],
1993
- "list": [
1994
- "markerScale": 2,
1995
- ],
1996
- ])
1997
- let json = """
1998
- [
1999
- {"type": "blockStart", "nodeType": "listItem", "depth": 1,
2000
- "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
2001
- {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
2002
- {"type": "textRun", "text": "Bullet item", "marks": []},
2003
- {"type": "blockEnd"},
2004
- {"type": "blockEnd"}
2005
- ]
2006
- """
2007
- let result = RenderBridge.renderElements(
2008
- fromJSON: json,
2009
- baseFont: baseFont,
2010
- textColor: textColor,
2011
- theme: theme
2012
- )
2013
-
2014
- let attrs = result.attributes(at: 0, effectiveRange: nil)
2015
- let textFont = attrs[.font] as? UIFont ?? baseFont
2016
- let paragraphStyle = attrs[.paragraphStyle] as? NSParagraphStyle
2017
- let markerScale = (attrs[RenderBridgeAttributes.listMarkerScale] as? NSNumber)
2018
- .map { CGFloat(truncating: $0) }
2019
- ?? 1
2020
- let bulletRect = EditorLayoutManager.unorderedBulletDrawingRect(
2021
- usedRect: CGRect(x: 24, y: 14, width: 160, height: 19),
2022
- lineFragmentRect: CGRect(x: 24, y: 8, width: 160, height: 32),
2023
- markerWidth: 20,
2024
- baselineY: 28.140625,
2025
- baseFont: textFont,
2026
- markerScale: markerScale,
2027
- origin: .zero
2028
- )
2029
- let expectedCenterY = 28.140625 - ((textFont.xHeight > 0 ? textFont.xHeight : textFont.capHeight) / 2.0)
2030
-
2031
- XCTAssertNotNil(attrs[RenderBridgeAttributes.listMarkerContext])
2032
- XCTAssertEqual(paragraphStyle?.minimumLineHeight ?? 0, 32, accuracy: 0.1)
2033
- XCTAssertEqual(paragraphStyle?.maximumLineHeight ?? 0, 32, accuracy: 0.1)
2034
- XCTAssertEqual(bulletRect.midY, expectedCenterY, accuracy: 0.1)
2035
- XCTAssertGreaterThan(bulletRect.width, 0)
2036
- XCTAssertGreaterThan(bulletRect.height, 0)
2037
- XCTAssertEqual(bulletRect.width, bulletRect.height, accuracy: 0.1)
2038
- }
2039
-
2040
- func testOrderedListMarkerBaselineOffsetIsNeutral() {
2041
- let orderedContext: [String: Any] = ["ordered": true]
2042
-
2043
- let offset = EditorLayoutManager.markerBaselineOffset(
2044
- for: orderedContext,
2045
- baseFont: baseFont,
2046
- markerFont: baseFont
2047
- )
2048
-
2049
- XCTAssertEqual(offset, 0, accuracy: 0.1)
2050
- }
2051
-
2052
- func testMarkerBaseFontPrefersStoredParagraphFont() {
2053
- let boldDescriptor = baseFont.fontDescriptor.withSymbolicTraits(.traitBold)
2054
- ?? baseFont.fontDescriptor
2055
- let boldFont = UIFont(descriptor: boldDescriptor, size: baseFont.pointSize)
2056
- let resolved = EditorLayoutManager.markerBaseFont(from: [
2057
- .font: boldFont,
2058
- RenderBridgeAttributes.listMarkerBaseFont: baseFont,
2059
- ])
2060
-
2061
- XCTAssertFalse(
2062
- resolved.fontDescriptor.symbolicTraits.contains(.traitBold),
2063
- "Stored paragraph font should win over the inline bold run font"
2064
- )
2065
- XCTAssertEqual(resolved.pointSize, baseFont.pointSize, accuracy: 0.1)
2066
- }
2067
-
2068
- // MARK: - HorizontalRuleAttachment
2069
-
2070
- func testHorizontalRuleAttachment_bounds() {
2071
- let attachment = HorizontalRuleAttachment()
2072
- let proposedRect = CGRect(x: 0, y: 0, width: 320, height: 20)
2073
- let bounds = attachment.attachmentBounds(
2074
- for: nil,
2075
- proposedLineFragment: proposedRect,
2076
- glyphPosition: .zero,
2077
- characterIndex: 0
2078
- )
2079
-
2080
- XCTAssertEqual(
2081
- bounds.width, 320,
2082
- "Attachment width should match proposed line fragment width"
2083
- )
2084
- let expectedHeight = 1.0 + (8.0 * 2) // line + padding
2085
- XCTAssertEqual(
2086
- bounds.height, expectedHeight,
2087
- "Attachment height should be line height + 2 * vertical padding"
2088
- )
2089
- }
2090
-
2091
- func testHorizontalRuleAttachment_rendersImage() {
2092
- let attachment = HorizontalRuleAttachment()
2093
- attachment.lineColor = .red
2094
- let bounds = CGRect(x: 0, y: 0, width: 200, height: 17)
2095
- let image = attachment.image(
2096
- forBounds: bounds,
2097
- textContainer: nil,
2098
- characterIndex: 0
2099
- )
2100
- XCTAssertNotNil(image, "HorizontalRuleAttachment should produce a non-nil image")
2101
- }
2102
-
2103
- // MARK: - Height Measurement
2104
-
2105
- func testMeasureHeightForSingleParagraph() {
2106
- let renderJSON = """
2107
- [
2108
- {"type":"blockStart","nodeType":"paragraph","depth":0},
2109
- {"type":"textRun","text":"Hello world"},
2110
- {"type":"blockEnd"}
2111
- ]
2112
- """
2113
- let height = RenderBridge.measureHeight(
2114
- forRenderJSON: renderJSON,
2115
- themeJSON: nil,
2116
- width: 375
2117
- )
2118
- XCTAssertGreaterThan(height, 0, "Single paragraph should have positive height")
2119
- }
2120
-
2121
- func testMeasureHeightForEmptyContent() {
2122
- let renderJSON = "[]"
2123
- let height = RenderBridge.measureHeight(
2124
- forRenderJSON: renderJSON,
2125
- themeJSON: nil,
2126
- width: 375
2127
- )
2128
- XCTAssertEqual(height, 0, "Empty content should have zero height")
2129
- }
2130
-
2131
- func testMeasureHeightRespectsWidth() {
2132
- let longText = String(repeating: "word ", count: 100)
2133
- let renderJSON = """
2134
- [
2135
- {"type":"blockStart","nodeType":"paragraph","depth":0},
2136
- {"type":"textRun","text":"\(longText)"},
2137
- {"type":"blockEnd"}
2138
- ]
2139
- """
2140
- let narrowHeight = RenderBridge.measureHeight(
2141
- forRenderJSON: renderJSON,
2142
- themeJSON: nil,
2143
- width: 100
2144
- )
2145
- let wideHeight = RenderBridge.measureHeight(
2146
- forRenderJSON: renderJSON,
2147
- themeJSON: nil,
2148
- width: 1000
2149
- )
2150
- XCTAssertGreaterThan(narrowHeight, wideHeight, "Narrower width should produce taller height")
2151
- }
2152
-
2153
- func testMeasureHeightRespectsThemeFontSize() {
2154
- let renderJSON = """
2155
- [
2156
- {"type":"blockStart","nodeType":"paragraph","depth":0},
2157
- {"type":"textRun","text":"Hello world"},
2158
- {"type":"blockEnd"}
2159
- ]
2160
- """
2161
- let smallTheme = """
2162
- {"text":{"fontSize":12}}
2163
- """
2164
- let largeTheme = """
2165
- {"text":{"fontSize":32}}
2166
- """
2167
- let smallHeight = RenderBridge.measureHeight(
2168
- forRenderJSON: renderJSON,
2169
- themeJSON: smallTheme,
2170
- width: 375
2171
- )
2172
- let largeHeight = RenderBridge.measureHeight(
2173
- forRenderJSON: renderJSON,
2174
- themeJSON: largeTheme,
2175
- width: 375
2176
- )
2177
- XCTAssertGreaterThan(largeHeight, smallHeight, "Larger font should produce taller height")
2178
- }
2179
-
2180
- func testMeasureHeightRespectsContentInsets() {
2181
- let renderJSON = """
2182
- [
2183
- {"type":"blockStart","nodeType":"paragraph","depth":0},
2184
- {"type":"textRun","text":"Hello world"},
2185
- {"type":"blockEnd"}
2186
- ]
2187
- """
2188
- let noInsetHeight = RenderBridge.measureHeight(
2189
- forRenderJSON: renderJSON,
2190
- themeJSON: nil,
2191
- width: 375
2192
- )
2193
- let insetTheme = """
2194
- {"contentInsets":{"top":20,"bottom":20}}
2195
- """
2196
- let insetHeight = RenderBridge.measureHeight(
2197
- forRenderJSON: renderJSON,
2198
- themeJSON: insetTheme,
2199
- width: 375
2200
- )
2201
- XCTAssertEqual(insetHeight, noInsetHeight + 40, accuracy: 1.0, "Content insets should add to height")
2202
- }
2203
-
2204
- func testRender_imageAttachmentHonorsPreferredDimensions() {
2205
- let json = """
2206
- [
2207
- {"type": "voidBlock", "nodeType": "image", "docPos": 1, "attrs": {
2208
- "src": "https://example.com/cat.png",
2209
- "width": 140,
2210
- "height": 80
2211
- }}
2212
- ]
2213
- """
2214
- let result = RenderBridge.renderElements(
2215
- fromJSON: json,
2216
- baseFont: baseFont,
2217
- textColor: textColor
2218
- )
2219
-
2220
- XCTAssertEqual(result.string, LayoutConstants.objectReplacementCharacter)
2221
-
2222
- let attrs = result.attributes(at: 0, effectiveRange: nil)
2223
- let attachment = attrs[.attachment] as? NSTextAttachment
2224
- XCTAssertNotNil(attachment, "Image render should produce an attachment")
2225
-
2226
- let bounds = attachment?.attachmentBounds(
2227
- for: nil,
2228
- proposedLineFragment: CGRect(x: 0, y: 0, width: 320, height: 24),
2229
- glyphPosition: .zero,
2230
- characterIndex: 0
2231
- )
2232
-
2233
- XCTAssertEqual(bounds?.width ?? 0, 140, accuracy: 0.1)
2234
- XCTAssertEqual(bounds?.height ?? 0, 80, accuracy: 0.1)
2235
- }
2236
- }