react-native-enriched-markdown 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +551 -0
  3. package/ReactNativeEnrichedMarkdown.podspec +27 -0
  4. package/android/build.gradle +101 -0
  5. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerDelegate.java +54 -0
  6. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +26 -0
  7. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/ComponentDescriptors.cpp +22 -0
  8. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/ComponentDescriptors.h +24 -0
  9. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +33 -0
  10. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +31 -0
  11. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +82 -0
  12. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +1388 -0
  13. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/ShadowNodes.cpp +17 -0
  14. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/ShadowNodes.h +32 -0
  15. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/States.cpp +16 -0
  16. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/States.h +20 -0
  17. package/android/gradle.properties +5 -0
  18. package/android/src/main/AndroidManifest.xml +2 -0
  19. package/android/src/main/baseline-prof.txt +65 -0
  20. package/android/src/main/cpp/jni-adapter.cpp +220 -0
  21. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +270 -0
  22. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt +15 -0
  23. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +173 -0
  24. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextPackage.kt +17 -0
  25. package/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +385 -0
  26. package/android/src/main/java/com/swmansion/enriched/markdown/accessibility/MarkdownAccessibilityHelper.kt +279 -0
  27. package/android/src/main/java/com/swmansion/enriched/markdown/events/LinkLongPressEvent.kt +23 -0
  28. package/android/src/main/java/com/swmansion/enriched/markdown/events/LinkPressEvent.kt +23 -0
  29. package/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt +31 -0
  30. package/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt +62 -0
  31. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt +166 -0
  32. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt +84 -0
  33. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt +104 -0
  34. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeRenderer.kt +36 -0
  35. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/DocumentRenderer.kt +16 -0
  36. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/EmphasisRenderer.kt +27 -0
  37. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt +70 -0
  38. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ImageRenderer.kt +68 -0
  39. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LineBreakRenderer.kt +16 -0
  40. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +29 -0
  41. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListContextManager.kt +105 -0
  42. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt +59 -0
  43. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt +76 -0
  44. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt +103 -0
  45. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ParagraphRenderer.kt +80 -0
  46. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/Renderer.kt +109 -0
  47. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +86 -0
  48. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/StrikethroughRenderer.kt +27 -0
  49. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/StrongRenderer.kt +27 -0
  50. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/TextRenderer.kt +30 -0
  51. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ThematicBreakRenderer.kt +45 -0
  52. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/UnderlineRenderer.kt +27 -0
  53. package/android/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt +136 -0
  54. package/android/src/main/java/com/swmansion/enriched/markdown/spans/BlockquoteSpan.kt +135 -0
  55. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBackgroundSpan.kt +180 -0
  56. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt +196 -0
  57. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt +27 -0
  58. package/android/src/main/java/com/swmansion/enriched/markdown/spans/EmphasisSpan.kt +34 -0
  59. package/android/src/main/java/com/swmansion/enriched/markdown/spans/HeadingSpan.kt +38 -0
  60. package/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt +321 -0
  61. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LineHeightSpan.kt +27 -0
  62. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +51 -0
  63. package/android/src/main/java/com/swmansion/enriched/markdown/spans/MarginBottomSpan.kt +76 -0
  64. package/android/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt +87 -0
  65. package/android/src/main/java/com/swmansion/enriched/markdown/spans/StrikethroughSpan.kt +12 -0
  66. package/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt +37 -0
  67. package/android/src/main/java/com/swmansion/enriched/markdown/spans/TextSpan.kt +26 -0
  68. package/android/src/main/java/com/swmansion/enriched/markdown/spans/ThematicBreakSpan.kt +69 -0
  69. package/android/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt +69 -0
  70. package/android/src/main/java/com/swmansion/enriched/markdown/styles/BaseBlockStyle.kt +11 -0
  71. package/android/src/main/java/com/swmansion/enriched/markdown/styles/BlockquoteStyle.kt +51 -0
  72. package/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeBlockStyle.kt +54 -0
  73. package/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt +21 -0
  74. package/android/src/main/java/com/swmansion/enriched/markdown/styles/EmphasisStyle.kt +17 -0
  75. package/android/src/main/java/com/swmansion/enriched/markdown/styles/HeadingStyle.kt +33 -0
  76. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ImageStyle.kt +23 -0
  77. package/android/src/main/java/com/swmansion/enriched/markdown/styles/InlineImageStyle.kt +17 -0
  78. package/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkStyle.kt +19 -0
  79. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ListStyle.kt +57 -0
  80. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ParagraphStyle.kt +33 -0
  81. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StrikethroughStyle.kt +17 -0
  82. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StrongStyle.kt +17 -0
  83. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleConfig.kt +211 -0
  84. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleParser.kt +92 -0
  85. package/android/src/main/java/com/swmansion/enriched/markdown/styles/TextAlignment.kt +32 -0
  86. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ThematicBreakStyle.kt +23 -0
  87. package/android/src/main/java/com/swmansion/enriched/markdown/styles/UnderlineStyle.kt +17 -0
  88. package/android/src/main/java/com/swmansion/enriched/markdown/utils/AsyncDrawable.kt +91 -0
  89. package/android/src/main/java/com/swmansion/enriched/markdown/utils/HTMLGenerator.kt +827 -0
  90. package/android/src/main/java/com/swmansion/enriched/markdown/utils/LinkLongPressMovementMethod.kt +121 -0
  91. package/android/src/main/java/com/swmansion/enriched/markdown/utils/MarkdownExtractor.kt +375 -0
  92. package/android/src/main/java/com/swmansion/enriched/markdown/utils/SelectionActionMode.kt +139 -0
  93. package/android/src/main/java/com/swmansion/enriched/markdown/utils/Utils.kt +183 -0
  94. package/android/src/main/jni/CMakeLists.txt +70 -0
  95. package/android/src/main/jni/EnrichedMarkdownTextSpec.cpp +21 -0
  96. package/android/src/main/jni/EnrichedMarkdownTextSpec.h +25 -0
  97. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextComponentDescriptor.h +29 -0
  98. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextMeasurementManager.cpp +45 -0
  99. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextMeasurementManager.h +21 -0
  100. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp +20 -0
  101. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h +37 -0
  102. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h +22 -0
  103. package/cpp/md4c/md4c.c +6492 -0
  104. package/cpp/md4c/md4c.h +402 -0
  105. package/cpp/parser/MD4CParser.cpp +327 -0
  106. package/cpp/parser/MD4CParser.hpp +27 -0
  107. package/cpp/parser/MarkdownASTNode.hpp +51 -0
  108. package/ios/EnrichedMarkdownText.h +18 -0
  109. package/ios/EnrichedMarkdownText.mm +1401 -0
  110. package/ios/attachments/EnrichedMarkdownImageAttachment.h +23 -0
  111. package/ios/attachments/EnrichedMarkdownImageAttachment.m +185 -0
  112. package/ios/attachments/ThematicBreakAttachment.h +15 -0
  113. package/ios/attachments/ThematicBreakAttachment.m +33 -0
  114. package/ios/generated/EnrichedMarkdownTextSpec/ComponentDescriptors.cpp +22 -0
  115. package/ios/generated/EnrichedMarkdownTextSpec/ComponentDescriptors.h +24 -0
  116. package/ios/generated/EnrichedMarkdownTextSpec/EventEmitters.cpp +33 -0
  117. package/ios/generated/EnrichedMarkdownTextSpec/EventEmitters.h +31 -0
  118. package/ios/generated/EnrichedMarkdownTextSpec/Props.cpp +82 -0
  119. package/ios/generated/EnrichedMarkdownTextSpec/Props.h +1388 -0
  120. package/ios/generated/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +20 -0
  121. package/ios/generated/EnrichedMarkdownTextSpec/ShadowNodes.cpp +17 -0
  122. package/ios/generated/EnrichedMarkdownTextSpec/ShadowNodes.h +32 -0
  123. package/ios/generated/EnrichedMarkdownTextSpec/States.cpp +16 -0
  124. package/ios/generated/EnrichedMarkdownTextSpec/States.h +20 -0
  125. package/ios/internals/EnrichedMarkdownTextComponentDescriptor.h +19 -0
  126. package/ios/internals/EnrichedMarkdownTextShadowNode.h +43 -0
  127. package/ios/internals/EnrichedMarkdownTextShadowNode.mm +85 -0
  128. package/ios/internals/EnrichedMarkdownTextState.h +24 -0
  129. package/ios/parser/MarkdownASTNode.h +35 -0
  130. package/ios/parser/MarkdownASTNode.m +32 -0
  131. package/ios/parser/MarkdownParser.h +17 -0
  132. package/ios/parser/MarkdownParser.mm +42 -0
  133. package/ios/parser/MarkdownParserBridge.mm +120 -0
  134. package/ios/renderer/AttributedRenderer.h +11 -0
  135. package/ios/renderer/AttributedRenderer.m +152 -0
  136. package/ios/renderer/BlockquoteRenderer.h +7 -0
  137. package/ios/renderer/BlockquoteRenderer.m +160 -0
  138. package/ios/renderer/CodeBlockRenderer.h +10 -0
  139. package/ios/renderer/CodeBlockRenderer.m +90 -0
  140. package/ios/renderer/CodeRenderer.h +10 -0
  141. package/ios/renderer/CodeRenderer.m +60 -0
  142. package/ios/renderer/EmphasisRenderer.h +6 -0
  143. package/ios/renderer/EmphasisRenderer.m +96 -0
  144. package/ios/renderer/HeadingRenderer.h +7 -0
  145. package/ios/renderer/HeadingRenderer.m +105 -0
  146. package/ios/renderer/ImageRenderer.h +12 -0
  147. package/ios/renderer/ImageRenderer.m +83 -0
  148. package/ios/renderer/LinkRenderer.h +7 -0
  149. package/ios/renderer/LinkRenderer.m +69 -0
  150. package/ios/renderer/ListItemRenderer.h +16 -0
  151. package/ios/renderer/ListItemRenderer.m +103 -0
  152. package/ios/renderer/ListRenderer.h +13 -0
  153. package/ios/renderer/ListRenderer.m +70 -0
  154. package/ios/renderer/NodeRenderer.h +8 -0
  155. package/ios/renderer/ParagraphRenderer.h +7 -0
  156. package/ios/renderer/ParagraphRenderer.m +80 -0
  157. package/ios/renderer/RenderContext.h +105 -0
  158. package/ios/renderer/RenderContext.m +312 -0
  159. package/ios/renderer/RendererFactory.h +12 -0
  160. package/ios/renderer/RendererFactory.m +116 -0
  161. package/ios/renderer/StrikethroughRenderer.h +6 -0
  162. package/ios/renderer/StrikethroughRenderer.m +40 -0
  163. package/ios/renderer/StrongRenderer.h +6 -0
  164. package/ios/renderer/StrongRenderer.m +83 -0
  165. package/ios/renderer/TextRenderer.h +6 -0
  166. package/ios/renderer/TextRenderer.m +16 -0
  167. package/ios/renderer/ThematicBreakRenderer.h +5 -0
  168. package/ios/renderer/ThematicBreakRenderer.m +53 -0
  169. package/ios/renderer/UnderlineRenderer.h +6 -0
  170. package/ios/renderer/UnderlineRenderer.m +39 -0
  171. package/ios/styles/StyleConfig.h +274 -0
  172. package/ios/styles/StyleConfig.mm +1806 -0
  173. package/ios/utils/AccessibilityInfo.h +35 -0
  174. package/ios/utils/AccessibilityInfo.m +24 -0
  175. package/ios/utils/BlockquoteBorder.h +20 -0
  176. package/ios/utils/BlockquoteBorder.m +92 -0
  177. package/ios/utils/CodeBackground.h +19 -0
  178. package/ios/utils/CodeBackground.m +191 -0
  179. package/ios/utils/CodeBlockBackground.h +17 -0
  180. package/ios/utils/CodeBlockBackground.m +82 -0
  181. package/ios/utils/EditMenuUtils.h +22 -0
  182. package/ios/utils/EditMenuUtils.m +118 -0
  183. package/ios/utils/FontUtils.h +25 -0
  184. package/ios/utils/FontUtils.m +27 -0
  185. package/ios/utils/HTMLGenerator.h +20 -0
  186. package/ios/utils/HTMLGenerator.m +793 -0
  187. package/ios/utils/LastElementUtils.h +53 -0
  188. package/ios/utils/ListMarkerDrawer.h +15 -0
  189. package/ios/utils/ListMarkerDrawer.m +127 -0
  190. package/ios/utils/MarkdownAccessibilityElementBuilder.h +45 -0
  191. package/ios/utils/MarkdownAccessibilityElementBuilder.m +323 -0
  192. package/ios/utils/MarkdownExtractor.h +17 -0
  193. package/ios/utils/MarkdownExtractor.m +308 -0
  194. package/ios/utils/ParagraphStyleUtils.h +21 -0
  195. package/ios/utils/ParagraphStyleUtils.m +111 -0
  196. package/ios/utils/PasteboardUtils.h +36 -0
  197. package/ios/utils/PasteboardUtils.m +134 -0
  198. package/ios/utils/RTFExportUtils.h +24 -0
  199. package/ios/utils/RTFExportUtils.m +297 -0
  200. package/ios/utils/RuntimeKeys.h +38 -0
  201. package/ios/utils/RuntimeKeys.m +11 -0
  202. package/ios/utils/TextViewLayoutManager.h +14 -0
  203. package/ios/utils/TextViewLayoutManager.mm +113 -0
  204. package/lib/module/EnrichedMarkdownText.js +65 -0
  205. package/lib/module/EnrichedMarkdownText.js.map +1 -0
  206. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +210 -0
  207. package/lib/module/index.js +4 -0
  208. package/lib/module/index.js.map +1 -0
  209. package/lib/module/normalizeMarkdownStyle.js +384 -0
  210. package/lib/module/normalizeMarkdownStyle.js.map +1 -0
  211. package/lib/module/package.json +1 -0
  212. package/lib/typescript/package.json +1 -0
  213. package/lib/typescript/src/EnrichedMarkdownText.d.ts +183 -0
  214. package/lib/typescript/src/EnrichedMarkdownText.d.ts.map +1 -0
  215. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +185 -0
  216. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -0
  217. package/lib/typescript/src/index.d.ts +4 -0
  218. package/lib/typescript/src/index.d.ts.map +1 -0
  219. package/lib/typescript/src/normalizeMarkdownStyle.d.ts +6 -0
  220. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -0
  221. package/package.json +186 -1
  222. package/react-native.config.js +13 -0
  223. package/src/EnrichedMarkdownText.tsx +280 -0
  224. package/src/EnrichedMarkdownTextNativeComponent.ts +210 -0
  225. package/src/index.tsx +10 -0
  226. package/src/normalizeMarkdownStyle.ts +423 -0
@@ -0,0 +1,827 @@
1
+ package com.swmansion.enriched.markdown.utils
2
+
3
+ import android.graphics.Typeface
4
+ import android.text.Spannable
5
+ import android.text.style.StyleSpan
6
+ import android.text.style.UnderlineSpan
7
+ import com.swmansion.enriched.markdown.spans.BlockquoteSpan
8
+ import com.swmansion.enriched.markdown.spans.CodeBlockSpan
9
+ import com.swmansion.enriched.markdown.spans.CodeSpan
10
+ import com.swmansion.enriched.markdown.spans.EmphasisSpan
11
+ import com.swmansion.enriched.markdown.spans.HeadingSpan
12
+ import com.swmansion.enriched.markdown.spans.ImageSpan
13
+ import com.swmansion.enriched.markdown.spans.LinkSpan
14
+ import com.swmansion.enriched.markdown.spans.OrderedListSpan
15
+ import com.swmansion.enriched.markdown.spans.StrikethroughSpan
16
+ import com.swmansion.enriched.markdown.spans.StrongSpan
17
+ import com.swmansion.enriched.markdown.spans.UnorderedListSpan
18
+ import com.swmansion.enriched.markdown.styles.StyleConfig
19
+
20
+ /** Generates semantic HTML with inline styles from Spannable text. */
21
+ object HTMLGenerator {
22
+ private const val OBJECT_REPLACEMENT_CHAR = '\uFFFC'
23
+
24
+ /** Pre-computed styles to avoid repeated StyleConfig method calls. */
25
+ private class CachedStyles(
26
+ style: StyleConfig,
27
+ private val fontDensity: Float,
28
+ private val dimDensity: Float,
29
+ ) {
30
+ // Convert device pixels back to CSS pixels
31
+ private fun fontPx(px: Float) = (px / fontDensity).toInt()
32
+
33
+ private fun dimPx(px: Float) = (px / dimDensity).toInt()
34
+
35
+ // Paragraph
36
+ val paragraphColor: String
37
+ val paragraphFontSize: Int
38
+ val paragraphMarginBottom: Int
39
+
40
+ // Code block
41
+ val codeBlockColor: String
42
+ val codeBlockBgColor: String
43
+ val codeBlockFontSize: Int
44
+ val codeBlockPadding: Int
45
+ val codeBlockBorderRadius: Int
46
+ val codeBlockMarginBottom: Int
47
+
48
+ // Inline code
49
+ val codeColor: String
50
+ val codeBgColor: String
51
+
52
+ // Blockquote
53
+ val blockquoteColor: String
54
+ val blockquoteBgColor: String
55
+ val blockquoteBorderColor: String
56
+ val blockquoteBorderWidth: Int
57
+ val blockquoteGapWidth: Int
58
+ val blockquoteMarginBottom: Int
59
+ val blockquoteFontSize: Int
60
+
61
+ // List
62
+ val listColor: String
63
+ val listFontSize: Int
64
+ val listMarginBottom: Int
65
+ val listMarginLeft: Int
66
+
67
+ // Link
68
+ val linkColor: String
69
+ val linkUnderline: Boolean
70
+
71
+ // Strong/Emphasis/Strikethrough/Underline
72
+ val strongColor: String?
73
+ val emphasisColor: String?
74
+ val strikethroughColor: String?
75
+ val underlineColor: String?
76
+
77
+ // Image
78
+ val imageMarginBottom: Int
79
+ val imageBorderRadius: Int
80
+
81
+ // Fixed HTML values (not from StyleConfig)
82
+ val blockquotePaddingVertical = "8px"
83
+ val blockquoteBorderRadiusCorners = "0 8px 8px 0"
84
+ val blockquoteNestedMargin = "8px 0 0 0"
85
+ val blockquoteParagraphMargin = "0 0 4px 0"
86
+ val inlineImageHeight = "1.2em"
87
+ val inlineImageVerticalAlign = "-0.2em"
88
+ val codePadding = "2px 4px"
89
+ val codeBorderRadius = "4px"
90
+ val codeFontSize = "0.7em"
91
+
92
+ // Headings (array for O(1) lookup)
93
+ val headingFontSizes: IntArray
94
+ val headingFontWeights: Array<String>
95
+ val headingColors: Array<String>
96
+ val headingMarginBottoms: IntArray
97
+
98
+ init {
99
+ // Paragraph
100
+ val pStyle = style.paragraphStyle
101
+ paragraphColor = colorToCSS(pStyle.color)
102
+ paragraphFontSize = fontPx(pStyle.fontSize)
103
+ paragraphMarginBottom = dimPx(pStyle.marginBottom)
104
+
105
+ // Code block
106
+ val cbStyle = style.codeBlockStyle
107
+ codeBlockColor = colorToCSS(cbStyle.color)
108
+ codeBlockBgColor = colorToCSS(cbStyle.backgroundColor)
109
+ codeBlockFontSize = fontPx(cbStyle.fontSize)
110
+ codeBlockPadding = dimPx(cbStyle.padding)
111
+ codeBlockBorderRadius = dimPx(cbStyle.borderRadius)
112
+ codeBlockMarginBottom = dimPx(cbStyle.marginBottom)
113
+
114
+ // Inline code
115
+ val cStyle = style.codeStyle
116
+ codeColor = colorToCSS(cStyle.color)
117
+ codeBgColor = colorToCSS(cStyle.backgroundColor)
118
+
119
+ // Blockquote
120
+ val bqStyle = style.blockquoteStyle
121
+ blockquoteColor = colorToCSS(bqStyle.color)
122
+ blockquoteBgColor = colorToCSS(bqStyle.backgroundColor ?: 0)
123
+ blockquoteBorderColor = colorToCSS(bqStyle.borderColor)
124
+ blockquoteBorderWidth = dimPx(bqStyle.borderWidth)
125
+ blockquoteGapWidth = dimPx(bqStyle.gapWidth)
126
+ blockquoteMarginBottom = dimPx(bqStyle.marginBottom)
127
+ blockquoteFontSize = fontPx(bqStyle.fontSize)
128
+
129
+ // List
130
+ val lStyle = style.listStyle
131
+ listColor = colorToCSS(lStyle.color)
132
+ listFontSize = fontPx(lStyle.fontSize)
133
+ listMarginBottom = dimPx(lStyle.marginBottom)
134
+ listMarginLeft = dimPx(lStyle.marginLeft)
135
+
136
+ // Link
137
+ linkColor = colorToCSS(style.linkStyle.color)
138
+ linkUnderline = style.linkStyle.underline
139
+
140
+ // Strong/Emphasis/Strikethrough/Underline (nullable for inherit)
141
+ val sc = style.strongStyle.color
142
+ strongColor = if (sc != null && sc != 0) colorToCSS(sc) else null
143
+ val ec = style.emphasisStyle.color
144
+ emphasisColor = if (ec != null && ec != 0) colorToCSS(ec) else null
145
+ val strikeColor = style.strikethroughStyle.color
146
+ strikethroughColor = if (strikeColor != 0) colorToCSS(strikeColor) else null
147
+ val underline = style.underlineStyle.color
148
+ underlineColor = if (underline != 0) colorToCSS(underline) else null
149
+
150
+ // Image
151
+ val imgStyle = style.imageStyle
152
+ imageMarginBottom = dimPx(imgStyle.marginBottom)
153
+ imageBorderRadius = dimPx(imgStyle.borderRadius)
154
+
155
+ // Headings (1-6, index 0-5)
156
+ headingFontSizes = IntArray(6)
157
+ headingFontWeights = Array(6) { "" }
158
+ headingColors = Array(6) { "" }
159
+ headingMarginBottoms = IntArray(6)
160
+
161
+ for (level in 1..6) {
162
+ val hStyle = style.headingStyles[level]!!
163
+ val idx = level - 1
164
+ headingFontSizes[idx] = fontPx(hStyle.fontSize)
165
+ headingFontWeights[idx] = fontWeightToCSS(hStyle.fontWeight)
166
+ headingColors[idx] = colorToCSS(hStyle.color)
167
+ headingMarginBottoms[idx] = dimPx(hStyle.marginBottom)
168
+ }
169
+ }
170
+
171
+ companion object {
172
+ private fun colorToCSS(color: Int): String {
173
+ if (color == 0) return "inherit"
174
+ val alpha = (color shr 24) and 0xFF
175
+ val red = (color shr 16) and 0xFF
176
+ val green = (color shr 8) and 0xFF
177
+ val blue = color and 0xFF
178
+
179
+ return if (alpha < 255) {
180
+ "rgba($red, $green, $blue, ${alpha / 255f})"
181
+ } else {
182
+ String.format("#%02X%02X%02X", red, green, blue)
183
+ }
184
+ }
185
+
186
+ private fun fontWeightToCSS(fontWeight: String): String =
187
+ when {
188
+ fontWeight.equals("bold", ignoreCase = true) -> "700"
189
+ fontWeight.equals("semibold", ignoreCase = true) -> "600"
190
+ fontWeight.equals("medium", ignoreCase = true) -> "500"
191
+ fontWeight.isEmpty() || fontWeight.equals("normal", ignoreCase = true) -> "normal"
192
+ else -> fontWeight
193
+ }
194
+ }
195
+ }
196
+
197
+ private class GeneratorState {
198
+ var inCodeBlock = false
199
+ var previousWasCodeBlock = false
200
+ val codeBlockLines = ArrayList<String>(16) // Pre-sized
201
+
202
+ var inBlockquote = false
203
+ var blockquoteDepth = -1
204
+ var previousWasBlockquote = false
205
+
206
+ var listDepth = -1
207
+ val openListTypes = ArrayList<Boolean>(4) // true = ol, false = ul
208
+ }
209
+
210
+ // Paragraph type constants (H1-H6 = 1-6 directly from HeadingSpan.level)
211
+ private const val TYPE_NORMAL = 0
212
+ private const val TYPE_H1 = 1
213
+ private const val TYPE_H6 = 6
214
+ private const val TYPE_CODE_BLOCK = 7
215
+ private const val TYPE_BLOCKQUOTE = 8
216
+ private const val TYPE_ORDERED_LIST = 9
217
+ private const val TYPE_UNORDERED_LIST = 10
218
+
219
+ private data class ParagraphInfo(
220
+ val start: Int,
221
+ val end: Int,
222
+ val type: Int,
223
+ val depth: Int = 0,
224
+ )
225
+
226
+ fun generateHTML(
227
+ text: Spannable,
228
+ style: StyleConfig,
229
+ scaledDensity: Float = 1f,
230
+ density: Float = 1f,
231
+ ): String {
232
+ if (text.isEmpty()) return "<html></html>"
233
+
234
+ // Pre-cache all styles (single allocation)
235
+ val styles = CachedStyles(style, scaledDensity, density)
236
+ val state = GeneratorState()
237
+
238
+ // Estimate capacity (average 2x text length)
239
+ val html = StringBuilder(text.length * 2)
240
+ html.append("<html>")
241
+
242
+ // Collect paragraphs
243
+ val paragraphs = collectParagraphs(text)
244
+
245
+ for (para in paragraphs) {
246
+ processParagraph(html, text, para, styles, state)
247
+ }
248
+
249
+ closeRemainingContainers(html, state, styles)
250
+ html.append("</html>")
251
+
252
+ return html.toString()
253
+ }
254
+
255
+ private fun processParagraph(
256
+ html: StringBuilder,
257
+ text: Spannable,
258
+ para: ParagraphInfo,
259
+ styles: CachedStyles,
260
+ state: GeneratorState,
261
+ ) {
262
+ val paraText = text.subSequence(para.start, para.end).toString().trimEnd('\n')
263
+
264
+ // Handle empty paragraphs
265
+ if (paraText.isEmpty() && para.type == TYPE_NORMAL) {
266
+ closeAllBlockquotes(html, state)
267
+ state.previousWasBlockquote = false
268
+ return
269
+ }
270
+
271
+ // Get content range (trim trailing newline)
272
+ val contentEnd = if (para.end > para.start && text[para.end - 1] == '\n') para.end - 1 else para.end
273
+ val isCodeBlock = para.type == TYPE_CODE_BLOCK
274
+ val inlineContent = generateInlineHTML(text, para.start, contentEnd, styles, isCodeBlock)
275
+
276
+ // Handle different paragraph types
277
+ when (para.type) {
278
+ TYPE_CODE_BLOCK -> handleCodeBlock(inlineContent, state)
279
+ TYPE_BLOCKQUOTE -> handleBlockquote(html, inlineContent, para, styles, state)
280
+ TYPE_ORDERED_LIST, TYPE_UNORDERED_LIST -> handleList(html, inlineContent, para, styles, state)
281
+ in TYPE_H1..TYPE_H6 -> handleHeading(html, inlineContent, para.type, styles, state)
282
+ else -> handleNormalParagraph(html, inlineContent, styles, state)
283
+ }
284
+ }
285
+
286
+ private fun handleCodeBlock(
287
+ content: String,
288
+ state: GeneratorState,
289
+ ) {
290
+ if (!state.inCodeBlock) {
291
+ state.inCodeBlock = true
292
+ state.codeBlockLines.clear()
293
+ }
294
+
295
+ state.codeBlockLines.add(content.trimStart())
296
+ state.previousWasCodeBlock = true
297
+ state.previousWasBlockquote = false
298
+ }
299
+
300
+ private fun outputCodeBlock(
301
+ html: StringBuilder,
302
+ lines: List<String>,
303
+ styles: CachedStyles,
304
+ ) {
305
+ if (lines.isEmpty()) return
306
+
307
+ html
308
+ .append("<pre style=\"background-color: ")
309
+ .append(styles.codeBlockBgColor)
310
+ .append("; padding: ")
311
+ .append(styles.codeBlockPadding)
312
+ .append("px; border-radius: ")
313
+ .append(styles.codeBlockBorderRadius)
314
+ .append("px; margin: 0 0 ")
315
+ .append(styles.codeBlockMarginBottom)
316
+ .append("px 0; overflow-x: auto;\"><code style=\"font-family: Menlo, Monaco, Consolas, monospace; font-size: ")
317
+ .append(styles.codeBlockFontSize)
318
+ .append("px; color: ")
319
+ .append(styles.codeBlockColor)
320
+ .append(";\">")
321
+
322
+ for (i in lines.indices) {
323
+ if (i > 0) html.append('\n')
324
+ html.append(lines[i])
325
+ }
326
+
327
+ html.append("</code></pre>")
328
+ }
329
+
330
+ private fun handleBlockquote(
331
+ html: StringBuilder,
332
+ content: String,
333
+ para: ParagraphInfo,
334
+ styles: CachedStyles,
335
+ state: GeneratorState,
336
+ ) {
337
+ closeCodeBlockIfOpen(html, state, styles)
338
+
339
+ val depth = para.depth
340
+
341
+ // Reset if coming from non-blockquote content
342
+ if (!state.previousWasBlockquote && state.inBlockquote) {
343
+ closeAllBlockquotes(html, state)
344
+ }
345
+
346
+ while (state.blockquoteDepth > depth) {
347
+ html.append("</blockquote>")
348
+ state.blockquoteDepth--
349
+ }
350
+
351
+ while (state.blockquoteDepth < depth) {
352
+ state.blockquoteDepth++
353
+ state.inBlockquote = true
354
+
355
+ if (state.blockquoteDepth == 0) {
356
+ html
357
+ .append("<blockquote style=\"background-color: ")
358
+ .append(styles.blockquoteBgColor)
359
+ .append("; border-left: ")
360
+ .append(styles.blockquoteBorderWidth)
361
+ .append("px solid ")
362
+ .append(styles.blockquoteBorderColor)
363
+ .append("; padding: ")
364
+ .append(styles.blockquotePaddingVertical)
365
+ .append(" ")
366
+ .append(styles.blockquoteGapWidth)
367
+ .append("px; margin: 0 0 ")
368
+ .append(styles.blockquoteMarginBottom)
369
+ .append("px 0; border-radius: ")
370
+ .append(styles.blockquoteBorderRadiusCorners)
371
+ .append(";\">")
372
+ } else {
373
+ html
374
+ .append("<blockquote style=\"border-left: ")
375
+ .append(styles.blockquoteBorderWidth)
376
+ .append("px solid ")
377
+ .append(styles.blockquoteBorderColor)
378
+ .append("; padding-left: ")
379
+ .append(styles.blockquoteGapWidth)
380
+ .append("px; margin: ")
381
+ .append(styles.blockquoteNestedMargin)
382
+ .append(";\">")
383
+ }
384
+ }
385
+
386
+ html
387
+ .append("<p style=\"margin: ")
388
+ .append(styles.blockquoteParagraphMargin)
389
+ .append("; color: ")
390
+ .append(styles.blockquoteColor)
391
+ .append("; font-size: ")
392
+ .append(styles.blockquoteFontSize)
393
+ .append("px;\">")
394
+ .append(content)
395
+ .append("</p>")
396
+
397
+ state.previousWasBlockquote = true
398
+ state.previousWasCodeBlock = false
399
+ }
400
+
401
+ private fun handleList(
402
+ html: StringBuilder,
403
+ content: String,
404
+ para: ParagraphInfo,
405
+ styles: CachedStyles,
406
+ state: GeneratorState,
407
+ ) {
408
+ closeCodeBlockIfOpen(html, state, styles)
409
+ closeAllBlockquotes(html, state)
410
+
411
+ val depth = para.depth
412
+ val isOrdered = para.type == TYPE_ORDERED_LIST
413
+
414
+ // Close lists to shallower depth
415
+ while (state.listDepth > depth) {
416
+ html.append(if (state.openListTypes.lastOrNull() == true) "</ol>" else "</ul>")
417
+ if (state.openListTypes.isNotEmpty()) state.openListTypes.removeAt(state.openListTypes.lastIndex)
418
+ state.listDepth--
419
+ }
420
+
421
+ // Handle list type change at same depth (ul <-> ol)
422
+ if (state.listDepth == depth && state.openListTypes.isNotEmpty()) {
423
+ if (state.openListTypes.last() != isOrdered) {
424
+ html.append(if (state.openListTypes.last()) "</ol>" else "</ul>")
425
+ state.openListTypes.removeAt(state.openListTypes.lastIndex)
426
+ state.listDepth--
427
+ }
428
+ }
429
+
430
+ // Open lists to deeper depth
431
+ while (state.listDepth < depth) {
432
+ state.listDepth++
433
+ val marginStyle =
434
+ if (state.listDepth == 0) {
435
+ "margin: 0 0 ${styles.paragraphMarginBottom}px 0; "
436
+ } else {
437
+ "margin: 0; "
438
+ }
439
+
440
+ if (isOrdered) {
441
+ html
442
+ .append("<ol style=\"")
443
+ .append(marginStyle)
444
+ .append("padding-left: ")
445
+ .append(styles.listMarginLeft)
446
+ .append("px;\">")
447
+ state.openListTypes.add(true)
448
+ } else {
449
+ html
450
+ .append("<ul style=\"")
451
+ .append(marginStyle)
452
+ .append("padding-left: ")
453
+ .append(styles.listMarginLeft)
454
+ .append("px; list-style-type: disc;\">")
455
+ state.openListTypes.add(false)
456
+ }
457
+ }
458
+
459
+ html
460
+ .append("<li style=\"margin-bottom: ")
461
+ .append(styles.listMarginBottom)
462
+ .append("px; color: ")
463
+ .append(styles.listColor)
464
+ .append("; font-size: ")
465
+ .append(styles.listFontSize)
466
+ .append("px;\">")
467
+ .append(content)
468
+ .append("</li>")
469
+
470
+ state.previousWasBlockquote = false
471
+ state.previousWasCodeBlock = false
472
+ }
473
+
474
+ private fun handleHeading(
475
+ html: StringBuilder,
476
+ content: String,
477
+ type: Int,
478
+ styles: CachedStyles,
479
+ state: GeneratorState,
480
+ ) {
481
+ closeCodeBlockIfOpen(html, state, styles)
482
+ closeAllBlockquotes(html, state)
483
+ closeListsIfOpen(html, state)
484
+
485
+ val level = type // TYPE_H1..TYPE_H6 = 1..6
486
+ val idx = level - 1
487
+
488
+ html
489
+ .append("<h")
490
+ .append(level)
491
+ .append(" style=\"font-size: ")
492
+ .append(styles.headingFontSizes[idx])
493
+ .append("px; font-weight: ")
494
+ .append(styles.headingFontWeights[idx])
495
+ .append("; color: ")
496
+ .append(styles.headingColors[idx])
497
+ .append("; margin: 0 0 ")
498
+ .append(styles.headingMarginBottoms[idx])
499
+ .append("px 0;\">")
500
+ .append(content)
501
+ .append("</h")
502
+ .append(level)
503
+ .append('>')
504
+
505
+ state.previousWasBlockquote = false
506
+ state.previousWasCodeBlock = false
507
+ }
508
+
509
+ private fun handleNormalParagraph(
510
+ html: StringBuilder,
511
+ content: String,
512
+ styles: CachedStyles,
513
+ state: GeneratorState,
514
+ ) {
515
+ closeCodeBlockIfOpen(html, state, styles)
516
+ closeAllBlockquotes(html, state)
517
+ closeListsIfOpen(html, state)
518
+
519
+ html
520
+ .append("<p style=\"margin: 0 0 ")
521
+ .append(styles.paragraphMarginBottom)
522
+ .append("px 0; color: ")
523
+ .append(styles.paragraphColor)
524
+ .append("; font-size: ")
525
+ .append(styles.paragraphFontSize)
526
+ .append("px;\">")
527
+ .append(content)
528
+ .append("</p>")
529
+
530
+ state.previousWasBlockquote = false
531
+ state.previousWasCodeBlock = false
532
+ }
533
+
534
+ private fun closeCodeBlockIfOpen(
535
+ html: StringBuilder,
536
+ state: GeneratorState,
537
+ styles: CachedStyles,
538
+ ) {
539
+ if (state.inCodeBlock) {
540
+ state.inCodeBlock = false
541
+ outputCodeBlock(html, state.codeBlockLines, styles)
542
+ state.codeBlockLines.clear()
543
+ }
544
+ }
545
+
546
+ private fun closeAllBlockquotes(
547
+ html: StringBuilder,
548
+ state: GeneratorState,
549
+ ) {
550
+ while (state.blockquoteDepth >= 0) {
551
+ html.append("</blockquote>")
552
+ state.blockquoteDepth--
553
+ }
554
+ state.inBlockquote = false
555
+ }
556
+
557
+ private fun closeListsIfOpen(
558
+ html: StringBuilder,
559
+ state: GeneratorState,
560
+ ) {
561
+ while (state.openListTypes.isNotEmpty()) {
562
+ html.append(if (state.openListTypes.last()) "</ol>" else "</ul>")
563
+ state.openListTypes.removeAt(state.openListTypes.lastIndex)
564
+ }
565
+ state.listDepth = -1
566
+ }
567
+
568
+ private fun closeRemainingContainers(
569
+ html: StringBuilder,
570
+ state: GeneratorState,
571
+ styles: CachedStyles,
572
+ ) {
573
+ closeCodeBlockIfOpen(html, state, styles)
574
+ closeAllBlockquotes(html, state)
575
+ closeListsIfOpen(html, state)
576
+ }
577
+
578
+ private fun generateInlineHTML(
579
+ text: Spannable,
580
+ start: Int,
581
+ end: Int,
582
+ styles: CachedStyles,
583
+ isCodeBlock: Boolean,
584
+ ): String {
585
+ val html = StringBuilder(end - start + 32)
586
+ var i = start
587
+
588
+ while (i < end) {
589
+ val char = text[i]
590
+
591
+ when {
592
+ char == '\n' && i == end - 1 -> {
593
+ i++
594
+ }
595
+
596
+ char == OBJECT_REPLACEMENT_CHAR -> {
597
+ appendImageIfPresent(html, text, i, styles)
598
+ i++
599
+ }
600
+
601
+ else -> {
602
+ val segmentEnd = minOf(text.nextSpanTransition(i, end, Any::class.java), end)
603
+ val segmentText = text.subSequence(i, segmentEnd).toString()
604
+
605
+ if (segmentText.isNotEmpty() && segmentText != "\n") {
606
+ appendStyledSegment(html, text, i, segmentEnd, segmentText, styles, isCodeBlock)
607
+ }
608
+ i = segmentEnd
609
+ }
610
+ }
611
+ }
612
+
613
+ return html.toString()
614
+ }
615
+
616
+ private fun appendImageIfPresent(
617
+ html: StringBuilder,
618
+ text: Spannable,
619
+ pos: Int,
620
+ styles: CachedStyles,
621
+ ) {
622
+ val imageSpans = text.getSpans(pos, pos + 1, ImageSpan::class.java)
623
+ if (imageSpans.isEmpty()) return
624
+
625
+ val imgSpan = imageSpans[0]
626
+
627
+ if (imgSpan.isInline) {
628
+ html.append("<img src=\"")
629
+ escapeHTMLTo(html, imgSpan.imageUrl)
630
+ html
631
+ .append("\" style=\"height: ")
632
+ .append(styles.inlineImageHeight)
633
+ .append("; width: auto; vertical-align: ")
634
+ .append(styles.inlineImageVerticalAlign)
635
+ .append(";\">")
636
+ } else {
637
+ html
638
+ .append("</p><div style=\"margin-bottom: ")
639
+ .append(styles.imageMarginBottom)
640
+ .append("px;\"><img src=\"")
641
+ escapeHTMLTo(html, imgSpan.imageUrl)
642
+ html
643
+ .append("\" style=\"max-width: 100%; border-radius: ")
644
+ .append(styles.imageBorderRadius)
645
+ .append("px;\"></div><p>")
646
+ }
647
+ }
648
+
649
+ private fun appendStyledSegment(
650
+ html: StringBuilder,
651
+ text: Spannable,
652
+ start: Int,
653
+ end: Int,
654
+ content: String,
655
+ styles: CachedStyles,
656
+ isCodeBlock: Boolean,
657
+ ) {
658
+ val strongSpans = text.getSpans(start, end, StrongSpan::class.java)
659
+ val styleSpans = text.getSpans(start, end, StyleSpan::class.java)
660
+ val emphasisSpans = text.getSpans(start, end, EmphasisSpan::class.java)
661
+ val underlineSpans = text.getSpans(start, end, UnderlineSpan::class.java)
662
+ val strikethroughSpans = text.getSpans(start, end, StrikethroughSpan::class.java)
663
+ val linkSpans = text.getSpans(start, end, LinkSpan::class.java)
664
+ val codeSpans = text.getSpans(start, end, CodeSpan::class.java)
665
+
666
+ val isBold =
667
+ strongSpans.isNotEmpty() ||
668
+ styleSpans.any { it.style == Typeface.BOLD || it.style == Typeface.BOLD_ITALIC }
669
+ val isItalic =
670
+ emphasisSpans.isNotEmpty() ||
671
+ styleSpans.any { it.style == Typeface.ITALIC || it.style == Typeface.BOLD_ITALIC }
672
+ val isUnderline = underlineSpans.isNotEmpty()
673
+ val isStrikethrough = strikethroughSpans.isNotEmpty()
674
+ val link = linkSpans.firstOrNull()
675
+ val isCode = codeSpans.isNotEmpty() && !isCodeBlock
676
+
677
+ link?.let {
678
+ html.append("<a href=\"")
679
+ escapeHTMLTo(html, it.url)
680
+ html
681
+ .append("\" style=\"color: ")
682
+ .append(styles.linkColor)
683
+ .append("; text-decoration: ")
684
+ .append(if (styles.linkUnderline) "underline" else "none")
685
+ .append(";\">")
686
+ }
687
+
688
+ if (isCode) {
689
+ html
690
+ .append("<code style=\"background-color: ")
691
+ .append(styles.codeBgColor)
692
+ .append("; color: ")
693
+ .append(styles.codeColor)
694
+ .append("; padding: ")
695
+ .append(styles.codePadding)
696
+ .append("; border-radius: ")
697
+ .append(styles.codeBorderRadius)
698
+ .append("; font-size: ")
699
+ .append(styles.codeFontSize)
700
+ .append("; font-family: Menlo, Monaco, Consolas, monospace;\">")
701
+ }
702
+
703
+ if (isBold) {
704
+ if (styles.strongColor != null) {
705
+ html.append("<strong style=\"color: ").append(styles.strongColor).append(";\">")
706
+ } else {
707
+ html.append("<strong>")
708
+ }
709
+ }
710
+
711
+ if (isItalic) {
712
+ if (styles.emphasisColor != null) {
713
+ html.append("<em style=\"color: ").append(styles.emphasisColor).append(";\">")
714
+ } else {
715
+ html.append("<em>")
716
+ }
717
+ }
718
+
719
+ if (isStrikethrough) {
720
+ if (styles.strikethroughColor != null) {
721
+ html.append("<s style=\"text-decoration-color: ").append(styles.strikethroughColor).append(";\">")
722
+ } else {
723
+ html.append("<s>")
724
+ }
725
+ }
726
+ if (isUnderline && link == null) {
727
+ if (styles.underlineColor != null) {
728
+ html.append("<u style=\"text-decoration-color: ").append(styles.underlineColor).append(";\">")
729
+ } else {
730
+ html.append("<u>")
731
+ }
732
+ }
733
+
734
+ escapeHTMLTo(html, content.trimEnd('\n'))
735
+
736
+ if (isUnderline && link == null) html.append("</u>")
737
+ if (isStrikethrough) html.append("</s>")
738
+ if (isItalic) html.append("</em>")
739
+ if (isBold) html.append("</strong>")
740
+ if (isCode) html.append("</code>")
741
+ if (link != null) html.append("</a>")
742
+ }
743
+
744
+ private fun collectParagraphs(text: Spannable): ArrayList<ParagraphInfo> {
745
+ val string = text.toString()
746
+ val paragraphs = ArrayList<ParagraphInfo>(string.length / 40 + 1) // Estimate
747
+ var currentIndex = 0
748
+
749
+ while (currentIndex < string.length) {
750
+ var lineEnd = string.indexOf('\n', currentIndex)
751
+ if (lineEnd == -1) lineEnd = string.length else lineEnd++
752
+
753
+ val type = getParagraphType(text, currentIndex)
754
+ val depth = getDepthForType(text, currentIndex, type)
755
+
756
+ paragraphs.add(ParagraphInfo(currentIndex, lineEnd, type, depth))
757
+ currentIndex = lineEnd
758
+ }
759
+
760
+ return paragraphs
761
+ }
762
+
763
+ private fun getParagraphType(
764
+ text: Spannable,
765
+ start: Int,
766
+ ): Int {
767
+ val end = minOf(start + 1, text.length)
768
+
769
+ if (text.getSpans(start, end, CodeBlockSpan::class.java).isNotEmpty()) return TYPE_CODE_BLOCK
770
+
771
+ val headingSpans = text.getSpans(start, end, HeadingSpan::class.java)
772
+ if (headingSpans.isNotEmpty()) return headingSpans[0].level.coerceIn(1, 6)
773
+
774
+ if (text.getSpans(start, end, BlockquoteSpan::class.java).isNotEmpty()) return TYPE_BLOCKQUOTE
775
+ if (text.getSpans(start, end, OrderedListSpan::class.java).isNotEmpty()) return TYPE_ORDERED_LIST
776
+ if (text.getSpans(start, end, UnorderedListSpan::class.java).isNotEmpty()) return TYPE_UNORDERED_LIST
777
+
778
+ return TYPE_NORMAL
779
+ }
780
+
781
+ private fun getDepthForType(
782
+ text: Spannable,
783
+ start: Int,
784
+ type: Int,
785
+ ): Int {
786
+ val end = start + 1
787
+ return when (type) {
788
+ TYPE_BLOCKQUOTE -> text.getSpans(start, end, BlockquoteSpan::class.java).maxOfOrNull { it.depth } ?: 0
789
+ TYPE_ORDERED_LIST -> text.getSpans(start, end, OrderedListSpan::class.java).maxOfOrNull { it.depth } ?: 0
790
+ TYPE_UNORDERED_LIST -> text.getSpans(start, end, UnorderedListSpan::class.java).maxOfOrNull { it.depth } ?: 0
791
+ else -> 0
792
+ }
793
+ }
794
+
795
+ /** Fast HTML escape using single-pass character scanning. */
796
+ private fun escapeHTMLTo(
797
+ output: StringBuilder,
798
+ text: String,
799
+ ) {
800
+ val len = text.length
801
+
802
+ // Fast path: skip if no escaping needed
803
+ var needsEscape = false
804
+ for (i in 0 until len) {
805
+ val c = text[i]
806
+ if (c == '&' || c == '<' || c == '>' || c == '"' || c == '\'') {
807
+ needsEscape = true
808
+ break
809
+ }
810
+ }
811
+ if (!needsEscape) {
812
+ output.append(text)
813
+ return
814
+ }
815
+
816
+ for (i in 0 until len) {
817
+ when (val c = text[i]) {
818
+ '&' -> output.append("&amp;")
819
+ '<' -> output.append("&lt;")
820
+ '>' -> output.append("&gt;")
821
+ '"' -> output.append("&quot;")
822
+ '\'' -> output.append("&#39;")
823
+ else -> output.append(c)
824
+ }
825
+ }
826
+ }
827
+ }