@openeditor/react-native-prose-editor 0.0.2

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 (91) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +180 -0
  3. package/android/build.gradle +69 -0
  4. package/android/consumer-rules.pro +18 -0
  5. package/android/src/androidTest/AndroidManifest.xml +8 -0
  6. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +53 -0
  7. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +241 -0
  8. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +338 -0
  9. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +789 -0
  10. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +350 -0
  11. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +5 -0
  12. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  13. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  14. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +50 -0
  15. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +135 -0
  16. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +4527 -0
  17. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  18. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +999 -0
  19. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +474 -0
  20. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
  21. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +3137 -0
  22. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +561 -0
  23. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +315 -0
  24. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +1413 -0
  25. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +133 -0
  26. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +315 -0
  27. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +2172 -0
  28. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +399 -0
  29. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +482 -0
  30. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +137 -0
  31. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +321 -0
  32. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +4202 -0
  33. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +2581 -0
  34. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +27 -0
  35. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +197 -0
  36. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +84 -0
  37. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +544 -0
  38. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +461 -0
  39. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +2054 -0
  40. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +1367 -0
  41. package/app.plugin.js +62 -0
  42. package/dist/EditorTheme.d.ts +113 -0
  43. package/dist/EditorTheme.js +29 -0
  44. package/dist/EditorToolbar.d.ts +191 -0
  45. package/dist/EditorToolbar.js +1095 -0
  46. package/dist/NativeEditorBridge.d.ts +325 -0
  47. package/dist/NativeEditorBridge.js +933 -0
  48. package/dist/NativeProseViewer.d.ts +53 -0
  49. package/dist/NativeProseViewer.js +480 -0
  50. package/dist/NativeRichTextEditor.d.ts +183 -0
  51. package/dist/NativeRichTextEditor.js +2574 -0
  52. package/dist/YjsCollaboration.d.ts +90 -0
  53. package/dist/YjsCollaboration.js +743 -0
  54. package/dist/addons.d.ts +100 -0
  55. package/dist/addons.js +82 -0
  56. package/dist/heightCache.d.ts +6 -0
  57. package/dist/heightCache.js +45 -0
  58. package/dist/index.d.ts +10 -0
  59. package/dist/index.js +30 -0
  60. package/dist/schemas.d.ts +37 -0
  61. package/dist/schemas.js +263 -0
  62. package/dist/useNativeEditor.d.ts +42 -0
  63. package/dist/useNativeEditor.js +124 -0
  64. package/expo-module.config.json +9 -0
  65. package/ios/EditorAddons.swift +327 -0
  66. package/ios/EditorCore.xcframework/Info.plist +44 -0
  67. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  68. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  69. package/ios/EditorLayoutManager.swift +774 -0
  70. package/ios/EditorTheme.swift +515 -0
  71. package/ios/Generated_editor_core.swift +1513 -0
  72. package/ios/NativeEditorExpoView.swift +3593 -0
  73. package/ios/NativeEditorModule.swift +624 -0
  74. package/ios/NativeProseViewerExpoView.swift +276 -0
  75. package/ios/PositionBridge.swift +558 -0
  76. package/ios/ReactNativeProseEditor.podspec +49 -0
  77. package/ios/RenderBridge.swift +1708 -0
  78. package/ios/RichTextEditorView.swift +6150 -0
  79. package/ios/Tests/NativePerformanceTests.swift +687 -0
  80. package/ios/Tests/PositionBridgeTests.swift +706 -0
  81. package/ios/Tests/RenderBridgeTests.swift +2236 -0
  82. package/ios/Tests/RichTextEditorViewTests.swift +6548 -0
  83. package/ios/editor_coreFFI/editor_coreFFI.h +1289 -0
  84. package/ios/editor_coreFFI/module.modulemap +7 -0
  85. package/ios/editor_coreFFI.h +904 -0
  86. package/ios/editor_coreFFI.modulemap +7 -0
  87. package/package.json +74 -0
  88. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  89. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  90. package/rust/android/x86_64/libeditor_core.so +0 -0
  91. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2563 -0
@@ -0,0 +1,2054 @@
1
+ package com.openeditor.editor
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.Color
5
+ import android.graphics.Paint
6
+ import android.graphics.Typeface
7
+ import android.text.Annotation
8
+ import android.text.Layout
9
+ import android.text.Spanned
10
+ import android.text.SpannableStringBuilder
11
+ import android.text.StaticLayout
12
+ import android.text.TextPaint
13
+ import android.util.Base64
14
+ import android.view.View
15
+ import android.widget.TextView
16
+ import kotlin.math.abs
17
+ import android.text.style.AbsoluteSizeSpan
18
+ import android.text.style.BackgroundColorSpan
19
+ import android.text.style.ForegroundColorSpan
20
+ import android.text.style.LeadingMarginSpan
21
+ import android.text.style.StrikethroughSpan
22
+ import android.text.style.StyleSpan
23
+ import android.text.style.TypefaceSpan
24
+ import android.text.style.URLSpan
25
+ import android.text.style.UnderlineSpan
26
+ import org.junit.Assert.assertEquals
27
+ import org.junit.Assert.assertFalse
28
+ import org.junit.Assert.assertNotNull
29
+ import org.junit.Assert.assertTrue
30
+ import org.junit.Assert.fail
31
+ import org.junit.Test
32
+ import org.junit.runner.RunWith
33
+ import org.robolectric.RobolectricTestRunner
34
+ import org.robolectric.annotation.Config
35
+ import java.util.concurrent.CountDownLatch
36
+ import java.util.concurrent.TimeUnit
37
+ import java.util.concurrent.atomic.AtomicInteger
38
+
39
+ /**
40
+ * Unit tests for [RenderBridge] — conversion of RenderElement JSON into
41
+ * [SpannableStringBuilder] with appropriate spans.
42
+ *
43
+ * Uses Robolectric to provide Android framework classes (SpannableStringBuilder,
44
+ * span types, etc.) in a JVM test environment.
45
+ *
46
+ * NOTE: Robolectric must be in the test dependencies for these to run:
47
+ * ```gradle
48
+ * testImplementation("org.robolectric:robolectric:4.11.1")
49
+ * ```
50
+ *
51
+ * These tests mirror the iOS RenderBridgeTests.swift test suite.
52
+ */
53
+ @RunWith(RobolectricTestRunner::class)
54
+ @Config(sdk = [34])
55
+ class RenderBridgeTest {
56
+
57
+ // ── Test Fixtures ───────────────────────────────────────────────────
58
+
59
+ private val baseFontSize = 16f
60
+ private val textColor = Color.BLACK
61
+
62
+ @Test
63
+ fun `viewer empty collapse detects documents with only empty top-level paragraphs`() {
64
+ val json = """
65
+ [
66
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
67
+ {"type": "textRun", "text": "\u200B", "marks": []},
68
+ {"type": "blockEnd"},
69
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
70
+ {"type": "textRun", "text": "", "marks": []},
71
+ {"type": "blockEnd"}
72
+ ]
73
+ """.trimIndent()
74
+
75
+ assertTrue(NativeProseViewerExpoView.renderJsonContainsOnlyEmptyParagraphs(json))
76
+ }
77
+
78
+ @Test
79
+ fun `viewer empty collapse keeps visible rendered content measurable`() {
80
+ val json = """
81
+ [
82
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
83
+ {"type": "textRun", "text": "Hello", "marks": []},
84
+ {"type": "blockEnd"}
85
+ ]
86
+ """.trimIndent()
87
+
88
+ assertFalse(NativeProseViewerExpoView.renderJsonContainsOnlyEmptyParagraphs(json))
89
+ }
90
+
91
+ @Test
92
+ fun `viewer empty collapse keeps non-paragraph rendered blocks measurable`() {
93
+ val json = """
94
+ [
95
+ {"type": "voidBlock", "nodeType": "image", "docPos": 1, "attrs": {}}
96
+ ]
97
+ """.trimIndent()
98
+
99
+ assertFalse(NativeProseViewerExpoView.renderJsonContainsOnlyEmptyParagraphs(json))
100
+ }
101
+
102
+ // ── Plain Text Rendering ────────────────────────────────────────────
103
+
104
+ /** A single paragraph with unstyled text should produce the text content. */
105
+ @Test
106
+ fun `render - plain paragraph`() {
107
+ val json = """
108
+ [
109
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
110
+ {"type": "textRun", "text": "Hello, world!", "marks": []},
111
+ {"type": "blockEnd"}
112
+ ]
113
+ """.trimIndent()
114
+
115
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
116
+
117
+ assertEquals(
118
+ "Plain paragraph should render as the text content",
119
+ "Hello, world!", result.toString()
120
+ )
121
+
122
+ // Verify foreground color span is present.
123
+ val colorSpans = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
124
+ assertTrue(
125
+ "Should have at least one ForegroundColorSpan",
126
+ colorSpans.isNotEmpty()
127
+ )
128
+ }
129
+
130
+ // ── Bold Text Rendering ─────────────────────────────────────────────
131
+
132
+ /** Bold mark should produce a StyleSpan with Typeface.BOLD. */
133
+ @Test
134
+ fun `render - bold text`() {
135
+ val json = """
136
+ [
137
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
138
+ {"type": "textRun", "text": "bold text", "marks": ["bold"]},
139
+ {"type": "blockEnd"}
140
+ ]
141
+ """.trimIndent()
142
+
143
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
144
+
145
+ assertEquals("bold text", result.toString())
146
+
147
+ val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
148
+ assertTrue("Should have a StyleSpan", styleSpans.isNotEmpty())
149
+
150
+ val boldSpan = styleSpans.find { it.style == Typeface.BOLD }
151
+ assertNotNull(
152
+ "Should have a BOLD StyleSpan. Styles found: ${styleSpans.map { it.style }}",
153
+ boldSpan
154
+ )
155
+ }
156
+
157
+ // ── Italic Text Rendering ───────────────────────────────────────────
158
+
159
+ @Test
160
+ fun `render - italic text`() {
161
+ val json = """
162
+ [
163
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
164
+ {"type": "textRun", "text": "italic text", "marks": ["italic"]},
165
+ {"type": "blockEnd"}
166
+ ]
167
+ """.trimIndent()
168
+
169
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
170
+
171
+ assertEquals("italic text", result.toString())
172
+
173
+ val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
174
+ val italicSpan = styleSpans.find { it.style == Typeface.ITALIC }
175
+ assertNotNull(
176
+ "Should have an ITALIC StyleSpan. Styles found: ${styleSpans.map { it.style }}",
177
+ italicSpan
178
+ )
179
+ }
180
+
181
+ // ── Bold + Italic Combined ──────────────────────────────────────────
182
+
183
+ @Test
184
+ fun `render - bold italic`() {
185
+ val json = """
186
+ [
187
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
188
+ {"type": "textRun", "text": "bold italic", "marks": ["bold", "italic"]},
189
+ {"type": "blockEnd"}
190
+ ]
191
+ """.trimIndent()
192
+
193
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
194
+
195
+ val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
196
+ val boldItalicSpan = styleSpans.find { it.style == Typeface.BOLD_ITALIC }
197
+ assertNotNull(
198
+ "Should have a BOLD_ITALIC StyleSpan. Styles found: ${styleSpans.map { it.style }}",
199
+ boldItalicSpan
200
+ )
201
+ }
202
+
203
+ // ── Underline ───────────────────────────────────────────────────────
204
+
205
+ @Test
206
+ fun `render - underline`() {
207
+ val json = """
208
+ [
209
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
210
+ {"type": "textRun", "text": "underlined", "marks": ["underline"]},
211
+ {"type": "blockEnd"}
212
+ ]
213
+ """.trimIndent()
214
+
215
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
216
+
217
+ assertEquals("underlined", result.toString())
218
+
219
+ val underlineSpans = result.getSpans(0, result.length, UnderlineSpan::class.java)
220
+ assertTrue(
221
+ "Should have an UnderlineSpan",
222
+ underlineSpans.isNotEmpty()
223
+ )
224
+ }
225
+
226
+ // ── Strikethrough ───────────────────────────────────────────────────
227
+
228
+ @Test
229
+ fun `render - strikethrough`() {
230
+ val json = """
231
+ [
232
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
233
+ {"type": "textRun", "text": "struck", "marks": ["strike"]},
234
+ {"type": "blockEnd"}
235
+ ]
236
+ """.trimIndent()
237
+
238
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
239
+
240
+ assertEquals("struck", result.toString())
241
+
242
+ val strikeSpans = result.getSpans(0, result.length, StrikethroughSpan::class.java)
243
+ assertTrue(
244
+ "Should have a StrikethroughSpan",
245
+ strikeSpans.isNotEmpty()
246
+ )
247
+ }
248
+
249
+ // ── Code Mark (Monospace) ───────────────────────────────────────────
250
+
251
+ @Test
252
+ fun `render - code inline`() {
253
+ val json = """
254
+ [
255
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
256
+ {"type": "textRun", "text": "code", "marks": ["code"]},
257
+ {"type": "blockEnd"}
258
+ ]
259
+ """.trimIndent()
260
+
261
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
262
+
263
+ assertEquals("code", result.toString())
264
+
265
+ val typefaceSpans = result.getSpans(0, result.length, TypefaceSpan::class.java)
266
+ val monoSpan = typefaceSpans.find { it.family == "monospace" }
267
+ assertNotNull(
268
+ "Code mark should produce monospace TypefaceSpan. " +
269
+ "Families found: ${typefaceSpans.map { it.family }}",
270
+ monoSpan
271
+ )
272
+
273
+ val bgSpans = result.getSpans(0, result.length, BackgroundColorSpan::class.java)
274
+ assertTrue(
275
+ "Code mark should have a background color span",
276
+ bgSpans.isNotEmpty()
277
+ )
278
+ }
279
+
280
+ // ── Hard Break (Void Inline) ────────────────────────────────────────
281
+
282
+ /** A hardBreak void inline should render as a newline character. */
283
+ @Test
284
+ fun `render - hard break`() {
285
+ val json = """
286
+ [
287
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
288
+ {"type": "textRun", "text": "Line 1", "marks": []},
289
+ {"type": "voidInline", "nodeType": "hardBreak", "docPos": 7},
290
+ {"type": "textRun", "text": "Line 2", "marks": []},
291
+ {"type": "blockEnd"}
292
+ ]
293
+ """.trimIndent()
294
+
295
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
296
+
297
+ assertEquals(
298
+ "Hard break should render as newline. Got: '${result}'",
299
+ "Line 1\nLine 2", result.toString()
300
+ )
301
+ }
302
+
303
+ // ── Horizontal Rule (Void Block) ────────────────────────────────────
304
+
305
+ /** A horizontalRule should render as FFFC with a HorizontalRuleSpan. */
306
+ @Test
307
+ fun `render - horizontal rule`() {
308
+ val json = """
309
+ [
310
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
311
+ {"type": "textRun", "text": "Above", "marks": []},
312
+ {"type": "blockEnd"},
313
+ {"type": "voidBlock", "nodeType": "horizontalRule", "docPos": 7},
314
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
315
+ {"type": "textRun", "text": "Below", "marks": []},
316
+ {"type": "blockEnd"}
317
+ ]
318
+ """.trimIndent()
319
+
320
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
321
+
322
+ val string = result.toString()
323
+ assertTrue(
324
+ "Horizontal rule should contain object replacement character. Got: '$string'",
325
+ string.contains("\uFFFC")
326
+ )
327
+
328
+ val hrSpans = result.getSpans(0, result.length, HorizontalRuleSpan::class.java)
329
+ assertTrue(
330
+ "Should have a HorizontalRuleSpan",
331
+ hrSpans.isNotEmpty()
332
+ )
333
+
334
+ val replacementMetrics = Paint.FontMetricsInt()
335
+ val hrOffset = string.indexOf('\uFFFC')
336
+ val hrSpan = hrSpans.single()
337
+ assertEquals(
338
+ "Horizontal rule should not reserve glyph width for the replacement character",
339
+ 0,
340
+ hrSpan.getSize(TextPaint().apply { textSize = baseFontSize }, result, hrOffset, hrOffset + 1, replacementMetrics)
341
+ )
342
+
343
+ val layout = StaticLayout.Builder
344
+ .obtain(result, 0, result.length, TextPaint().apply { textSize = baseFontSize }, 240)
345
+ .build()
346
+ val hrLine = layout.getLineForOffset(hrOffset)
347
+ assertTrue(
348
+ "Horizontal rule line should not report a visible replacement glyph width; actual width=${layout.getLineWidth(hrLine)}",
349
+ layout.getLineWidth(hrLine) <= 1f
350
+ )
351
+ }
352
+
353
+ @Test
354
+ fun `render - image span honors preferred dimensions`() {
355
+ val json = """
356
+ [
357
+ {"type": "voidBlock", "nodeType": "image", "docPos": 1, "attrs": {
358
+ "src": "https://example.com/cat.png",
359
+ "width": 140,
360
+ "height": 80
361
+ }}
362
+ ]
363
+ """.trimIndent()
364
+
365
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, density = 1f)
366
+
367
+ assertTrue(
368
+ "Image should contain object replacement character. Got: '$result'",
369
+ result.toString().contains("\uFFFC")
370
+ )
371
+
372
+ val imageSpans = result.getSpans(0, result.length, BlockImageSpan::class.java)
373
+ assertEquals("Should have one BlockImageSpan", 1, imageSpans.size)
374
+
375
+ val (widthPx, heightPx) = imageSpans.single().currentSizePx()
376
+ assertEquals(140, widthPx)
377
+ assertEquals(80, heightPx)
378
+ }
379
+
380
+ @Test
381
+ fun `render - oversized preferred image dimensions scale to host width`() {
382
+ val hostView = TextView(org.robolectric.RuntimeEnvironment.getApplication()).apply {
383
+ measure(
384
+ View.MeasureSpec.makeMeasureSpec(320, View.MeasureSpec.EXACTLY),
385
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
386
+ )
387
+ layout(0, 0, measuredWidth, measuredHeight)
388
+ }
389
+ val json = """
390
+ [
391
+ {"type": "voidBlock", "nodeType": "image", "docPos": 1, "attrs": {
392
+ "src": "https://example.com/cat.png",
393
+ "width": 4000,
394
+ "height": 2000
395
+ }}
396
+ ]
397
+ """.trimIndent()
398
+
399
+ val result = RenderBridge.buildSpannable(
400
+ json,
401
+ baseFontSize,
402
+ textColor,
403
+ density = 1f,
404
+ hostView = hostView
405
+ )
406
+
407
+ val imageSpan = result.getSpans(0, result.length, BlockImageSpan::class.java).single()
408
+ val (widthPx, heightPx) = imageSpan.currentSizePx()
409
+ assertTrue(widthPx <= hostView.width)
410
+ assertTrue(abs(heightPx - (widthPx / 2)) <= 1)
411
+ }
412
+
413
+ @Test
414
+ fun `render - data url decoder handles expo style payloads`() {
415
+ val dataUrl =
416
+ "data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs="
417
+
418
+ val bitmap = RenderImageDecoder.decodeSource(dataUrl)
419
+
420
+ assertNotNull("Standard base64 image data URLs should decode", bitmap)
421
+ assertEquals(1, bitmap?.width)
422
+ assertEquals(1, bitmap?.height)
423
+ }
424
+
425
+ @Test
426
+ fun `render - data url decoder accepts url safe base64`() {
427
+ val standardDataUrl =
428
+ "data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs="
429
+ val bytes = RenderImageDecoder.decodeDataUrlBytes(standardDataUrl)
430
+ assertNotNull(bytes)
431
+
432
+ val urlSafePayload = Base64.encodeToString(
433
+ bytes,
434
+ Base64.URL_SAFE or Base64.NO_WRAP
435
+ )
436
+ val bitmap = RenderImageDecoder.decodeSource("data:image/gif;base64,$urlSafePayload")
437
+
438
+ assertNotNull("URL-safe base64 image data URLs should decode", bitmap)
439
+ assertEquals(1, bitmap?.width)
440
+ assertEquals(1, bitmap?.height)
441
+ }
442
+
443
+ @Test
444
+ fun `render - data url image span is ready on first render`() {
445
+ val dataUrl =
446
+ "data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs="
447
+ val hostView = TextView(org.robolectric.RuntimeEnvironment.getApplication()).apply {
448
+ measure(
449
+ View.MeasureSpec.makeMeasureSpec(320, View.MeasureSpec.EXACTLY),
450
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
451
+ )
452
+ layout(0, 0, measuredWidth, measuredHeight)
453
+ }
454
+ val json = """
455
+ [
456
+ {"type": "voidBlock", "nodeType": "image", "docPos": 1, "attrs": {
457
+ "src": "$dataUrl"
458
+ }}
459
+ ]
460
+ """.trimIndent()
461
+
462
+ val result = RenderBridge.buildSpannable(
463
+ json,
464
+ baseFontSize,
465
+ textColor,
466
+ density = 1f,
467
+ hostView = hostView
468
+ )
469
+
470
+ val imageSpan = result.getSpans(0, result.length, BlockImageSpan::class.java).single()
471
+ val (widthPx, heightPx) = imageSpan.currentSizePx()
472
+ assertEquals(1, widthPx)
473
+ assertEquals(1, heightPx)
474
+ }
475
+
476
+ @Test
477
+ fun `render - image loader deduplicates concurrent remote loads`() {
478
+ RenderImageLoader.resetForTesting()
479
+ val decodeCount = AtomicInteger(0)
480
+ val decodeStarted = CountDownLatch(1)
481
+ val releaseDecode = CountDownLatch(1)
482
+ val callbacks = CountDownLatch(2)
483
+ val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
484
+ val loaded = mutableListOf<Bitmap?>()
485
+
486
+ RenderImageLoader.decodeSourceOverride = {
487
+ decodeCount.incrementAndGet()
488
+ decodeStarted.countDown()
489
+ assertTrue(releaseDecode.await(2, TimeUnit.SECONDS))
490
+ bitmap
491
+ }
492
+
493
+ try {
494
+ RenderImageLoader.load("https://example.com/cat.png") {
495
+ synchronized(loaded) {
496
+ loaded += it
497
+ }
498
+ callbacks.countDown()
499
+ }
500
+ assertTrue(decodeStarted.await(2, TimeUnit.SECONDS))
501
+ RenderImageLoader.load("https://example.com/cat.png") {
502
+ synchronized(loaded) {
503
+ loaded += it
504
+ }
505
+ callbacks.countDown()
506
+ }
507
+
508
+ releaseDecode.countDown()
509
+ assertTrue(callbacks.await(2, TimeUnit.SECONDS))
510
+ assertEquals(1, decodeCount.get())
511
+ assertEquals(2, loaded.size)
512
+ assertTrue(loaded.all { loadedBitmap -> loadedBitmap === bitmap })
513
+ } finally {
514
+ releaseDecode.countDown()
515
+ RenderImageLoader.resetForTesting()
516
+ }
517
+ }
518
+
519
+ @Test
520
+ fun `render - large images are downsampled for decode`() {
521
+ assertEquals(1, RenderImageDecoder.calculateInSampleSize(width = 1024, height = 768))
522
+ assertEquals(2, RenderImageDecoder.calculateInSampleSize(width = 4096, height = 2048))
523
+ assertEquals(4, RenderImageDecoder.calculateInSampleSize(width = 8192, height = 4096))
524
+ }
525
+
526
+ // ── Multiple Paragraphs ─────────────────────────────────────────────
527
+
528
+ /** Two consecutive paragraphs should be separated by a newline. */
529
+ @Test
530
+ fun `render - multiple paragraphs`() {
531
+ val json = """
532
+ [
533
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
534
+ {"type": "textRun", "text": "First", "marks": []},
535
+ {"type": "blockEnd"},
536
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
537
+ {"type": "textRun", "text": "Second", "marks": []},
538
+ {"type": "blockEnd"}
539
+ ]
540
+ """.trimIndent()
541
+
542
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
543
+
544
+ assertEquals(
545
+ "Two paragraphs should be separated by a newline",
546
+ "First\nSecond", result.toString()
547
+ )
548
+ }
549
+
550
+ // ── Mixed Marks in Same Paragraph ───────────────────────────────────
551
+
552
+ @Test
553
+ fun `render - mixed marks in paragraph`() {
554
+ val json = """
555
+ [
556
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
557
+ {"type": "textRun", "text": "normal ", "marks": []},
558
+ {"type": "textRun", "text": "bold", "marks": ["bold"]},
559
+ {"type": "textRun", "text": " end", "marks": []},
560
+ {"type": "blockEnd"}
561
+ ]
562
+ """.trimIndent()
563
+
564
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
565
+
566
+ assertEquals("normal bold end", result.toString())
567
+
568
+ // Check "normal " (offset 0-7) has no bold StyleSpan.
569
+ val normalStyleSpans = result.getSpans(0, 7, StyleSpan::class.java)
570
+ val normalBold = normalStyleSpans.find { it.style == Typeface.BOLD }
571
+ // The bold span should NOT cover the "normal " range.
572
+ if (normalBold != null) {
573
+ val spanStart = result.getSpanStart(normalBold)
574
+ assertTrue(
575
+ "'normal' range should not overlap with bold span (span starts at $spanStart)",
576
+ spanStart >= 7
577
+ )
578
+ }
579
+
580
+ // Check "bold" (offset 7-11) has bold StyleSpan.
581
+ val boldStyleSpans = result.getSpans(7, 11, StyleSpan::class.java)
582
+ val boldSpan = boldStyleSpans.find { it.style == Typeface.BOLD }
583
+ assertNotNull("'bold' should have BOLD StyleSpan", boldSpan)
584
+ }
585
+
586
+ // ── Mark Aliases ────────────────────────────────────────────────────
587
+
588
+ @Test
589
+ fun `render - strong alias for bold`() {
590
+ val json = """
591
+ [
592
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
593
+ {"type": "textRun", "text": "strong", "marks": ["strong"]},
594
+ {"type": "blockEnd"}
595
+ ]
596
+ """.trimIndent()
597
+
598
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
599
+ val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
600
+ val boldSpan = styleSpans.find { it.style == Typeface.BOLD }
601
+ assertNotNull("'strong' should produce BOLD StyleSpan", boldSpan)
602
+ }
603
+
604
+ @Test
605
+ fun `render - em alias for italic`() {
606
+ val json = """
607
+ [
608
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
609
+ {"type": "textRun", "text": "emphasis", "marks": ["em"]},
610
+ {"type": "blockEnd"}
611
+ ]
612
+ """.trimIndent()
613
+
614
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
615
+ val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
616
+ val italicSpan = styleSpans.find { it.style == Typeface.ITALIC }
617
+ assertNotNull("'em' should produce ITALIC StyleSpan", italicSpan)
618
+ }
619
+
620
+ @Test
621
+ fun `render - strikethrough alias for strike`() {
622
+ val json = """
623
+ [
624
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
625
+ {"type": "textRun", "text": "deleted", "marks": ["strikethrough"]},
626
+ {"type": "blockEnd"}
627
+ ]
628
+ """.trimIndent()
629
+
630
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
631
+ val strikeSpans = result.getSpans(0, result.length, StrikethroughSpan::class.java)
632
+ assertTrue("'strikethrough' should produce StrikethroughSpan", strikeSpans.isNotEmpty())
633
+ }
634
+
635
+ // ── All Marks Combined ──────────────────────────────────────────────
636
+
637
+ @Test
638
+ fun `render - all marks combined`() {
639
+ val json = """
640
+ [
641
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
642
+ {"type": "textRun", "text": "everything", "marks": ["bold", "italic", "underline", "strike"]},
643
+ {"type": "blockEnd"}
644
+ ]
645
+ """.trimIndent()
646
+
647
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
648
+
649
+ val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
650
+ val boldItalicSpan = styleSpans.find { it.style == Typeface.BOLD_ITALIC }
651
+ assertNotNull("Should have BOLD_ITALIC", boldItalicSpan)
652
+
653
+ val underlineSpans = result.getSpans(0, result.length, UnderlineSpan::class.java)
654
+ assertTrue("Should have underline", underlineSpans.isNotEmpty())
655
+
656
+ val strikeSpans = result.getSpans(0, result.length, StrikethroughSpan::class.java)
657
+ assertTrue("Should have strikethrough", strikeSpans.isNotEmpty())
658
+ }
659
+
660
+ // ── Ordered List ────────────────────────────────────────────────────
661
+
662
+ @Test
663
+ fun `render - ordered list item`() {
664
+ val json = """
665
+ [
666
+ {"type": "blockStart", "nodeType": "listItem", "depth": 1,
667
+ "listContext": {"ordered": true, "index": 1, "total": 2, "start": 1, "isFirst": true, "isLast": false}},
668
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
669
+ {"type": "textRun", "text": "First item", "marks": []},
670
+ {"type": "blockEnd"},
671
+ {"type": "blockEnd"},
672
+ {"type": "blockStart", "nodeType": "listItem", "depth": 1,
673
+ "listContext": {"ordered": true, "index": 2, "total": 2, "start": 1, "isFirst": false, "isLast": true}},
674
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
675
+ {"type": "textRun", "text": "Second item", "marks": []},
676
+ {"type": "blockEnd"},
677
+ {"type": "blockEnd"}
678
+ ]
679
+ """.trimIndent()
680
+
681
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
682
+ val string = result.toString()
683
+
684
+ assertTrue(
685
+ "Ordered list should contain '1. ' marker. Got: '$string'",
686
+ string.contains("1. ")
687
+ )
688
+ assertTrue(
689
+ "Ordered list should contain '2. ' marker. Got: '$string'",
690
+ string.contains("2. ")
691
+ )
692
+ assertTrue("Should contain first item text", string.contains("First item"))
693
+ assertTrue("Should contain second item text", string.contains("Second item"))
694
+ }
695
+
696
+ // ── Unordered List ──────────────────────────────────────────────────
697
+
698
+ @Test
699
+ fun `render - unordered list item`() {
700
+ val json = """
701
+ [
702
+ {"type": "blockStart", "nodeType": "listItem", "depth": 1,
703
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
704
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
705
+ {"type": "textRun", "text": "Bullet item", "marks": []},
706
+ {"type": "blockEnd"},
707
+ {"type": "blockEnd"}
708
+ ]
709
+ """.trimIndent()
710
+
711
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
712
+ val string = result.toString()
713
+
714
+ assertTrue(
715
+ "Unordered list should contain bullet character. Got: '$string'",
716
+ string.contains("\u2022")
717
+ )
718
+ assertTrue("Should contain item text", string.contains("Bullet item"))
719
+ }
720
+
721
+ @Test
722
+ fun `render - unordered list marker keeps body text font metrics`() {
723
+ val json = """
724
+ [
725
+ {"type": "blockStart", "nodeType": "listItem", "depth": 1,
726
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
727
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
728
+ {"type": "textRun", "text": "Bullet item", "marks": []},
729
+ {"type": "blockEnd"},
730
+ {"type": "blockEnd"}
731
+ ]
732
+ """.trimIndent()
733
+
734
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
735
+ val markerSpans = result.getSpans(0, 1, AbsoluteSizeSpan::class.java)
736
+ val textSpans = result.getSpans(2, 3, AbsoluteSizeSpan::class.java)
737
+
738
+ assertTrue("Marker should have a size span", markerSpans.isNotEmpty())
739
+ assertTrue("Text should have a size span", textSpans.isNotEmpty())
740
+ assertEquals(textSpans[0].size, markerSpans[0].size)
741
+ assertEquals(baseFontSize.toInt(), textSpans[0].size)
742
+ }
743
+
744
+ // ── Opaque Atoms ────────────────────────────────────────────────────
745
+
746
+ @Test
747
+ fun `render - opaque inline atom`() {
748
+ val json = """
749
+ [
750
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
751
+ {"type": "textRun", "text": "before ", "marks": []},
752
+ {"type": "opaqueInlineAtom", "label": "widget", "docPos": 8},
753
+ {"type": "textRun", "text": " after", "marks": []},
754
+ {"type": "blockEnd"}
755
+ ]
756
+ """.trimIndent()
757
+
758
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
759
+
760
+ assertTrue(
761
+ "Opaque inline atom should render as '[widget]'. Got: '${result}'",
762
+ result.toString().contains("[widget]")
763
+ )
764
+ }
765
+
766
+ @Test
767
+ fun `render - mention inline atom uses visible label and mention theme`() {
768
+ val json = """
769
+ [
770
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
771
+ {"type": "textRun", "text": "Hello ", "marks": []},
772
+ {"type": "opaqueInlineAtom", "nodeType": "mention", "label": "@Alice", "docPos": 7},
773
+ {"type": "textRun", "text": "!", "marks": []},
774
+ {"type": "blockEnd"}
775
+ ]
776
+ """.trimIndent()
777
+ val theme = EditorTheme(
778
+ mentions = EditorMentionTheme(
779
+ textColor = 0xff112233.toInt(),
780
+ backgroundColor = 0xffddeeff.toInt(),
781
+ fontWeight = "bold"
782
+ )
783
+ )
784
+
785
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme)
786
+
787
+ assertTrue(
788
+ "Mention inline atom should render its visible label. Got: '${result}'",
789
+ result.toString().contains("@Alice")
790
+ )
791
+ assertTrue(
792
+ "Mention inline atom should not use generic opaque brackets. Got: '${result}'",
793
+ !result.toString().contains("[@Alice]")
794
+ )
795
+ }
796
+
797
+ @Test
798
+ fun `render - mention inline atom merges element mention theme override`() {
799
+ val json = """
800
+ [
801
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
802
+ {
803
+ "type": "opaqueInlineAtom",
804
+ "nodeType": "mention",
805
+ "label": "@Alice",
806
+ "docPos": 1,
807
+ "mentionTheme": {"textColor": "#445566"}
808
+ },
809
+ {"type": "blockEnd"}
810
+ ]
811
+ """.trimIndent()
812
+ val theme = EditorTheme(
813
+ mentions = EditorMentionTheme(
814
+ textColor = 0xff112233.toInt(),
815
+ backgroundColor = 0xffddeeff.toInt(),
816
+ fontWeight = "bold"
817
+ )
818
+ )
819
+
820
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme)
821
+
822
+ assertEquals("@Alice", result.toString())
823
+ val foreground = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
824
+ .firstOrNull()
825
+ val background = result.getSpans(0, result.length, BackgroundColorSpan::class.java)
826
+ .firstOrNull()
827
+ val boldSpan = result.getSpans(0, result.length, StyleSpan::class.java)
828
+ .firstOrNull { it.style == Typeface.BOLD }
829
+
830
+ assertEquals(Color.parseColor("#445566"), foreground?.foregroundColor)
831
+ assertEquals(0xffddeeff.toInt(), background?.backgroundColor)
832
+ assertNotNull("Mention override should preserve global bold styling", boldSpan)
833
+ }
834
+
835
+ @Test
836
+ fun `render - opaque block atom`() {
837
+ val json = """
838
+ [
839
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
840
+ {"type": "textRun", "text": "Above", "marks": []},
841
+ {"type": "blockEnd"},
842
+ {"type": "opaqueBlockAtom", "label": "widgetBlock", "docPos": 7}
843
+ ]
844
+ """.trimIndent()
845
+
846
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
847
+
848
+ assertTrue(
849
+ "Opaque block atom should render as '[widgetBlock]'. Got: '${result}'",
850
+ result.toString().contains("[widgetBlock]")
851
+ )
852
+ }
853
+
854
+ // ── Invalid / Edge Cases ────────────────────────────────────────────
855
+
856
+ @Test
857
+ fun `render - invalid JSON`() {
858
+ val result = RenderBridge.buildSpannable("not valid json", baseFontSize, textColor)
859
+ assertEquals(
860
+ "Invalid JSON should produce empty SpannableStringBuilder",
861
+ "", result.toString()
862
+ )
863
+ }
864
+
865
+ @Test
866
+ fun `render - empty array`() {
867
+ val result = RenderBridge.buildSpannable("[]", baseFontSize, textColor)
868
+ assertEquals(
869
+ "Empty array should produce empty SpannableStringBuilder",
870
+ "", result.toString()
871
+ )
872
+ }
873
+
874
+ // ── List Marker Generation ──────────────────────────────────────────
875
+
876
+ @Test
877
+ fun `list marker - ordered`() {
878
+ val ctx = org.json.JSONObject("""{"ordered": true, "index": 3}""")
879
+ val marker = RenderBridge.listMarkerString(ctx)
880
+ assertEquals("Ordered list item 3 should produce '3. '", "3. ", marker)
881
+ }
882
+
883
+ @Test
884
+ fun `list marker - unordered`() {
885
+ val ctx = org.json.JSONObject("""{"ordered": false, "index": 1}""")
886
+ val marker = RenderBridge.listMarkerString(ctx)
887
+ assertEquals(
888
+ "Unordered list should produce bullet + space",
889
+ "\u2022 ", marker
890
+ )
891
+ }
892
+
893
+ @Test
894
+ fun `list marker - task unchecked`() {
895
+ val ctx = org.json.JSONObject("""{"kind": "task", "checked": false}""")
896
+ val marker = RenderBridge.listMarkerString(ctx)
897
+ assertEquals(
898
+ "Unchecked task list should produce ballot box + space",
899
+ "\u2610 ", marker
900
+ )
901
+ }
902
+
903
+ @Test
904
+ fun `render - task item includes checkbox marker and text`() {
905
+ val json = """
906
+ [
907
+ {"type": "blockStart", "nodeType": "taskItem", "depth": 1,
908
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true, "kind": "task", "checked": false}},
909
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
910
+ {"type": "textRun", "text": "A", "marks": []},
911
+ {"type": "blockEnd"},
912
+ {"type": "blockEnd"}
913
+ ]
914
+ """.trimIndent()
915
+
916
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
917
+ assertEquals("\u2610 A", result.toString())
918
+ }
919
+
920
+ // ── Link Mark ───────────────────────────────────────────────────────
921
+
922
+ @Test
923
+ fun `render - link mark`() {
924
+ val json = """
925
+ [
926
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
927
+ {"type": "textRun", "text": "click here", "marks": [{"type":"link","href":"https://example.com"}]},
928
+ {"type": "blockEnd"}
929
+ ]
930
+ """.trimIndent()
931
+
932
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
933
+ assertEquals("click here", result.toString())
934
+ val underlineSpans = result.getSpans(0, result.length, UnderlineSpan::class.java)
935
+ val colorSpans = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
936
+ val urlSpans = result.getSpans(0, result.length, URLSpan::class.java)
937
+ val hrefAnnotations = result.getSpans(0, result.length, Annotation::class.java)
938
+ .filter { it.key == RenderBridge.NATIVE_LINK_HREF_ANNOTATION }
939
+
940
+ assertTrue("Link text should be underlined", underlineSpans.isNotEmpty())
941
+ assertTrue(
942
+ "Link text should use link color",
943
+ colorSpans.any { it.foregroundColor == Color.parseColor("#1B73E8") }
944
+ )
945
+ assertTrue("Editor render should not expose clickable URL spans", urlSpans.isEmpty())
946
+ assertEquals(1, hrefAnnotations.size)
947
+ assertEquals("https://example.com", hrefAnnotations.first().value)
948
+ }
949
+
950
+ @Test
951
+ fun `render - themed link mark`() {
952
+ val json = """
953
+ [
954
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
955
+ {"type": "textRun", "text": "click here", "marks": [{"type":"link","href":"https://example.com"}]},
956
+ {"type": "blockEnd"}
957
+ ]
958
+ """.trimIndent()
959
+ val theme = EditorTheme.fromJson(
960
+ """
961
+ {
962
+ "links": {
963
+ "color": "#445566",
964
+ "backgroundColor": "#eef6ff",
965
+ "fontSize": 18,
966
+ "fontWeight": "700",
967
+ "fontStyle": "italic",
968
+ "underline": false
969
+ }
970
+ }
971
+ """.trimIndent()
972
+ )
973
+
974
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme)
975
+ assertEquals("click here", result.toString())
976
+ val underlineSpans = result.getSpans(0, result.length, UnderlineSpan::class.java)
977
+ val colorSpans = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
978
+ val backgroundSpans = result.getSpans(0, result.length, BackgroundColorSpan::class.java)
979
+ val sizeSpans = result.getSpans(0, result.length, AbsoluteSizeSpan::class.java)
980
+ val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
981
+ val hrefAnnotations = result.getSpans(0, result.length, Annotation::class.java)
982
+ .filter { it.key == RenderBridge.NATIVE_LINK_HREF_ANNOTATION }
983
+
984
+ assertTrue("Link underline should be disabled by theme", underlineSpans.isEmpty())
985
+ assertTrue(colorSpans.any { it.foregroundColor == Color.parseColor("#445566") })
986
+ assertTrue(backgroundSpans.any { it.backgroundColor == Color.parseColor("#eef6ff") })
987
+ assertTrue(sizeSpans.any { it.size == 18 })
988
+ assertTrue(styleSpans.any { it.style == Typeface.BOLD_ITALIC })
989
+ assertEquals(1, hrefAnnotations.size)
990
+ assertEquals("https://example.com", hrefAnnotations.first().value)
991
+ }
992
+
993
+ // ── Depth Indentation ───────────────────────────────────────────────
994
+
995
+ @Test
996
+ fun `render - nested block indentation`() {
997
+ val json = """
998
+ [
999
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1000
+ {"type": "textRun", "text": "indented", "marks": []},
1001
+ {"type": "blockEnd"}
1002
+ ]
1003
+ """.trimIndent()
1004
+
1005
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
1006
+
1007
+ assertEquals("indented", result.toString())
1008
+
1009
+ // Check for LeadingMarginSpan with expected indent.
1010
+ val marginSpans = result.getSpans(0, result.length, LeadingMarginSpan.Standard::class.java)
1011
+ assertTrue(
1012
+ "Depth 2 paragraph should have LeadingMarginSpan",
1013
+ marginSpans.isNotEmpty()
1014
+ )
1015
+ val expectedIndent = (2 * LayoutConstants.INDENT_PER_DEPTH).toInt()
1016
+ val actualIndent = marginSpans[0].getLeadingMargin(true)
1017
+ assertEquals(
1018
+ "Depth 2 paragraph should have ${expectedIndent}px indent",
1019
+ expectedIndent, actualIndent
1020
+ )
1021
+ }
1022
+
1023
+ @Test
1024
+ fun `render - blockquote applies quote span and blockquote text style`() {
1025
+ val json = """
1026
+ [
1027
+ {"type": "blockStart", "nodeType": "blockquote", "depth": 0},
1028
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1029
+ {"type": "textRun", "text": "Quoted", "marks": []},
1030
+ {"type": "blockEnd"},
1031
+ {"type": "blockEnd"}
1032
+ ]
1033
+ """.trimIndent()
1034
+ val theme = EditorTheme.fromJson(
1035
+ """
1036
+ {
1037
+ "blockquote": {
1038
+ "indent": 20,
1039
+ "borderColor": "#aa5500",
1040
+ "borderWidth": 4,
1041
+ "markerGap": 10,
1042
+ "text": { "color": "#334455" }
1043
+ }
1044
+ }
1045
+ """.trimIndent()
1046
+ )
1047
+
1048
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
1049
+
1050
+ val quoteSpans = result.getSpans(0, result.length, BlockquoteSpan::class.java)
1051
+ assertTrue("Quoted paragraph should receive BlockquoteSpan", quoteSpans.isNotEmpty())
1052
+ assertEquals(20, quoteSpans.single().getLeadingMargin(true))
1053
+
1054
+ val colorSpans = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
1055
+ assertTrue(
1056
+ "Blockquote text style should override text color",
1057
+ colorSpans.any { it.foregroundColor == Color.parseColor("#334455") }
1058
+ )
1059
+ }
1060
+
1061
+ @Test
1062
+ fun `render - blockquote does not insert extra leading paragraph break`() {
1063
+ val json = """
1064
+ [
1065
+ {"type": "blockStart", "nodeType": "blockquote", "depth": 0},
1066
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1067
+ {"type": "textRun", "text": "Hello", "marks": []},
1068
+ {"type": "blockEnd"},
1069
+ {"type": "blockEnd"},
1070
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1071
+ {"type": "textRun", "text": "World", "marks": []},
1072
+ {"type": "blockEnd"}
1073
+ ]
1074
+ """.trimIndent()
1075
+
1076
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1077
+
1078
+ assertEquals("Hello\nWorld", result.toString())
1079
+ }
1080
+
1081
+ @Test
1082
+ fun `render - consecutive blockquote paragraphs share one quote span`() {
1083
+ val json = """
1084
+ [
1085
+ {"type": "blockStart", "nodeType": "blockquote", "depth": 0},
1086
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1087
+ {"type": "textRun", "text": "Hello", "marks": []},
1088
+ {"type": "blockEnd"},
1089
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1090
+ {"type": "textRun", "text": "World", "marks": []},
1091
+ {"type": "blockEnd"},
1092
+ {"type": "blockEnd"}
1093
+ ]
1094
+ """.trimIndent()
1095
+
1096
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1097
+
1098
+ assertEquals("Hello\nWorld", result.toString())
1099
+ val quoteSpans = result.getSpans(0, result.length, BlockquoteSpan::class.java)
1100
+ assertEquals(1, quoteSpans.size)
1101
+ }
1102
+
1103
+ @Test
1104
+ fun `render - trailing hard break in blockquote preserves quote span into following paragraph`() {
1105
+ val json = """
1106
+ [
1107
+ {"type": "blockStart", "nodeType": "blockquote", "depth": 0},
1108
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1109
+ {"type": "textRun", "text": "Hello", "marks": []},
1110
+ {"type": "voidInline", "nodeType": "hardBreak", "docPos": 6},
1111
+ {"type": "blockEnd"},
1112
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1113
+ {"type": "textRun", "text": "Tail", "marks": []},
1114
+ {"type": "blockEnd"},
1115
+ {"type": "blockEnd"}
1116
+ ]
1117
+ """.trimIndent()
1118
+
1119
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1120
+ assertEquals("Hello\n\u200B\nTail", result.toString())
1121
+ val quoteSpans = result.getSpans(0, result.length, BlockquoteSpan::class.java)
1122
+ assertEquals(1, quoteSpans.size)
1123
+ }
1124
+
1125
+ @Test
1126
+ fun `render - trailing hard break in blockquote appends synthetic placeholder with quote styling`() {
1127
+ val json = """
1128
+ [
1129
+ {"type": "blockStart", "nodeType": "blockquote", "depth": 0},
1130
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1131
+ {"type": "textRun", "text": "A", "marks": []},
1132
+ {"type": "voidInline", "nodeType": "hardBreak", "docPos": 2},
1133
+ {"type": "blockEnd"},
1134
+ {"type": "blockEnd"}
1135
+ ]
1136
+ """.trimIndent()
1137
+
1138
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1139
+ assertEquals("A\n\u200B", result.toString())
1140
+
1141
+ val placeholderIndex = result.length - 1
1142
+ val placeholderAnnotations =
1143
+ result.getSpans(placeholderIndex, placeholderIndex + 1, Annotation::class.java)
1144
+ assertTrue(
1145
+ "Trailing hard-break placeholder should be marked as synthetic",
1146
+ placeholderAnnotations.any { it.key == "nativeSyntheticPlaceholder" }
1147
+ )
1148
+ assertTrue(
1149
+ "Trailing hard-break placeholder should keep blockquote styling",
1150
+ placeholderAnnotations.any { it.key == "nativeBlockquote" }
1151
+ )
1152
+ }
1153
+
1154
+ @Test
1155
+ fun `render - blockquote span ends before separator newline to plain paragraph`() {
1156
+ val json = """
1157
+ [
1158
+ {"type": "blockStart", "nodeType": "blockquote", "depth": 0},
1159
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1160
+ {"type": "textRun", "text": "Hello", "marks": []},
1161
+ {"type": "blockEnd"},
1162
+ {"type": "blockEnd"},
1163
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1164
+ {"type": "textRun", "text": "World", "marks": []},
1165
+ {"type": "blockEnd"}
1166
+ ]
1167
+ """.trimIndent()
1168
+
1169
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1170
+ assertEquals("Hello\nWorld", result.toString())
1171
+
1172
+ val quoteSpans = result.getSpans(0, result.length, BlockquoteSpan::class.java)
1173
+ assertEquals(1, quoteSpans.size)
1174
+ assertEquals(
1175
+ "Blockquote span should end at the separator newline boundary to following plain content",
1176
+ 6,
1177
+ result.getSpanEnd(quoteSpans.single())
1178
+ )
1179
+ }
1180
+
1181
+ @Test
1182
+ fun `render - paragraph preceding blockquote does not inherit quote indent`() {
1183
+ val json = """
1184
+ [
1185
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1186
+ {"type": "textRun", "text": "Intro", "marks": []},
1187
+ {"type": "blockEnd"},
1188
+ {"type": "blockStart", "nodeType": "blockquote", "depth": 0},
1189
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1190
+ {"type": "textRun", "text": "Quote", "marks": []},
1191
+ {"type": "blockEnd"},
1192
+ {"type": "blockEnd"}
1193
+ ]
1194
+ """.trimIndent()
1195
+
1196
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1197
+ assertEquals("Intro\nQuote", result.toString())
1198
+
1199
+ val quoteSpans = result.getSpans(0, result.length, BlockquoteSpan::class.java)
1200
+ assertEquals(1, quoteSpans.size)
1201
+ assertEquals(
1202
+ "Blockquote span should start at the quoted paragraph, not the preceding plain paragraph",
1203
+ 6,
1204
+ result.getSpanStart(quoteSpans.single())
1205
+ )
1206
+ }
1207
+
1208
+ @Test
1209
+ fun `render - nested list item inside blockquote indents more than parent item`() {
1210
+ val json = """
1211
+ [
1212
+ {"type": "blockStart", "nodeType": "blockquote", "depth": 0},
1213
+ {"type": "blockStart", "nodeType": "listItem", "depth": 1, "listContext": {"ordered": false, "index": 1, "total": 2, "start": 1, "isFirst": true, "isLast": false}},
1214
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1215
+ {"type": "textRun", "text": "Parent", "marks": []},
1216
+ {"type": "blockEnd"},
1217
+ {"type": "blockStart", "nodeType": "listItem", "depth": 2, "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1218
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 3},
1219
+ {"type": "textRun", "text": "Child", "marks": []},
1220
+ {"type": "blockEnd"},
1221
+ {"type": "blockEnd"},
1222
+ {"type": "blockEnd"},
1223
+ {"type": "blockEnd"}
1224
+ ]
1225
+ """.trimIndent()
1226
+
1227
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1228
+
1229
+ val parentIndex = result.indexOf("Parent")
1230
+ val childIndex = result.indexOf("Child")
1231
+ assertTrue(parentIndex >= 0)
1232
+ assertTrue(childIndex >= 0)
1233
+ val parentMargin = result
1234
+ .getSpans(parentIndex, parentIndex + 1, LeadingMarginSpan::class.java)
1235
+ .sumOf { it.getLeadingMargin(true) }
1236
+ val childMargin = result
1237
+ .getSpans(childIndex, childIndex + 1, LeadingMarginSpan::class.java)
1238
+ .sumOf { it.getLeadingMargin(true) }
1239
+
1240
+ assertTrue(
1241
+ "nested list item inside a blockquote should indent more than its parent item",
1242
+ childMargin > parentMargin
1243
+ )
1244
+ }
1245
+
1246
+ @Test
1247
+ fun `render - first level list inside blockquote keeps extra list indent`() {
1248
+ val json = """
1249
+ [
1250
+ {"type": "blockStart", "nodeType": "blockquote", "depth": 0},
1251
+ {"type": "blockStart", "nodeType": "listItem", "depth": 1, "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1252
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1253
+ {"type": "textRun", "text": "Quoted item", "marks": []},
1254
+ {"type": "blockEnd"},
1255
+ {"type": "blockEnd"},
1256
+ {"type": "blockEnd"}
1257
+ ]
1258
+ """.trimIndent()
1259
+
1260
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1261
+ val quotedIndex = result.indexOf("Quoted item")
1262
+ assertTrue(quotedIndex >= 0)
1263
+
1264
+ val quotedMargins = result.getSpans(
1265
+ quotedIndex,
1266
+ quotedIndex + 1,
1267
+ LeadingMarginSpan::class.java
1268
+ )
1269
+ val totalMargin = quotedMargins.sumOf { it.getLeadingMargin(true) }
1270
+
1271
+ assertEquals(
1272
+ "first-level list text inside a blockquote should keep its extra list indent",
1273
+ 42,
1274
+ totalMargin
1275
+ )
1276
+ }
1277
+
1278
+ @Test
1279
+ fun `blockquote span trims bottom on final quoted line before plain content`() {
1280
+ val text = SpannableStringBuilder("Quote\nPlain")
1281
+ text.setSpan(
1282
+ Annotation(RenderBridge.NATIVE_BLOCKQUOTE_ANNOTATION, "1"),
1283
+ 0,
1284
+ 5,
1285
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1286
+ )
1287
+ text.setSpan(
1288
+ Annotation(RenderBridge.NATIVE_BLOCKQUOTE_ANNOTATION, "1"),
1289
+ 5,
1290
+ 6,
1291
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1292
+ )
1293
+
1294
+ val paint = TextPaint().apply { textSize = 16f }
1295
+ val layout = StaticLayout.Builder
1296
+ .obtain(text, 0, text.length, paint, 200)
1297
+ .build()
1298
+ val span = BlockquoteSpan(
1299
+ baseIndentPx = 0,
1300
+ totalIndentPx = 18,
1301
+ stripeColor = Color.BLACK,
1302
+ stripeWidthPx = 3,
1303
+ gapWidthPx = 8
1304
+ )
1305
+ val line = 0
1306
+ val bottom = span.resolvedStripeBottom(
1307
+ text = text,
1308
+ start = layout.getLineStart(line),
1309
+ end = layout.getLineEnd(line),
1310
+ baseline = layout.getLineBaseline(line),
1311
+ bottom = layout.getLineBottom(line),
1312
+ layout = layout,
1313
+ paint = paint
1314
+ )
1315
+
1316
+ assertEquals(
1317
+ "Final quoted line before plain content should trim stripe to baseline + font descent",
1318
+ layout.getLineBaseline(line) + paint.fontMetrics.descent,
1319
+ bottom,
1320
+ 0.01f
1321
+ )
1322
+ }
1323
+
1324
+ @Test
1325
+ fun `blockquote span ignores paragraph spacer when trimming final quoted line`() {
1326
+ val text = SpannableStringBuilder("Quote\nPlain")
1327
+ text.setSpan(
1328
+ Annotation(RenderBridge.NATIVE_BLOCKQUOTE_ANNOTATION, "1"),
1329
+ 0,
1330
+ 5,
1331
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1332
+ )
1333
+ text.setSpan(
1334
+ Annotation(RenderBridge.NATIVE_BLOCKQUOTE_ANNOTATION, "1"),
1335
+ 5,
1336
+ 6,
1337
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1338
+ )
1339
+ text.setSpan(
1340
+ ParagraphSpacerSpan(
1341
+ spacingPx = 40,
1342
+ baseFontSize = 16,
1343
+ textColor = Color.BLACK
1344
+ ),
1345
+ 5,
1346
+ 6,
1347
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1348
+ )
1349
+
1350
+ val paint = TextPaint().apply { textSize = 16f }
1351
+ val layout = StaticLayout.Builder
1352
+ .obtain(text, 0, text.length, paint, 200)
1353
+ .build()
1354
+ val span = BlockquoteSpan(
1355
+ baseIndentPx = 0,
1356
+ totalIndentPx = 18,
1357
+ stripeColor = Color.BLACK,
1358
+ stripeWidthPx = 3,
1359
+ gapWidthPx = 8
1360
+ )
1361
+ val line = 0
1362
+ val bottom = span.resolvedStripeBottom(
1363
+ text = text,
1364
+ start = layout.getLineStart(line),
1365
+ end = layout.getLineEnd(line),
1366
+ baseline = layout.getLineBaseline(line),
1367
+ bottom = layout.getLineBottom(line),
1368
+ layout = layout,
1369
+ paint = paint
1370
+ )
1371
+
1372
+ assertTrue("Paragraph spacer should inflate line metrics in this reproduction", layout.getLineDescent(line) > paint.fontMetrics.descent)
1373
+ assertEquals(
1374
+ "Final quoted line should trim to font descent even when paragraph spacing inflates layout descent",
1375
+ layout.getLineBaseline(line) + paint.fontMetrics.descent,
1376
+ bottom,
1377
+ 0.01f
1378
+ )
1379
+ }
1380
+
1381
+ @Test
1382
+ fun `render - theme overrides paragraph typography`() {
1383
+ val json = """
1384
+ [
1385
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1386
+ {"type": "textRun", "text": "Styled", "marks": []},
1387
+ {"type": "blockEnd"}
1388
+ ]
1389
+ """.trimIndent()
1390
+ val theme = EditorTheme.fromJson(
1391
+ """
1392
+ {
1393
+ "text": { "fontSize": 18, "color": "#112233" },
1394
+ "paragraph": { "lineHeight": 28, "spacingAfter": 14 }
1395
+ }
1396
+ """.trimIndent()
1397
+ )
1398
+
1399
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
1400
+
1401
+ val colorSpans = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
1402
+ val sizeSpans = result.getSpans(0, result.length, AbsoluteSizeSpan::class.java)
1403
+ val lineHeightSpans = result.getSpans(0, result.length, FixedLineHeightSpan::class.java)
1404
+
1405
+ assertTrue(colorSpans.any { it.foregroundColor == Color.parseColor("#112233") })
1406
+ assertTrue(sizeSpans.any { it.size == 18 })
1407
+ assertTrue(lineHeightSpans.isNotEmpty())
1408
+ }
1409
+
1410
+ @Test
1411
+ fun `render - theme overrides specific heading level typography`() {
1412
+ val json = """
1413
+ [
1414
+ {"type": "blockStart", "nodeType": "h2", "depth": 0},
1415
+ {"type": "textRun", "text": "Styled heading", "marks": []},
1416
+ {"type": "blockEnd"}
1417
+ ]
1418
+ """.trimIndent()
1419
+ val theme = EditorTheme.fromJson(
1420
+ """
1421
+ {
1422
+ "text": { "fontSize": 16, "color": "#112233" },
1423
+ "headings": {
1424
+ "h2": { "fontSize": 28, "fontWeight": "700", "color": "#445566", "lineHeight": 34, "spacingAfter": 12 },
1425
+ "h4": { "fontSize": 18, "color": "#AA5500" }
1426
+ }
1427
+ }
1428
+ """.trimIndent()
1429
+ )
1430
+
1431
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
1432
+
1433
+ val colorSpans = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
1434
+ val sizeSpans = result.getSpans(0, result.length, AbsoluteSizeSpan::class.java)
1435
+ val lineHeightSpans = result.getSpans(0, result.length, FixedLineHeightSpan::class.java)
1436
+ val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
1437
+
1438
+ assertTrue(colorSpans.any { it.foregroundColor == Color.parseColor("#445566") })
1439
+ assertTrue(sizeSpans.any { it.size == 28 })
1440
+ assertTrue(lineHeightSpans.isNotEmpty())
1441
+ assertTrue(styleSpans.any { it.style == Typeface.BOLD })
1442
+ }
1443
+
1444
+ @Test
1445
+ fun `render - paragraph does not inherit text line height when paragraph line height is unset`() {
1446
+ val json = """
1447
+ [
1448
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1449
+ {"type": "textRun", "text": "Styled", "marks": []},
1450
+ {"type": "blockEnd"}
1451
+ ]
1452
+ """.trimIndent()
1453
+ val theme = EditorTheme.fromJson(
1454
+ """
1455
+ {
1456
+ "text": { "fontSize": 18, "lineHeight": 28 },
1457
+ "paragraph": { "spacingAfter": 14 }
1458
+ }
1459
+ """.trimIndent()
1460
+ )
1461
+
1462
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
1463
+ val lineHeightSpans = result.getSpans(0, result.length, FixedLineHeightSpan::class.java)
1464
+
1465
+ assertTrue(lineHeightSpans.isEmpty())
1466
+ }
1467
+
1468
+ @Test
1469
+ fun `render - no spacer span when spacingAfter is unset`() {
1470
+ val json = """
1471
+ [
1472
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1473
+ {"type": "textRun", "text": "First paragraph", "marks": []},
1474
+ {"type": "blockEnd"},
1475
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1476
+ {"type": "textRun", "text": "Second paragraph", "marks": []},
1477
+ {"type": "blockEnd"}
1478
+ ]
1479
+ """.trimIndent()
1480
+
1481
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
1482
+ val spacerSpans = result.getSpans(0, result.length, ParagraphSpacerSpan::class.java)
1483
+
1484
+ assertTrue("No spacer spans when theme has no spacingAfter", spacerSpans.isEmpty())
1485
+ }
1486
+
1487
+ @Test
1488
+ fun `render - paragraph spacing applied to inter-block newline via ParagraphSpacerSpan`() {
1489
+ val json = """
1490
+ [
1491
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1492
+ {"type": "textRun", "text": "First paragraph", "marks": []},
1493
+ {"type": "blockEnd"},
1494
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
1495
+ {"type": "textRun", "text": "Second paragraph", "marks": []},
1496
+ {"type": "blockEnd"}
1497
+ ]
1498
+ """.trimIndent()
1499
+ val theme = EditorTheme.fromJson(
1500
+ """
1501
+ {
1502
+ "paragraph": { "spacingAfter": 14 }
1503
+ }
1504
+ """.trimIndent()
1505
+ )
1506
+
1507
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
1508
+ val separatorIndex = result.toString().indexOf('\n')
1509
+
1510
+ // Spacer span should be on the inter-block newline character.
1511
+ val spacerSpans = result.getSpans(separatorIndex, separatorIndex + 1, ParagraphSpacerSpan::class.java)
1512
+ assertTrue("Inter-block newline should have a ParagraphSpacerSpan", spacerSpans.isNotEmpty())
1513
+
1514
+ // No spacer span on paragraph content.
1515
+ val firstParaSpans = result.getSpans(0, separatorIndex, ParagraphSpacerSpan::class.java)
1516
+ assertTrue("Paragraph content should not have spacer spans", firstParaSpans.isEmpty())
1517
+
1518
+ val secondParaSpans = result.getSpans(separatorIndex + 1, result.length, ParagraphSpacerSpan::class.java)
1519
+ assertTrue("Second paragraph content should not have spacer spans", secondParaSpans.isEmpty())
1520
+ }
1521
+
1522
+ @Test
1523
+ fun `render - list item spacing applies to sibling list item separator`() {
1524
+ val json = """
1525
+ [
1526
+ {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1527
+ "listContext": {"ordered": false, "index": 1, "total": 2, "start": 1, "isFirst": true, "isLast": false}},
1528
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1529
+ {"type": "textRun", "text": "First item", "marks": []},
1530
+ {"type": "blockEnd"},
1531
+ {"type": "blockEnd"},
1532
+ {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1533
+ "listContext": {"ordered": false, "index": 2, "total": 2, "start": 1, "isFirst": false, "isLast": true}},
1534
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1535
+ {"type": "textRun", "text": "Second item", "marks": []},
1536
+ {"type": "blockEnd"},
1537
+ {"type": "blockEnd"}
1538
+ ]
1539
+ """.trimIndent()
1540
+ val theme = EditorTheme.fromJson(
1541
+ """
1542
+ {
1543
+ "list": { "itemSpacing": 14 }
1544
+ }
1545
+ """.trimIndent()
1546
+ )
1547
+
1548
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
1549
+ val separatorIndex = result.toString().indexOf('\n')
1550
+
1551
+ assertTrue("Expected a separator newline between list items", separatorIndex >= 0)
1552
+ val spacerSpans = result.getSpans(separatorIndex, separatorIndex + 1, ParagraphSpacerSpan::class.java)
1553
+ assertTrue("List item separator should receive ParagraphSpacerSpan from itemSpacing", spacerSpans.isNotEmpty())
1554
+ }
1555
+
1556
+ @Test
1557
+ fun `render - nested first list item does not inherit paragraph spacing when itemSpacing is zero`() {
1558
+ val json = """
1559
+ [
1560
+ {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1561
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1562
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1563
+ {"type": "textRun", "text": "Parent item", "marks": []},
1564
+ {"type": "blockEnd"},
1565
+ {"type": "blockStart", "nodeType": "listItem", "depth": 1,
1566
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1567
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1568
+ {"type": "textRun", "text": "Nested item", "marks": []},
1569
+ {"type": "blockEnd"},
1570
+ {"type": "blockEnd"},
1571
+ {"type": "blockEnd"}
1572
+ ]
1573
+ """.trimIndent()
1574
+ val theme = EditorTheme.fromJson(
1575
+ """
1576
+ {
1577
+ "paragraph": { "spacingAfter": 14 },
1578
+ "list": { "itemSpacing": 0 }
1579
+ }
1580
+ """.trimIndent()
1581
+ )
1582
+
1583
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
1584
+ val separatorIndex = result.toString().indexOf('\n')
1585
+
1586
+ assertTrue("Expected a separator newline before nested list item", separatorIndex >= 0)
1587
+ val spacerSpans = result.getSpans(separatorIndex, separatorIndex + 1, ParagraphSpacerSpan::class.java)
1588
+ assertTrue(
1589
+ "Nested list separator should not keep parent paragraph spacing when itemSpacing is zero",
1590
+ spacerSpans.isEmpty()
1591
+ )
1592
+ }
1593
+
1594
+ @Test
1595
+ fun `render - theme overrides list indentation`() {
1596
+ val json = """
1597
+ [
1598
+ {"type": "blockStart", "nodeType": "listItem", "depth": 1,
1599
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1600
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1601
+ {"type": "textRun", "text": "Item", "marks": []},
1602
+ {"type": "blockEnd"},
1603
+ {"type": "blockEnd"}
1604
+ ]
1605
+ """.trimIndent()
1606
+ val theme = EditorTheme.fromJson(
1607
+ """
1608
+ {
1609
+ "list": { "indent": 32, "markerScale": 1.5, "markerColor": "#334455" }
1610
+ }
1611
+ """.trimIndent()
1612
+ )
1613
+
1614
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
1615
+ val marginSpans = result.getSpans(0, result.length, LeadingMarginSpan.Standard::class.java)
1616
+ assertTrue(marginSpans.isNotEmpty())
1617
+ assertEquals(64, marginSpans[0].getLeadingMargin(true))
1618
+ }
1619
+
1620
+ @Test
1621
+ fun `render - list base indent multiplier can collapse top-level list indent`() {
1622
+ val json = """
1623
+ [
1624
+ {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1625
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1626
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1627
+ {"type": "textRun", "text": "Item", "marks": []},
1628
+ {"type": "blockEnd"},
1629
+ {"type": "blockEnd"}
1630
+ ]
1631
+ """.trimIndent()
1632
+ val theme = EditorTheme.fromJson(
1633
+ """
1634
+ {
1635
+ "list": { "indent": 32, "baseIndentMultiplier": 0 }
1636
+ }
1637
+ """.trimIndent()
1638
+ )
1639
+
1640
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
1641
+ val marginSpan = result.getSpans(0, result.length, LeadingMarginSpan.Standard::class.java).single()
1642
+
1643
+ assertEquals(0, marginSpan.getLeadingMargin(true))
1644
+ assertEquals(LayoutConstants.LIST_MARKER_WIDTH.toInt(), marginSpan.getLeadingMargin(false))
1645
+ }
1646
+
1647
+ @Test
1648
+ fun `render - unordered marker scale does not widen list text gutter`() {
1649
+ val json = """
1650
+ [
1651
+ {"type": "blockStart", "nodeType": "listItem", "depth": 1,
1652
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1653
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1654
+ {"type": "textRun", "text": "Item", "marks": []},
1655
+ {"type": "blockEnd"},
1656
+ {"type": "blockEnd"}
1657
+ ]
1658
+ """.trimIndent()
1659
+ val baseTheme = EditorTheme.fromJson(
1660
+ """
1661
+ {
1662
+ "text": { "fontSize": 40 },
1663
+ "list": { "indent": 32, "markerScale": 1 }
1664
+ }
1665
+ """.trimIndent()
1666
+ )
1667
+ val scaledTheme = EditorTheme.fromJson(
1668
+ """
1669
+ {
1670
+ "text": { "fontSize": 40 },
1671
+ "list": { "indent": 32, "markerScale": 2 }
1672
+ }
1673
+ """.trimIndent()
1674
+ )
1675
+
1676
+ val baseResult = RenderBridge.buildSpannable(json, baseFontSize, textColor, baseTheme, 1f)
1677
+ val scaledResult = RenderBridge.buildSpannable(json, baseFontSize, textColor, scaledTheme, 1f)
1678
+ val baseMargin = baseResult.getSpans(0, baseResult.length, LeadingMarginSpan.Standard::class.java).single()
1679
+ val scaledMargin = scaledResult.getSpans(0, scaledResult.length, LeadingMarginSpan.Standard::class.java).single()
1680
+
1681
+ assertEquals(baseMargin.getLeadingMargin(false), scaledMargin.getLeadingMargin(false))
1682
+ }
1683
+
1684
+ @Test
1685
+ fun `render - themed list marker receives line height span`() {
1686
+ val json = """
1687
+ [
1688
+ {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1689
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1690
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1691
+ {"type": "textRun", "text": "Item", "marks": []},
1692
+ {"type": "blockEnd"},
1693
+ {"type": "blockEnd"}
1694
+ ]
1695
+ """.trimIndent()
1696
+ val theme = EditorTheme.fromJson(
1697
+ """
1698
+ {
1699
+ "paragraph": { "lineHeight": 28 }
1700
+ }
1701
+ """.trimIndent()
1702
+ )
1703
+
1704
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
1705
+ val markerLineHeightSpans = result.getSpans(0, 1, FixedLineHeightSpan::class.java)
1706
+ assertTrue(markerLineHeightSpans.isNotEmpty())
1707
+ }
1708
+
1709
+ @Test
1710
+ fun `render - indented list item has larger leading margin than non-indented`() {
1711
+ val json = """
1712
+ [
1713
+ {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1714
+ "listContext": {"ordered": false, "index": 1, "total": 2, "start": 1, "isFirst": true, "isLast": false}},
1715
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1716
+ {"type": "textRun", "text": "First item", "marks": []},
1717
+ {"type": "blockEnd"},
1718
+ {"type": "blockEnd"},
1719
+ {"type": "blockStart", "nodeType": "listItem", "depth": 1,
1720
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1721
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1722
+ {"type": "textRun", "text": "Indented item", "marks": []},
1723
+ {"type": "blockEnd"},
1724
+ {"type": "blockEnd"}
1725
+ ]
1726
+ """.trimIndent()
1727
+
1728
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1729
+ val text = result.toString()
1730
+ val newlineIndex = text.indexOf('\n')
1731
+
1732
+ val allMargins = result.getSpans(0, result.length, LeadingMarginSpan.Standard::class.java)
1733
+ assertTrue("List items should have LeadingMarginSpans", allMargins.isNotEmpty())
1734
+
1735
+ val firstItemMargin = allMargins.firstOrNull { result.getSpanStart(it) == 0 }
1736
+ assertNotNull("First item should have a paragraph-scoped LeadingMarginSpan", firstItemMargin)
1737
+
1738
+ val indentedItemMargin = allMargins.firstOrNull { result.getSpanStart(it) > newlineIndex }
1739
+ assertNotNull("Indented item should have its own paragraph-scoped LeadingMarginSpan", indentedItemMargin)
1740
+
1741
+ val firstIndent = firstItemMargin!!.getLeadingMargin(true)
1742
+ val indentedIndent = indentedItemMargin!!.getLeadingMargin(true)
1743
+
1744
+ assertTrue(
1745
+ "Indented item margin ($indentedIndent) should be greater than first item margin ($firstIndent)",
1746
+ indentedIndent > firstIndent
1747
+ )
1748
+ }
1749
+
1750
+ @Test
1751
+ fun `render - list indentation uses paragraph span flags`() {
1752
+ val json = """
1753
+ [
1754
+ {"type": "blockStart", "nodeType": "listItem", "depth": 1,
1755
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1756
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1757
+ {"type": "textRun", "text": "Indented item", "marks": []},
1758
+ {"type": "blockEnd"},
1759
+ {"type": "blockEnd"}
1760
+ ]
1761
+ """.trimIndent()
1762
+
1763
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1764
+ val marginSpans = result.getSpans(0, result.length, LeadingMarginSpan.Standard::class.java)
1765
+
1766
+ assertTrue("Indented list item should have a LeadingMarginSpan", marginSpans.isNotEmpty())
1767
+ assertEquals(
1768
+ "LeadingMarginSpan should be paragraph-scoped",
1769
+ Spanned.SPAN_PARAGRAPH,
1770
+ result.getSpanFlags(marginSpans[0])
1771
+ )
1772
+ assertEquals(
1773
+ "LeadingMarginSpan should start at the list paragraph start, including the marker",
1774
+ 0,
1775
+ result.getSpanStart(marginSpans[0])
1776
+ )
1777
+ }
1778
+
1779
+ @Test
1780
+ fun `render - list paragraph uses a single leading margin span across multiple text runs`() {
1781
+ val json = """
1782
+ [
1783
+ {"type": "blockStart", "nodeType": "listItem", "depth": 1,
1784
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1785
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1786
+ {"type": "textRun", "text": "Alpha", "marks": []},
1787
+ {"type": "textRun", "text": " Beta", "marks": ["bold"]},
1788
+ {"type": "blockEnd"},
1789
+ {"type": "blockEnd"}
1790
+ ]
1791
+ """.trimIndent()
1792
+
1793
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1794
+ val marginSpans = result.getSpans(0, result.length, LeadingMarginSpan.Standard::class.java)
1795
+ val paragraphSpans = marginSpans.filter { result.getSpanStart(it) == 0 }
1796
+
1797
+ assertEquals("Paragraph should have exactly one LeadingMarginSpan", 1, paragraphSpans.size)
1798
+ }
1799
+
1800
+ @Test
1801
+ fun `layout - sibling list items at same depth share the same visual left offset`() {
1802
+ val json = """
1803
+ [
1804
+ {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1805
+ "listContext": {"ordered": false, "index": 1, "total": 3, "start": 1, "isFirst": true, "isLast": false}},
1806
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1807
+ {"type": "textRun", "text": "First", "marks": []},
1808
+ {"type": "blockEnd"},
1809
+ {"type": "blockEnd"},
1810
+ {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1811
+ "listContext": {"ordered": false, "index": 2, "total": 3, "start": 1, "isFirst": false, "isLast": false}},
1812
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1813
+ {"type": "textRun", "text": "Second", "marks": []},
1814
+ {"type": "blockEnd"},
1815
+ {"type": "blockEnd"},
1816
+ {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1817
+ "listContext": {"ordered": false, "index": 3, "total": 3, "start": 1, "isFirst": false, "isLast": true}},
1818
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1819
+ {"type": "textRun", "text": "Third", "marks": []},
1820
+ {"type": "blockEnd"},
1821
+ {"type": "blockEnd"}
1822
+ ]
1823
+ """.trimIndent()
1824
+
1825
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1826
+ val paint = TextPaint().apply { textSize = baseFontSize }
1827
+ val layout = StaticLayout.Builder
1828
+ .obtain(result, 0, result.length, paint, 400)
1829
+ .setAlignment(Layout.Alignment.ALIGN_NORMAL)
1830
+ .setIncludePad(false)
1831
+ .build()
1832
+
1833
+ assertEquals("Expected one line per list item", 3, layout.lineCount)
1834
+
1835
+ val firstLeft = layout.getLineLeft(0)
1836
+ val secondLeft = layout.getLineLeft(1)
1837
+ val thirdLeft = layout.getLineLeft(2)
1838
+
1839
+ assertEquals("First and second sibling items should align", firstLeft, secondLeft, 0.01f)
1840
+ assertEquals("Second and third sibling items should align", secondLeft, thirdLeft, 0.01f)
1841
+ }
1842
+
1843
+ @Test
1844
+ fun `layout - nested middle item does not shift trailing outer sibling`() {
1845
+ val json = """
1846
+ [
1847
+ {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1848
+ "listContext": {"ordered": false, "index": 1, "total": 3, "start": 1, "isFirst": true, "isLast": false}},
1849
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1850
+ {"type": "textRun", "text": "First", "marks": []},
1851
+ {"type": "blockEnd"},
1852
+ {"type": "blockEnd"},
1853
+ {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1854
+ "listContext": {"ordered": false, "index": 2, "total": 3, "start": 1, "isFirst": false, "isLast": false}},
1855
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1856
+ {"type": "textRun", "text": "Second", "marks": []},
1857
+ {"type": "blockEnd"},
1858
+ {"type": "blockStart", "nodeType": "listItem", "depth": 1,
1859
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1860
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 2},
1861
+ {"type": "textRun", "text": "Nested", "marks": []},
1862
+ {"type": "blockEnd"},
1863
+ {"type": "blockEnd"},
1864
+ {"type": "blockEnd"},
1865
+ {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1866
+ "listContext": {"ordered": false, "index": 3, "total": 3, "start": 1, "isFirst": false, "isLast": true}},
1867
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1868
+ {"type": "textRun", "text": "Third", "marks": []},
1869
+ {"type": "blockEnd"},
1870
+ {"type": "blockEnd"}
1871
+ ]
1872
+ """.trimIndent()
1873
+
1874
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1875
+ val paint = TextPaint().apply { textSize = baseFontSize }
1876
+ val layout = StaticLayout.Builder
1877
+ .obtain(result, 0, result.length, paint, 400)
1878
+ .setAlignment(Layout.Alignment.ALIGN_NORMAL)
1879
+ .setIncludePad(false)
1880
+ .build()
1881
+
1882
+ val text = result.toString()
1883
+ val firstOffset = text.indexOf("First")
1884
+ val secondOffset = text.indexOf("Second")
1885
+ val nestedOffset = text.indexOf("Nested")
1886
+ val thirdOffset = text.indexOf("Third")
1887
+
1888
+ val firstLeft = layout.getPrimaryHorizontal(firstOffset)
1889
+ val secondLeft = layout.getPrimaryHorizontal(secondOffset)
1890
+ val nestedLeft = layout.getPrimaryHorizontal(nestedOffset)
1891
+ val thirdLeft = layout.getPrimaryHorizontal(thirdOffset)
1892
+ val marginSummary = result
1893
+ .getSpans(0, result.length, LeadingMarginSpan.Standard::class.java)
1894
+ .joinToString(" | ") {
1895
+ "start=${result.getSpanStart(it)} end=${result.getSpanEnd(it)} margin=${it.getLeadingMargin(true)}"
1896
+ }
1897
+
1898
+ val outerAligned = kotlin.math.abs(firstLeft - secondLeft) <= 0.01f
1899
+ val nestedIndented = nestedLeft > secondLeft
1900
+ val trailingAligned = kotlin.math.abs(firstLeft - thirdLeft) <= 0.01f
1901
+
1902
+ if (!outerAligned || !nestedIndented || !trailingAligned) {
1903
+ fail(
1904
+ "Unexpected nested list layout: first=$firstLeft second=$secondLeft " +
1905
+ "nested=$nestedLeft third=$thirdLeft text=$text margins=$marginSummary"
1906
+ )
1907
+ }
1908
+ }
1909
+
1910
+ @Test
1911
+ fun `render - unordered list marker uses centered bullet span`() {
1912
+ val json = """
1913
+ [
1914
+ {"type": "blockStart", "nodeType": "listItem", "depth": 0,
1915
+ "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
1916
+ {"type": "blockStart", "nodeType": "paragraph", "depth": 1},
1917
+ {"type": "textRun", "text": "Item", "marks": []},
1918
+ {"type": "blockEnd"},
1919
+ {"type": "blockEnd"}
1920
+ ]
1921
+ """.trimIndent()
1922
+
1923
+ val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
1924
+ val bulletSpans = result.getSpans(0, 2, CenteredBulletSpan::class.java)
1925
+
1926
+ assertTrue(bulletSpans.isNotEmpty())
1927
+ }
1928
+
1929
+ @Test
1930
+ fun `FixedLineHeightSpan - pushes all extra space below baseline`() {
1931
+ val span = FixedLineHeightSpan(30)
1932
+ val fm = android.graphics.Paint.FontMetricsInt()
1933
+ fm.ascent = -14
1934
+ fm.top = -14
1935
+ fm.descent = 6
1936
+ fm.bottom = 6
1937
+ // currentHeight = 6 - (-14) = 20, extra = 30 - 20 = 10
1938
+
1939
+ span.chooseHeight("x", 0, 1, 0, 0, fm)
1940
+
1941
+ assertEquals("ascent unchanged", -14, fm.ascent)
1942
+ assertEquals("top unchanged", -14, fm.top)
1943
+ assertEquals("descent increased by extra", 16, fm.descent)
1944
+ assertEquals("bottom matches descent", 16, fm.bottom)
1945
+ }
1946
+
1947
+ @Test
1948
+ fun `FixedLineHeightSpan - no change when height matches target`() {
1949
+ val span = FixedLineHeightSpan(20)
1950
+ val fm = android.graphics.Paint.FontMetricsInt()
1951
+ fm.ascent = -14
1952
+ fm.top = -14
1953
+ fm.descent = 6
1954
+ fm.bottom = 6
1955
+
1956
+ span.chooseHeight("x", 0, 1, 0, 0, fm)
1957
+
1958
+ assertEquals(-14, fm.ascent)
1959
+ assertEquals(-14, fm.top)
1960
+ assertEquals(6, fm.descent)
1961
+ assertEquals(6, fm.bottom)
1962
+ }
1963
+
1964
+ @Test
1965
+ fun `CenteredBulletSpan - restores paint state after draw`() {
1966
+ val bulletRadius = 3f
1967
+ val markerWidth = 24f
1968
+ val bodyFontSize = 16f
1969
+ val markerFontSize = 32f
1970
+ val span = CenteredBulletSpan(
1971
+ Color.BLACK,
1972
+ markerWidth,
1973
+ bulletRadius,
1974
+ bodyFontSize,
1975
+ LayoutConstants.LIST_MARKER_TEXT_GAP
1976
+ )
1977
+
1978
+ val paint = Paint()
1979
+ paint.textSize = markerFontSize
1980
+ paint.color = Color.RED
1981
+ paint.style = Paint.Style.STROKE
1982
+
1983
+ val bitmap = android.graphics.Bitmap.createBitmap(100, 100, android.graphics.Bitmap.Config.ARGB_8888)
1984
+ val canvas = android.graphics.Canvas(bitmap)
1985
+
1986
+ span.draw(canvas, "•", 0, 1, 0f, 0, 20, 40, paint)
1987
+
1988
+ assertEquals("textSize should be restored", markerFontSize, paint.textSize)
1989
+ assertEquals("color should be restored", Color.RED, paint.color)
1990
+ assertEquals("style should be restored", Paint.Style.STROKE, paint.style)
1991
+ }
1992
+
1993
+ @Test
1994
+ fun `CenteredBulletSpan - larger bullet preserves text side gap`() {
1995
+ val markerWidth = 24f
1996
+ val bodyFontSize = 16f
1997
+ val gapToText = LayoutConstants.LIST_MARKER_TEXT_GAP
1998
+ val normalSpan = CenteredBulletSpan(Color.BLACK, markerWidth, 3f, bodyFontSize, gapToText)
1999
+ val scaledSpan = CenteredBulletSpan(Color.BLACK, markerWidth, 6f, bodyFontSize, gapToText)
2000
+
2001
+ assertEquals(normalSpan.textSideGapPx(0f), scaledSpan.textSideGapPx(0f), 0.01f)
2002
+ assertEquals(gapToText, scaledSpan.textSideGapPx(0f), 0.01f)
2003
+ }
2004
+
2005
+ // ── Height Measurement ──────────────────────────────────────────────
2006
+
2007
+ @Test
2008
+ fun `measureHeight returns positive height for single paragraph`() {
2009
+ val renderJSON = """[{"type":"blockStart","nodeType":"paragraph","depth":0},{"type":"textRun","text":"Hello world"},{"type":"blockEnd"}]"""
2010
+ val height = RenderBridge.measureHeight(
2011
+ json = renderJSON,
2012
+ themeJson = null,
2013
+ width = 375f,
2014
+ density = 1f
2015
+ )
2016
+ assertTrue("Single paragraph should have positive height, got $height", height > 0f)
2017
+ }
2018
+
2019
+ @Test
2020
+ fun `measureHeight returns zero for empty content`() {
2021
+ val height = RenderBridge.measureHeight(
2022
+ json = "[]",
2023
+ themeJson = null,
2024
+ width = 375f,
2025
+ density = 1f
2026
+ )
2027
+ assertEquals("Empty content should have zero height", 0f, height)
2028
+ }
2029
+
2030
+ @Test
2031
+ fun `measureHeight adds content insets`() {
2032
+ val renderJSON = """[{"type":"blockStart","nodeType":"paragraph","depth":0},{"type":"textRun","text":"Hello world"},{"type":"blockEnd"}]"""
2033
+ val noInsetHeight = RenderBridge.measureHeight(
2034
+ json = renderJSON,
2035
+ themeJson = null,
2036
+ width = 375f,
2037
+ density = 1f
2038
+ )
2039
+ val insetHeight = RenderBridge.measureHeight(
2040
+ json = renderJSON,
2041
+ themeJson = """{"contentInsets":{"top":20,"bottom":20}}""",
2042
+ width = 375f,
2043
+ density = 1f
2044
+ )
2045
+ assertEquals(
2046
+ "Content insets should add 40 to height",
2047
+ noInsetHeight + 40f,
2048
+ insetHeight,
2049
+ 1f
2050
+ )
2051
+ }
2052
+
2053
+
2054
+ }