@mathers101/react-native-render-rich-text 0.1.10 → 0.1.14

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 (327) hide show
  1. package/LICENSE +20 -20
  2. package/README.md +140 -140
  3. package/ReactNativeRenderRichText.podspec +23 -23
  4. package/android/build.gradle +86 -86
  5. package/android/consumer-rules.pro +3 -3
  6. package/android/src/main/AndroidManifest.xml +2 -2
  7. package/android/src/main/java/com/renderrichtext/EnrichedTiptapTextLayoutManager.kt +24 -24
  8. package/android/src/main/java/com/renderrichtext/EnrichedTiptapTextPackage.kt +13 -13
  9. package/android/src/main/java/com/renderrichtext/MeasurementStore.kt +644 -644
  10. package/android/src/main/java/com/renderrichtext/accessibility/AccessibilityLabels.kt +17 -17
  11. package/android/src/main/java/com/renderrichtext/accessibility/AccessibleTiptapTextView.kt +112 -112
  12. package/android/src/main/java/com/renderrichtext/accessibility/TiptapAccessibilityHelper.kt +576 -576
  13. package/android/src/main/java/com/renderrichtext/events/CodeBlockPressEvent.kt +24 -24
  14. package/android/src/main/java/com/renderrichtext/events/ContextMenuItemPressEvent.kt +28 -28
  15. package/android/src/main/java/com/renderrichtext/events/CopyPressEvent.kt +24 -24
  16. package/android/src/main/java/com/renderrichtext/events/ImagePressEvent.kt +25 -25
  17. package/android/src/main/java/com/renderrichtext/events/LatexErrorEvent.kt +29 -29
  18. package/android/src/main/java/com/renderrichtext/events/LinkLongPressEvent.kt +23 -23
  19. package/android/src/main/java/com/renderrichtext/events/LinkPressEvent.kt +23 -23
  20. package/android/src/main/java/com/renderrichtext/events/MentionLongPressEvent.kt +25 -25
  21. package/android/src/main/java/com/renderrichtext/events/MentionPressEvent.kt +25 -25
  22. package/android/src/main/java/com/renderrichtext/events/TaskListItemPressEvent.kt +26 -26
  23. package/android/src/main/java/com/renderrichtext/math/LatexErrorReporter.kt +9 -9
  24. package/android/src/main/java/com/renderrichtext/parser/Parser.kt +59 -59
  25. package/android/src/main/java/com/renderrichtext/parser/TextLinkMatching.kt +35 -35
  26. package/android/src/main/java/com/renderrichtext/renderer/BaselineShiftRenderer.kt +45 -45
  27. package/android/src/main/java/com/renderrichtext/renderer/BlankLineRenderer.kt +39 -39
  28. package/android/src/main/java/com/renderrichtext/renderer/BlockquoteRenderer.kt +243 -243
  29. package/android/src/main/java/com/renderrichtext/renderer/BlockquoteTextRenderer.kt +56 -56
  30. package/android/src/main/java/com/renderrichtext/renderer/CodeBlockRenderer.kt +135 -135
  31. package/android/src/main/java/com/renderrichtext/renderer/CodeRenderer.kt +37 -37
  32. package/android/src/main/java/com/renderrichtext/renderer/EmphasisRenderer.kt +28 -28
  33. package/android/src/main/java/com/renderrichtext/renderer/HeadingRenderer.kt +81 -81
  34. package/android/src/main/java/com/renderrichtext/renderer/HighlightRenderer.kt +26 -26
  35. package/android/src/main/java/com/renderrichtext/renderer/LineBreakRenderer.kt +17 -17
  36. package/android/src/main/java/com/renderrichtext/renderer/LinkRenderer.kt +90 -90
  37. package/android/src/main/java/com/renderrichtext/renderer/ListContextManager.kt +109 -109
  38. package/android/src/main/java/com/renderrichtext/renderer/ListItemRenderer.kt +211 -211
  39. package/android/src/main/java/com/renderrichtext/renderer/ListRenderer.kt +133 -133
  40. package/android/src/main/java/com/renderrichtext/renderer/MentionRenderer.kt +85 -85
  41. package/android/src/main/java/com/renderrichtext/renderer/ParagraphRenderer.kt +91 -91
  42. package/android/src/main/java/com/renderrichtext/renderer/Renderer.kt +148 -148
  43. package/android/src/main/java/com/renderrichtext/renderer/SoftBreakRenderer.kt +17 -17
  44. package/android/src/main/java/com/renderrichtext/renderer/SpanStyleCache.kt +143 -143
  45. package/android/src/main/java/com/renderrichtext/renderer/StrikethroughRenderer.kt +28 -28
  46. package/android/src/main/java/com/renderrichtext/renderer/StrongRenderer.kt +28 -28
  47. package/android/src/main/java/com/renderrichtext/renderer/TextRenderer.kt +31 -31
  48. package/android/src/main/java/com/renderrichtext/renderer/ThematicBreakRenderer.kt +46 -46
  49. package/android/src/main/java/com/renderrichtext/renderer/UnderlineRenderer.kt +28 -28
  50. package/android/src/main/java/com/renderrichtext/spans/BaseListSpan.kt +164 -164
  51. package/android/src/main/java/com/renderrichtext/spans/BaselineShiftSpan.kt +27 -27
  52. package/android/src/main/java/com/renderrichtext/spans/BlockquoteSpan.kt +302 -302
  53. package/android/src/main/java/com/renderrichtext/spans/CodeBackgroundSpan.kt +205 -205
  54. package/android/src/main/java/com/renderrichtext/spans/CodeBlockSpan.kt +216 -216
  55. package/android/src/main/java/com/renderrichtext/spans/CodeSpan.kt +32 -32
  56. package/android/src/main/java/com/renderrichtext/spans/EmphasisSpan.kt +42 -42
  57. package/android/src/main/java/com/renderrichtext/spans/FadeInSpan.kt +29 -29
  58. package/android/src/main/java/com/renderrichtext/spans/HeadingSpan.kt +38 -38
  59. package/android/src/main/java/com/renderrichtext/spans/HighlightSpan.kt +80 -80
  60. package/android/src/main/java/com/renderrichtext/spans/ImageSpan.kt +481 -481
  61. package/android/src/main/java/com/renderrichtext/spans/InlineBackgroundGeometry.kt +42 -42
  62. package/android/src/main/java/com/renderrichtext/spans/LineHeightSpan.kt +39 -39
  63. package/android/src/main/java/com/renderrichtext/spans/LinkPillIconCache.kt +154 -154
  64. package/android/src/main/java/com/renderrichtext/spans/LinkPillSpan.kt +381 -381
  65. package/android/src/main/java/com/renderrichtext/spans/LinkSpan.kt +68 -68
  66. package/android/src/main/java/com/renderrichtext/spans/ListItemSpacingSpan.kt +33 -33
  67. package/android/src/main/java/com/renderrichtext/spans/ListMarkerAnchorSpan.kt +38 -38
  68. package/android/src/main/java/com/renderrichtext/spans/MarginBottomSpan.kt +76 -76
  69. package/android/src/main/java/com/renderrichtext/spans/MentionChipSpan.kt +132 -132
  70. package/android/src/main/java/com/renderrichtext/spans/MentionSpan.kt +46 -46
  71. package/android/src/main/java/com/renderrichtext/spans/OrderedListSpan.kt +127 -127
  72. package/android/src/main/java/com/renderrichtext/spans/SpoilerSpan.kt +33 -33
  73. package/android/src/main/java/com/renderrichtext/spans/StrikethroughSpan.kt +12 -12
  74. package/android/src/main/java/com/renderrichtext/spans/StrongSpan.kt +39 -39
  75. package/android/src/main/java/com/renderrichtext/spans/TaskListSpan.kt +143 -143
  76. package/android/src/main/java/com/renderrichtext/spans/TextSpan.kt +26 -26
  77. package/android/src/main/java/com/renderrichtext/spans/ThematicBreakSpan.kt +69 -69
  78. package/android/src/main/java/com/renderrichtext/spans/UnorderedListSpan.kt +96 -96
  79. package/android/src/main/java/com/renderrichtext/spoiler/SpoilerStubs.kt +46 -46
  80. package/android/src/main/java/com/renderrichtext/styles/BaseBlockStyle.kt +11 -11
  81. package/android/src/main/java/com/renderrichtext/styles/BlockquoteStyle.kt +83 -83
  82. package/android/src/main/java/com/renderrichtext/styles/CodeBlockStyle.kt +80 -80
  83. package/android/src/main/java/com/renderrichtext/styles/CodeStyle.kt +26 -26
  84. package/android/src/main/java/com/renderrichtext/styles/EmphasisStyle.kt +21 -21
  85. package/android/src/main/java/com/renderrichtext/styles/FactoidStyle.kt +21 -21
  86. package/android/src/main/java/com/renderrichtext/styles/HeadingStyle.kt +33 -33
  87. package/android/src/main/java/com/renderrichtext/styles/HighlightStyle.kt +19 -19
  88. package/android/src/main/java/com/renderrichtext/styles/ImageStyle.kt +29 -29
  89. package/android/src/main/java/com/renderrichtext/styles/InlineImageStyle.kt +17 -17
  90. package/android/src/main/java/com/renderrichtext/styles/InlineMathStyle.kt +17 -17
  91. package/android/src/main/java/com/renderrichtext/styles/LinkPillContent.kt +36 -36
  92. package/android/src/main/java/com/renderrichtext/styles/LinkPillStyle.kt +44 -44
  93. package/android/src/main/java/com/renderrichtext/styles/LinkStyle.kt +23 -23
  94. package/android/src/main/java/com/renderrichtext/styles/LinkVariantEntry.kt +34 -34
  95. package/android/src/main/java/com/renderrichtext/styles/ListStyle.kt +65 -65
  96. package/android/src/main/java/com/renderrichtext/styles/MathStyle.kt +29 -29
  97. package/android/src/main/java/com/renderrichtext/styles/MentionStyle.kt +19 -19
  98. package/android/src/main/java/com/renderrichtext/styles/ParagraphStyle.kt +33 -33
  99. package/android/src/main/java/com/renderrichtext/styles/SpoilerStyle.kt +27 -27
  100. package/android/src/main/java/com/renderrichtext/styles/StrikethroughStyle.kt +17 -17
  101. package/android/src/main/java/com/renderrichtext/styles/StrongStyle.kt +21 -21
  102. package/android/src/main/java/com/renderrichtext/styles/StyleConfig.kt +411 -411
  103. package/android/src/main/java/com/renderrichtext/styles/StyleParser.kt +92 -92
  104. package/android/src/main/java/com/renderrichtext/styles/SubscriptStyle.kt +16 -16
  105. package/android/src/main/java/com/renderrichtext/styles/SuperscriptStyle.kt +16 -16
  106. package/android/src/main/java/com/renderrichtext/styles/TableStyle.kt +75 -75
  107. package/android/src/main/java/com/renderrichtext/styles/TaskListStyle.kt +29 -29
  108. package/android/src/main/java/com/renderrichtext/styles/TextAlignment.kt +32 -32
  109. package/android/src/main/java/com/renderrichtext/styles/ThematicBreakStyle.kt +23 -23
  110. package/android/src/main/java/com/renderrichtext/styles/UnderlineStyle.kt +17 -17
  111. package/android/src/main/java/com/renderrichtext/styles/VideoStyle.kt +31 -31
  112. package/android/src/main/java/com/renderrichtext/utils/common/BorderStyleUtils.kt +86 -86
  113. package/android/src/main/java/com/renderrichtext/utils/common/BreakStrategyUtils.kt +32 -32
  114. package/android/src/main/java/com/renderrichtext/utils/common/CodeBlockHighlighter.kt +36 -36
  115. package/android/src/main/java/com/renderrichtext/utils/common/CodeBlockNode.kt +42 -42
  116. package/android/src/main/java/com/renderrichtext/utils/common/EllipsizeUtils.kt +27 -27
  117. package/android/src/main/java/com/renderrichtext/utils/common/EnrichedTiptapAncestorUtils.kt +10 -10
  118. package/android/src/main/java/com/renderrichtext/utils/common/FeatureFlags.kt +9 -9
  119. package/android/src/main/java/com/renderrichtext/utils/common/LinkRecognitionProps.kt +31 -31
  120. package/android/src/main/java/com/renderrichtext/utils/common/LinkRegexConfig.kt +56 -56
  121. package/android/src/main/java/com/renderrichtext/utils/common/MotionPreferences.kt +18 -18
  122. package/android/src/main/java/com/renderrichtext/utils/common/ReadableMapExtensions.kt +28 -28
  123. package/android/src/main/java/com/renderrichtext/utils/common/layout/RTLUtils.kt +6 -6
  124. package/android/src/main/java/com/renderrichtext/utils/common/serialization/TiptapASTSerializer.kt +368 -368
  125. package/android/src/main/java/com/renderrichtext/utils/text/ImageCache.kt +88 -88
  126. package/android/src/main/java/com/renderrichtext/utils/text/ImageDownloader.kt +203 -203
  127. package/android/src/main/java/com/renderrichtext/utils/text/LocalImageLoader.kt +167 -167
  128. package/android/src/main/java/com/renderrichtext/utils/text/TailFadeInAnimator.kt +84 -84
  129. package/android/src/main/java/com/renderrichtext/utils/text/conversion/HTMLGenerator.kt +1105 -1105
  130. package/android/src/main/java/com/renderrichtext/utils/text/conversion/TiptapExtractor.kt +423 -423
  131. package/android/src/main/java/com/renderrichtext/utils/text/extensions/ParagraphUtils.kt +9 -9
  132. package/android/src/main/java/com/renderrichtext/utils/text/extensions/SpannableExtensions.kt +9 -9
  133. package/android/src/main/java/com/renderrichtext/utils/text/extensions/TextPaintExtensions.kt +76 -76
  134. package/android/src/main/java/com/renderrichtext/utils/text/interaction/CheckboxTouchHelper.kt +61 -61
  135. package/android/src/main/java/com/renderrichtext/utils/text/interaction/TaskListTapUtils.kt +286 -286
  136. package/android/src/main/java/com/renderrichtext/utils/text/span/ApplyLineHeight.kt +39 -39
  137. package/android/src/main/java/com/renderrichtext/utils/text/span/BlockSpacing.kt +142 -142
  138. package/android/src/main/java/com/renderrichtext/utils/text/span/SpanFlags.kt +43 -43
  139. package/android/src/main/java/com/renderrichtext/utils/text/span/WidthAwareSpans.kt +30 -30
  140. package/android/src/main/java/com/renderrichtext/utils/text/view/CodeBlockPressHost.kt +15 -15
  141. package/android/src/main/java/com/renderrichtext/utils/text/view/ImagePressHost.kt +19 -19
  142. package/android/src/main/java/com/renderrichtext/utils/text/view/SelectionActionMode.kt +235 -235
  143. package/android/src/main/java/com/renderrichtext/utils/text/view/TextSelectionColors.kt +48 -48
  144. package/android/src/main/java/com/renderrichtext/utils/text/view/TextViewSetup.kt +119 -119
  145. package/android/src/main/java/com/renderrichtext/views/ContextMenuPopup.kt +217 -217
  146. package/android/src/main/java/com/renderrichtext/views/CopyGlyph.kt +101 -101
  147. package/android/src/main/java/com/renderrichtext/views/WrapGlyph.kt +40 -40
  148. package/android/src/main/jni/CMakeLists.txt +52 -52
  149. package/android/src/main/jni/react/renderer/components/RichTextSpec/ComponentDescriptors.h +30 -30
  150. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextComponentDescriptor.h +29 -29
  151. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextMeasurementManager.cpp +45 -45
  152. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextMeasurementManager.h +21 -21
  153. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextShadowNode.cpp +38 -38
  154. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextShadowNode.h +53 -53
  155. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextState.cpp +9 -9
  156. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextState.h +24 -24
  157. package/android/src/main/jni/react/renderer/components/RichTextSpec/conversions.h +40 -40
  158. package/cpp/parser/TiptapParser.hpp +13 -13
  159. package/cpp/vendor/nlohmann/json.hpp +24765 -24765
  160. package/ios/EnrichedTiptapText.h +27 -27
  161. package/ios/attachments/ENRMImageAttachment.h +51 -51
  162. package/ios/attachments/ENRMImageAttachment.m +398 -398
  163. package/ios/attachments/ENRMLinkPillAttachment.h +47 -47
  164. package/ios/attachments/ENRMLinkPillAttachment.m +324 -324
  165. package/ios/attachments/ENRMLinkPillIconCache.h +28 -28
  166. package/ios/attachments/ENRMLinkPillIconCache.m +181 -181
  167. package/ios/attachments/ENRMLinkPillText.h +27 -27
  168. package/ios/attachments/ENRMLinkPillText.m +118 -118
  169. package/ios/attachments/ENRMMathInlineAttachment.h +7 -7
  170. package/ios/attachments/ENRMMathInlineAttachment.m +4 -4
  171. package/ios/attachments/ThematicBreakAttachment.h +15 -15
  172. package/ios/attachments/ThematicBreakAttachment.m +33 -33
  173. package/ios/internals/ENRMViewFreeMeasurement.h +208 -208
  174. package/ios/internals/EnrichedTiptapTextComponentDescriptor.h +19 -19
  175. package/ios/internals/EnrichedTiptapTextShadowNode.h +43 -43
  176. package/ios/internals/EnrichedTiptapTextShadowNode.mm +69 -69
  177. package/ios/internals/EnrichedTiptapTextState.h +24 -24
  178. package/ios/internals/MeasurementCache.h +308 -308
  179. package/ios/internals/ShadowMeasurementUtils.h +145 -145
  180. package/ios/parser/ENRMTextLinkAttributes.h +7 -7
  181. package/ios/parser/ENRMTiptapParser.h +33 -33
  182. package/ios/parser/ENRMTiptapParser.mm +73 -73
  183. package/ios/parser/TiptapASTNode.m +52 -52
  184. package/ios/renderer/AttributedRenderer.h +23 -23
  185. package/ios/renderer/AttributedRenderer.m +229 -229
  186. package/ios/renderer/BaseRenderer.h +33 -33
  187. package/ios/renderer/BaseRenderer.m +46 -46
  188. package/ios/renderer/BaselineShiftTextAttributes.h +10 -10
  189. package/ios/renderer/BaselineShiftTextAttributes.m +26 -26
  190. package/ios/renderer/BlankLineRenderer.h +4 -4
  191. package/ios/renderer/BlankLineRenderer.m +51 -51
  192. package/ios/renderer/BlockquoteRenderer.h +6 -6
  193. package/ios/renderer/BlockquoteRenderer.m +258 -258
  194. package/ios/renderer/CodeBlockRenderer.h +9 -9
  195. package/ios/renderer/CodeBlockRenderer.m +99 -99
  196. package/ios/renderer/CodeRenderer.h +9 -9
  197. package/ios/renderer/CodeRenderer.m +69 -69
  198. package/ios/renderer/ENRMBlockquoteTextRenderer.h +25 -25
  199. package/ios/renderer/ENRMBlockquoteTextRenderer.m +48 -48
  200. package/ios/renderer/EmphasisRenderer.h +5 -5
  201. package/ios/renderer/EmphasisRenderer.m +99 -99
  202. package/ios/renderer/HeadingRenderer.h +6 -6
  203. package/ios/renderer/HeadingRenderer.m +102 -102
  204. package/ios/renderer/HighlightRenderer.h +7 -7
  205. package/ios/renderer/HighlightRenderer.m +33 -33
  206. package/ios/renderer/LinkRenderer.h +6 -6
  207. package/ios/renderer/LinkRenderer.m +157 -157
  208. package/ios/renderer/ListItemRenderer.h +24 -24
  209. package/ios/renderer/ListItemRenderer.m +283 -283
  210. package/ios/renderer/ListRenderer.h +13 -13
  211. package/ios/renderer/ListRenderer.m +126 -126
  212. package/ios/renderer/MentionRenderer.h +9 -9
  213. package/ios/renderer/MentionRenderer.m +96 -96
  214. package/ios/renderer/NodeRenderer.h +8 -8
  215. package/ios/renderer/ParagraphRenderer.h +6 -6
  216. package/ios/renderer/ParagraphRenderer.m +85 -85
  217. package/ios/renderer/RenderContext.h +145 -145
  218. package/ios/renderer/RenderContext.m +402 -402
  219. package/ios/renderer/RendererFactory.h +12 -12
  220. package/ios/renderer/StrikethroughRenderer.h +5 -5
  221. package/ios/renderer/StrikethroughRenderer.m +30 -30
  222. package/ios/renderer/StrongRenderer.h +5 -5
  223. package/ios/renderer/StrongRenderer.m +87 -87
  224. package/ios/renderer/TextRenderer.h +6 -6
  225. package/ios/renderer/TextRenderer.m +18 -18
  226. package/ios/renderer/ThematicBreakRenderer.h +4 -4
  227. package/ios/renderer/ThematicBreakRenderer.m +41 -41
  228. package/ios/renderer/UnderlineRenderer.h +4 -4
  229. package/ios/renderer/UnderlineRenderer.m +41 -41
  230. package/ios/styles/ENRMFontSlot.h +12 -12
  231. package/ios/styles/ENRMFontSlot.m +24 -24
  232. package/ios/styles/StyleConfig.h +488 -488
  233. package/ios/styles/StyleConfig.mm +2820 -2820
  234. package/ios/utils/AccessibilityInfo.h +35 -35
  235. package/ios/utils/AccessibilityInfo.m +24 -24
  236. package/ios/utils/BlockquoteBorder.h +26 -26
  237. package/ios/utils/BlockquoteBorder.m +294 -294
  238. package/ios/utils/CodeBackground.h +19 -19
  239. package/ios/utils/CodeBackground.m +196 -196
  240. package/ios/utils/CodeBlockBackground.h +17 -17
  241. package/ios/utils/CodeBlockBackground.m +102 -102
  242. package/ios/utils/ContextMenuUtils.h +76 -76
  243. package/ios/utils/ContextMenuUtils.mm +75 -75
  244. package/ios/utils/ENRMAccessibilityLabels.h +40 -40
  245. package/ios/utils/ENRMAccessibilityLabels.m +5 -5
  246. package/ios/utils/ENRMAsyncRenderCoordinator.h +24 -24
  247. package/ios/utils/ENRMAsyncRenderCoordinator.m +37 -37
  248. package/ios/utils/ENRMAtomicSize.h +65 -65
  249. package/ios/utils/ENRMCodeBlockContent.h +37 -37
  250. package/ios/utils/ENRMCodeBlockContent.mm +89 -89
  251. package/ios/utils/ENRMFeatureFlags.h +25 -25
  252. package/ios/utils/ENRMImageDownloader.h +30 -30
  253. package/ios/utils/ENRMImageDownloader.m +168 -168
  254. package/ios/utils/ENRMLatexErrorCoordinator.h +27 -27
  255. package/ios/utils/ENRMLatexErrorCoordinator.mm +75 -75
  256. package/ios/utils/ENRMLatexErrorReporting.h +13 -13
  257. package/ios/utils/ENRMLinkContextMenus.h +93 -93
  258. package/ios/utils/ENRMLinkContextMenus.m +178 -178
  259. package/ios/utils/ENRMLinkRegexConfig.h +30 -30
  260. package/ios/utils/ENRMLinkRegexConfig.mm +104 -104
  261. package/ios/utils/ENRMLinkRegexProps.h +23 -23
  262. package/ios/utils/ENRMLocalImageLoader.h +35 -35
  263. package/ios/utils/ENRMLocalImageLoader.m +111 -111
  264. package/ios/utils/ENRMMenuAction.h +27 -27
  265. package/ios/utils/ENRMMenuAction.m +33 -33
  266. package/ios/utils/ENRMSpoilerOverlayManager.h +15 -15
  267. package/ios/utils/ENRMSpoilerOverlayManager.m +19 -19
  268. package/ios/utils/ENRMSpoilerTapUtils.h +6 -6
  269. package/ios/utils/ENRMSpoilerTapUtils.m +11 -11
  270. package/ios/utils/ENRMTailFadeInAnimator.h +16 -16
  271. package/ios/utils/ENRMTailFadeInAnimator.m +161 -161
  272. package/ios/utils/ENRMTextHitTest.h +23 -23
  273. package/ios/utils/ENRMTextHitTest.m +20 -20
  274. package/ios/utils/ENRMTextInteractionUtils.h +20 -20
  275. package/ios/utils/ENRMTextInteractionUtils.m +15 -15
  276. package/ios/utils/ENRMTextRenderer.h +38 -38
  277. package/ios/utils/ENRMTextRenderer.m +54 -54
  278. package/ios/utils/ENRMTextViewSetup.h +132 -132
  279. package/ios/utils/ENRMUIKit.h +429 -429
  280. package/ios/utils/EditMenuUtils.h +105 -105
  281. package/ios/utils/EditMenuUtils.m +143 -143
  282. package/ios/utils/FontScaleObserver.h +10 -10
  283. package/ios/utils/FontScaleObserver.m +55 -55
  284. package/ios/utils/FontUtils.h +31 -31
  285. package/ios/utils/FontUtils.m +63 -63
  286. package/ios/utils/HTMLGenerator.h +25 -25
  287. package/ios/utils/HTMLGenerator.m +1041 -1041
  288. package/ios/utils/HeightUpdateUtils.h +25 -25
  289. package/ios/utils/ImageRequestHeaderUtils.h +35 -35
  290. package/ios/utils/LastElementUtils.h +97 -97
  291. package/ios/utils/LinkContextMenuUtils.h +55 -55
  292. package/ios/utils/LinkPillContentUtils.h +46 -46
  293. package/ios/utils/ListMarkerDrawer.h +15 -15
  294. package/ios/utils/ListMarkerDrawer.m +239 -239
  295. package/ios/utils/MentionBackground.h +26 -26
  296. package/ios/utils/MentionBackground.m +86 -86
  297. package/ios/utils/ParagraphStyleUtils.h +87 -87
  298. package/ios/utils/ParagraphStyleUtils.m +484 -484
  299. package/ios/utils/PasteboardUtils.h +48 -48
  300. package/ios/utils/PasteboardUtils.m +166 -166
  301. package/ios/utils/RTFExportUtils.h +24 -24
  302. package/ios/utils/RTFExportUtils.m +299 -299
  303. package/ios/utils/SelectionColorUtils.h +14 -14
  304. package/ios/utils/StylePropsUtils.h +1340 -1340
  305. package/ios/utils/TaskListTapUtils.h +6 -6
  306. package/ios/utils/TaskListTapUtils.m +14 -14
  307. package/ios/utils/TextViewLayoutManager.h +14 -14
  308. package/ios/utils/TiptapAccessibilityElementBuilder.h +43 -43
  309. package/ios/utils/TiptapAccessibilityElementBuilder.m +432 -432
  310. package/ios/utils/TiptapExtractor.h +17 -17
  311. package/ios/utils/TiptapExtractor.m +367 -367
  312. package/ios/views/ENRMTiptapTextView.h +25 -25
  313. package/ios/views/ENRMTiptapTextView.m +247 -247
  314. package/package.json +65 -70
  315. package/react-native.config.js +10 -10
  316. package/src/admonitionDefaults.ts +58 -58
  317. package/src/darkModeTextStyle.ts +99 -99
  318. package/src/headingDefaults.ts +21 -21
  319. package/src/index.tsx +4 -4
  320. package/src/linkVariantUtils.ts +120 -120
  321. package/src/normalizeTiptapStyle.ts +397 -397
  322. package/src/rn-globals.d.ts +1 -1
  323. package/src/styleUtils.ts +107 -107
  324. package/src/types/TiptapStyle.ts +532 -532
  325. package/src/types/TiptapStyleInternal.ts +299 -299
  326. package/src/types/linkPill.ts +9 -9
  327. package/src/utils/isDev.ts +4 -4
@@ -1,1105 +1,1105 @@
1
- package com.renderrichtext.utils.text.conversion
2
-
3
- import android.graphics.Typeface
4
- import android.text.Layout
5
- import android.text.Spannable
6
- import android.text.style.StyleSpan
7
- import android.text.style.UnderlineSpan
8
- import com.renderrichtext.spans.BaseListSpan
9
- import com.renderrichtext.spans.BlockquoteSpan
10
- import com.renderrichtext.spans.CodeBlockSpan
11
- import com.renderrichtext.spans.CodeSpan
12
- import com.renderrichtext.spans.EmphasisSpan
13
- import com.renderrichtext.spans.FactoidPaddingSpan
14
- import com.renderrichtext.spans.HeadingSpan
15
- import com.renderrichtext.spans.HighlightSpan
16
- import com.renderrichtext.spans.ImageSpan
17
- import com.renderrichtext.spans.LinkSpan
18
- import com.renderrichtext.spans.OrderedListSpan
19
- import com.renderrichtext.spans.StrikethroughSpan
20
- import com.renderrichtext.spans.StrongSpan
21
- import com.renderrichtext.spans.TaskListSpan
22
- import com.renderrichtext.spans.UnorderedListSpan
23
- import com.renderrichtext.styles.StyleConfig
24
-
25
- /** Generates semantic HTML with inline styles from Spannable text. */
26
- object HTMLGenerator {
27
- private const val OBJECT_REPLACEMENT_CHAR = '\uFFFC'
28
-
29
- /** Pre-computed styles to avoid repeated StyleConfig method calls. */
30
- private class CachedStyles(
31
- style: StyleConfig,
32
- private val fontDensity: Float,
33
- private val dimDensity: Float,
34
- ) {
35
- // Convert device pixels back to CSS pixels
36
- private fun fontPx(px: Float) = (px / fontDensity).toInt()
37
-
38
- private fun dimPx(px: Float) = (px / dimDensity).toInt()
39
-
40
- // Paragraph
41
- val paragraphColor: String
42
- val paragraphFontSize: Int
43
- val paragraphMarginBottom: Int
44
-
45
- // Code block
46
- val codeBlockColor: String
47
- val codeBlockBgColor: String
48
- val codeBlockFontSize: Int
49
- val codeBlockPadding: Int
50
- val codeBlockBorderRadius: Int
51
- val codeBlockMarginBottom: Int
52
-
53
- // Inline code
54
- val codeColor: String
55
- val codeBgColor: String
56
-
57
- // Blockquote
58
- val blockquoteColor: String
59
- val blockquoteBgColor: String
60
- val blockquoteBorderColor: String
61
- val blockquoteBorderWidth: Int
62
- val blockquoteGapWidth: Int
63
- val blockquoteMarginBottom: Int
64
- val blockquoteFontSize: Int
65
-
66
- // List
67
- val listColor: String
68
- val listFontSize: Int
69
- val listMarginBottom: Int
70
- val listMarginLeft: Int
71
-
72
- // Task list checkbox
73
- val taskCheckedColor: String
74
- val taskBorderColor: String
75
- val taskCheckmarkColor: String
76
- val taskCheckboxSize: Int
77
- val taskCheckboxBorderRadius: Int
78
-
79
- // Link
80
- val linkFontFamily: String
81
- val linkColor: String
82
- val linkUnderline: Boolean
83
-
84
- // Strong/Emphasis/Strikethrough/Underline
85
- val strongColor: String?
86
- val emphasisColor: String?
87
- val strikethroughColor: String?
88
- val underlineColor: String?
89
- val highlightColor: String
90
- val highlightBackgroundColor: String
91
-
92
- // Image
93
- val imageMarginBottom: Int
94
- val imageBorderRadius: Int
95
-
96
- // Fixed HTML values (not from StyleConfig)
97
- val blockquotePaddingVertical = "8px"
98
- val blockquoteBorderRadiusCorners = "border-start-end-radius: 8px; border-end-end-radius: 8px"
99
- val blockquoteNestedMargin = "8px 0 0 0"
100
- val blockquoteParagraphMargin = "0 0 4px 0"
101
- val inlineImageHeight = "1.2em"
102
- val inlineImageVerticalAlign = "-0.2em"
103
- val codePadding = "2px 4px"
104
- val codeBorderRadius = "4px"
105
- val codeFontSize = "1em"
106
-
107
- // Headings (array for O(1) lookup)
108
- val headingFontSizes: IntArray
109
- val headingFontWeights: Array<String>
110
- val headingColors: Array<String>
111
- val headingMarginBottoms: IntArray
112
-
113
- init {
114
- // Paragraph
115
- val pStyle = style.paragraphStyle
116
- paragraphColor = colorToCSS(pStyle.color)
117
- paragraphFontSize = fontPx(pStyle.fontSize)
118
- paragraphMarginBottom = dimPx(pStyle.marginBottom)
119
-
120
- // Code block
121
- val cbStyle = style.codeBlockStyle
122
- codeBlockColor = colorToCSS(cbStyle.color)
123
- codeBlockBgColor = colorToCSS(cbStyle.backgroundColor)
124
- codeBlockFontSize = fontPx(cbStyle.fontSize)
125
- codeBlockPadding = dimPx(cbStyle.padding)
126
- codeBlockBorderRadius = dimPx(cbStyle.borderRadius)
127
- codeBlockMarginBottom = dimPx(cbStyle.marginBottom)
128
-
129
- // Inline code
130
- val cStyle = style.codeStyle
131
- codeColor = colorToCSS(cStyle.color)
132
- codeBgColor = colorToCSS(cStyle.backgroundColor)
133
-
134
- // Blockquote
135
- val bqStyle = style.blockquoteStyle
136
- blockquoteColor = colorToCSS(bqStyle.color)
137
- blockquoteBgColor = colorToCSS(bqStyle.backgroundColor ?: 0)
138
- blockquoteBorderColor = colorToCSS(bqStyle.borderColor)
139
- blockquoteBorderWidth = dimPx(bqStyle.borderWidth)
140
- blockquoteGapWidth = dimPx(bqStyle.gapWidth)
141
- blockquoteMarginBottom = dimPx(bqStyle.marginBottom)
142
- blockquoteFontSize = fontPx(bqStyle.fontSize)
143
-
144
- // List
145
- val lStyle = style.listStyle
146
- listColor = colorToCSS(lStyle.color)
147
- listFontSize = fontPx(lStyle.fontSize)
148
- listMarginBottom = dimPx(lStyle.marginBottom)
149
- listMarginLeft = dimPx(lStyle.marginLeft)
150
-
151
- // Task list checkbox
152
- val tlStyle = style.taskListStyle
153
- taskCheckedColor = colorToCSS(tlStyle.checkedColor)
154
- taskBorderColor = colorToCSS(tlStyle.borderColor)
155
- taskCheckmarkColor = colorToCSS(tlStyle.checkmarkColor)
156
- taskCheckboxSize = dimPx(tlStyle.checkboxSize)
157
- taskCheckboxBorderRadius = dimPx(tlStyle.checkboxBorderRadius)
158
-
159
- // Link
160
- linkFontFamily = style.linkStyle.fontFamily
161
- linkColor = colorToCSS(style.linkStyle.color)
162
- linkUnderline = style.linkStyle.underline
163
-
164
- // Strong/Emphasis/Strikethrough/Underline (nullable for inherit)
165
- val sc = style.strongStyle.color
166
- strongColor = if (sc != null && sc != 0) colorToCSS(sc) else null
167
- val ec = style.emphasisStyle.color
168
- emphasisColor = if (ec != null && ec != 0) colorToCSS(ec) else null
169
- val strikeColor = style.strikethroughStyle.color
170
- strikethroughColor = if (strikeColor != 0) colorToCSS(strikeColor) else null
171
- val underline = style.underlineStyle.color
172
- underlineColor = if (underline != 0) colorToCSS(underline) else null
173
- highlightColor = colorToCSS(style.highlightStyle.color)
174
- highlightBackgroundColor = colorToCSS(style.highlightStyle.backgroundColor)
175
-
176
- // Image
177
- val imgStyle = style.imageStyle
178
- imageMarginBottom = dimPx(imgStyle.marginBottom)
179
- imageBorderRadius = dimPx(imgStyle.borderRadius)
180
-
181
- // Headings (1-6, index 0-5)
182
- headingFontSizes = IntArray(6)
183
- headingFontWeights = Array(6) { "" }
184
- headingColors = Array(6) { "" }
185
- headingMarginBottoms = IntArray(6)
186
-
187
- for (level in 1..6) {
188
- val hStyle = style.headingStyles[level]!!
189
- val idx = level - 1
190
- headingFontSizes[idx] = fontPx(hStyle.fontSize)
191
- headingFontWeights[idx] = fontWeightToCSS(hStyle.fontWeight)
192
- headingColors[idx] = colorToCSS(hStyle.color)
193
- headingMarginBottoms[idx] = dimPx(hStyle.marginBottom)
194
- }
195
- }
196
-
197
- companion object {
198
- private fun colorToCSS(color: Int): String {
199
- if (color == 0) return "inherit"
200
- val alpha = (color shr 24) and 0xFF
201
- val red = (color shr 16) and 0xFF
202
- val green = (color shr 8) and 0xFF
203
- val blue = color and 0xFF
204
-
205
- return if (alpha < 255) {
206
- "rgba($red, $green, $blue, ${alpha / 255f})"
207
- } else {
208
- String.format("#%02X%02X%02X", red, green, blue)
209
- }
210
- }
211
-
212
- fun fontWeightToCSS(fontWeight: String): String =
213
- when {
214
- fontWeight.equals("bold", ignoreCase = true) -> "700"
215
- fontWeight.equals("semibold", ignoreCase = true) -> "600"
216
- fontWeight.equals("medium", ignoreCase = true) -> "500"
217
- fontWeight.isEmpty() || fontWeight.equals("normal", ignoreCase = true) -> "normal"
218
- else -> fontWeight
219
- }
220
- }
221
- }
222
-
223
- private class GeneratorState {
224
- var inCodeBlock = false
225
- var previousWasCodeBlock = false
226
- val codeBlockLines = ArrayList<String>(16) // Pre-sized
227
-
228
- var inBlockquote = false
229
- var blockquoteDepth = -1
230
- var previousWasBlockquote = false
231
-
232
- var listDepth = -1
233
- val openListTypes = ArrayList<Boolean>(4) // true = ol, false = ul
234
- }
235
-
236
- // Paragraph type constants (H1-H6 = 1-6 directly from HeadingSpan.level)
237
- private const val TYPE_NORMAL = 0
238
- private const val TYPE_H1 = 1
239
- private const val TYPE_H6 = 6
240
- private const val TYPE_CODE_BLOCK = 7
241
- private const val TYPE_BLOCKQUOTE = 8
242
- private const val TYPE_ORDERED_LIST = 9
243
- private const val TYPE_UNORDERED_LIST = 10
244
- private const val TYPE_TASK_LIST = 11
245
-
246
- private data class ParagraphInfo(
247
- val start: Int,
248
- val end: Int,
249
- val type: Int,
250
- val depth: Int = 0,
251
- val isTaskChecked: Boolean = false,
252
- )
253
-
254
- fun generateHTML(
255
- text: Spannable,
256
- style: StyleConfig,
257
- scaledDensity: Float = 1f,
258
- density: Float = 1f,
259
- isRTL: Boolean = false,
260
- ): String {
261
- if (text.isEmpty()) return "<html></html>"
262
-
263
- // Pre-cache all styles (single allocation)
264
- val styles = CachedStyles(style, scaledDensity, density)
265
- val state = GeneratorState()
266
-
267
- // Estimate capacity (average 2x text length)
268
- val html = StringBuilder(text.length * 2)
269
- if (isRTL) {
270
- html.append("<html dir=\"rtl\">")
271
- html.append("<div dir=\"rtl\" style=\"direction: rtl; text-align: right;\">")
272
- } else {
273
- html.append("<html>")
274
- }
275
-
276
- // Collect paragraphs
277
- val paragraphs = collectParagraphs(text)
278
-
279
- for (para in paragraphs) {
280
- processParagraph(html, text, para, styles, state)
281
- }
282
-
283
- closeRemainingContainers(html, state, styles)
284
- if (isRTL) {
285
- html.append("</div>")
286
- }
287
- html.append("</html>")
288
-
289
- return html.toString()
290
- }
291
-
292
- private fun processParagraph(
293
- html: StringBuilder,
294
- text: Spannable,
295
- para: ParagraphInfo,
296
- styles: CachedStyles,
297
- state: GeneratorState,
298
- ) {
299
- val paraText = text.subSequence(para.start, para.end).toString().trimEnd('\n')
300
-
301
- // Handle empty paragraphs
302
- if (paraText.isEmpty() && para.type == TYPE_NORMAL) {
303
- closeAllBlockquotes(html, state)
304
- state.previousWasBlockquote = false
305
- return
306
- }
307
-
308
- // Get content range (trim trailing newline)
309
- val contentEnd = if (para.end > para.start && text[para.end - 1] == '\n') para.end - 1 else para.end
310
- val isCodeBlock = para.type == TYPE_CODE_BLOCK
311
- val inlineContent = generateInlineHTML(text, para.start, contentEnd, styles, isCodeBlock)
312
-
313
- // Handle different paragraph types
314
- when (para.type) {
315
- TYPE_CODE_BLOCK -> handleCodeBlock(inlineContent, state)
316
- TYPE_BLOCKQUOTE -> handleBlockquote(html, inlineContent, para, styles, state)
317
- TYPE_ORDERED_LIST, TYPE_UNORDERED_LIST, TYPE_TASK_LIST -> handleList(html, inlineContent, para, styles, state)
318
- in TYPE_H1..TYPE_H6 -> handleHeading(html, inlineContent, para.type, styles, state)
319
- else -> handleNormalParagraph(html, inlineContent, styles, state)
320
- }
321
- }
322
-
323
- private fun handleCodeBlock(
324
- content: String,
325
- state: GeneratorState,
326
- ) {
327
- if (!state.inCodeBlock) {
328
- state.inCodeBlock = true
329
- state.codeBlockLines.clear()
330
- }
331
-
332
- state.codeBlockLines.add(content.trimStart())
333
- state.previousWasCodeBlock = true
334
- state.previousWasBlockquote = false
335
- }
336
-
337
- private fun outputCodeBlock(
338
- html: StringBuilder,
339
- lines: List<String>,
340
- styles: CachedStyles,
341
- ) {
342
- if (lines.isEmpty()) return
343
-
344
- html
345
- .append("<pre dir=\"ltr\" style=\"background-color: ")
346
- .append(styles.codeBlockBgColor)
347
- .append("; padding: ")
348
- .append(styles.codeBlockPadding)
349
- .append("px; border-radius: ")
350
- .append(styles.codeBlockBorderRadius)
351
- .append("px; margin: 0 0 ")
352
- .append(styles.codeBlockMarginBottom)
353
- .append("px 0; overflow-x: auto; text-align: left;\"><code style=\"font-family: Menlo, Monaco, Consolas, monospace; font-size: ")
354
- .append(styles.codeBlockFontSize)
355
- .append("px; color: ")
356
- .append(styles.codeBlockColor)
357
- .append(";\">")
358
-
359
- for (i in lines.indices) {
360
- if (i > 0) html.append('\n')
361
- html.append(lines[i])
362
- }
363
-
364
- html.append("</code></pre>")
365
- }
366
-
367
- private fun handleBlockquote(
368
- html: StringBuilder,
369
- content: String,
370
- para: ParagraphInfo,
371
- styles: CachedStyles,
372
- state: GeneratorState,
373
- ) {
374
- closeCodeBlockIfOpen(html, state, styles)
375
-
376
- val depth = para.depth
377
-
378
- // Reset if coming from non-blockquote content
379
- if (!state.previousWasBlockquote && state.inBlockquote) {
380
- closeAllBlockquotes(html, state)
381
- }
382
-
383
- while (state.blockquoteDepth > depth) {
384
- html.append("</blockquote>")
385
- state.blockquoteDepth--
386
- }
387
-
388
- while (state.blockquoteDepth < depth) {
389
- state.blockquoteDepth++
390
- state.inBlockquote = true
391
-
392
- if (state.blockquoteDepth == 0) {
393
- html
394
- .append("<blockquote style=\"background-color: ")
395
- .append(styles.blockquoteBgColor)
396
- .append("; border-inline-start: ")
397
- .append(styles.blockquoteBorderWidth)
398
- .append("px solid ")
399
- .append(styles.blockquoteBorderColor)
400
- .append("; padding: ")
401
- .append(styles.blockquotePaddingVertical)
402
- .append(" ")
403
- .append(styles.blockquoteGapWidth)
404
- .append("px; margin: 0 0 ")
405
- .append(styles.blockquoteMarginBottom)
406
- .append("px 0; ")
407
- .append(styles.blockquoteBorderRadiusCorners)
408
- .append(";\">")
409
- } else {
410
- html
411
- .append("<blockquote style=\"border-inline-start: ")
412
- .append(styles.blockquoteBorderWidth)
413
- .append("px solid ")
414
- .append(styles.blockquoteBorderColor)
415
- .append("; padding-inline-start: ")
416
- .append(styles.blockquoteGapWidth)
417
- .append("px; margin: ")
418
- .append(styles.blockquoteNestedMargin)
419
- .append(";\">")
420
- }
421
- }
422
-
423
- html
424
- .append("<p style=\"margin: ")
425
- .append(styles.blockquoteParagraphMargin)
426
- .append("; color: ")
427
- .append(styles.blockquoteColor)
428
- .append("; font-size: ")
429
- .append(styles.blockquoteFontSize)
430
- .append("px;\">")
431
- .append(content)
432
- .append("</p>")
433
-
434
- state.previousWasBlockquote = true
435
- state.previousWasCodeBlock = false
436
- }
437
-
438
- private fun handleList(
439
- html: StringBuilder,
440
- content: String,
441
- para: ParagraphInfo,
442
- styles: CachedStyles,
443
- state: GeneratorState,
444
- ) {
445
- closeCodeBlockIfOpen(html, state, styles)
446
- closeAllBlockquotes(html, state)
447
-
448
- val depth = para.depth
449
- val isOrdered = para.type == TYPE_ORDERED_LIST
450
- val isTask = para.type == TYPE_TASK_LIST
451
-
452
- // Close lists to shallower depth
453
- while (state.listDepth > depth) {
454
- html.append(if (state.openListTypes.lastOrNull() == true) "</ol>" else "</ul>")
455
- if (state.openListTypes.isNotEmpty()) state.openListTypes.removeAt(state.openListTypes.lastIndex)
456
- state.listDepth--
457
- }
458
-
459
- // Handle list type change at same depth (ul <-> ol)
460
- if (state.listDepth == depth && state.openListTypes.isNotEmpty()) {
461
- if (state.openListTypes.last() != isOrdered) {
462
- html.append(if (state.openListTypes.last()) "</ol>" else "</ul>")
463
- state.openListTypes.removeAt(state.openListTypes.lastIndex)
464
- state.listDepth--
465
- }
466
- }
467
-
468
- // Open lists to deeper depth
469
- while (state.listDepth < depth) {
470
- state.listDepth++
471
- val marginStyle =
472
- if (state.listDepth == 0) {
473
- "margin: 0 0 ${styles.paragraphMarginBottom}px 0; "
474
- } else {
475
- "margin: 0; "
476
- }
477
-
478
- if (isOrdered) {
479
- html
480
- .append("<ol style=\"")
481
- .append(marginStyle)
482
- .append("padding-inline-start: ")
483
- .append(styles.listMarginLeft)
484
- .append("px;\">")
485
- state.openListTypes.add(true)
486
- } else {
487
- html
488
- .append("<ul style=\"")
489
- .append(marginStyle)
490
- .append("padding-inline-start: ")
491
- .append(styles.listMarginLeft)
492
- .append("px; list-style-type: disc;\">")
493
- state.openListTypes.add(false)
494
- }
495
- }
496
-
497
- html
498
- .append("<li style=\"margin-bottom: ")
499
- .append(styles.listMarginBottom)
500
- .append("px; color: ")
501
- .append(styles.listColor)
502
- .append("; font-size: ")
503
- .append(styles.listFontSize)
504
- .append("px;")
505
-
506
- if (isTask) {
507
- html.append(" list-style-type: none;")
508
- }
509
-
510
- html.append("\">")
511
-
512
- if (isTask) {
513
- val size = styles.taskCheckboxSize
514
- val radius = styles.taskCheckboxBorderRadius
515
- if (para.isTaskChecked) {
516
- html
517
- .append("<span style=\"display: inline-block; width: ")
518
- .append(size)
519
- .append("px; height: ")
520
- .append(size)
521
- .append("px; border-radius: ")
522
- .append(radius)
523
- .append("px; background-color: ")
524
- .append(styles.taskCheckedColor)
525
- .append("; color: ")
526
- .append(styles.taskCheckmarkColor)
527
- .append("; font-size: ")
528
- .append(size - 2)
529
- .append("px; line-height: ")
530
- .append(size)
531
- .append("px; text-align: center; vertical-align: middle; margin-inline-end: 4px;\">&#10003;</span> ")
532
- } else {
533
- html
534
- .append("<span style=\"display: inline-block; width: ")
535
- .append(size)
536
- .append("px; height: ")
537
- .append(size)
538
- .append("px; border-radius: ")
539
- .append(radius)
540
- .append("px; border: 1.5px solid ")
541
- .append(styles.taskBorderColor)
542
- .append("; vertical-align: middle; margin-inline-end: 4px;\"></span> ")
543
- }
544
- }
545
-
546
- html
547
- .append(content)
548
- .append("</li>")
549
-
550
- state.previousWasBlockquote = false
551
- state.previousWasCodeBlock = false
552
- }
553
-
554
- private fun handleHeading(
555
- html: StringBuilder,
556
- content: String,
557
- type: Int,
558
- styles: CachedStyles,
559
- state: GeneratorState,
560
- ) {
561
- closeCodeBlockIfOpen(html, state, styles)
562
- closeAllBlockquotes(html, state)
563
- closeListsIfOpen(html, state)
564
-
565
- val level = type // TYPE_H1..TYPE_H6 = 1..6
566
- val idx = level - 1
567
-
568
- html
569
- .append("<h")
570
- .append(level)
571
- .append(" style=\"font-size: ")
572
- .append(styles.headingFontSizes[idx])
573
- .append("px; font-weight: ")
574
- .append(styles.headingFontWeights[idx])
575
- .append("; color: ")
576
- .append(styles.headingColors[idx])
577
- .append("; margin: 0 0 ")
578
- .append(styles.headingMarginBottoms[idx])
579
- .append("px 0;\">")
580
- .append(content)
581
- .append("</h")
582
- .append(level)
583
- .append('>')
584
-
585
- state.previousWasBlockquote = false
586
- state.previousWasCodeBlock = false
587
- }
588
-
589
- private fun handleNormalParagraph(
590
- html: StringBuilder,
591
- content: String,
592
- styles: CachedStyles,
593
- state: GeneratorState,
594
- ) {
595
- closeCodeBlockIfOpen(html, state, styles)
596
- closeAllBlockquotes(html, state)
597
- closeListsIfOpen(html, state)
598
-
599
- html
600
- .append("<p style=\"margin: 0 0 ")
601
- .append(styles.paragraphMarginBottom)
602
- .append("px 0; color: ")
603
- .append(styles.paragraphColor)
604
- .append("; font-size: ")
605
- .append(styles.paragraphFontSize)
606
- .append("px;\">")
607
- .append(content)
608
- .append("</p>")
609
-
610
- state.previousWasBlockquote = false
611
- state.previousWasCodeBlock = false
612
- }
613
-
614
- private fun closeCodeBlockIfOpen(
615
- html: StringBuilder,
616
- state: GeneratorState,
617
- styles: CachedStyles,
618
- ) {
619
- if (state.inCodeBlock) {
620
- state.inCodeBlock = false
621
- outputCodeBlock(html, state.codeBlockLines, styles)
622
- state.codeBlockLines.clear()
623
- }
624
- }
625
-
626
- private fun closeAllBlockquotes(
627
- html: StringBuilder,
628
- state: GeneratorState,
629
- ) {
630
- while (state.blockquoteDepth >= 0) {
631
- html.append("</blockquote>")
632
- state.blockquoteDepth--
633
- }
634
- state.inBlockquote = false
635
- }
636
-
637
- private fun closeListsIfOpen(
638
- html: StringBuilder,
639
- state: GeneratorState,
640
- ) {
641
- while (state.openListTypes.isNotEmpty()) {
642
- html.append(if (state.openListTypes.last()) "</ol>" else "</ul>")
643
- state.openListTypes.removeAt(state.openListTypes.lastIndex)
644
- }
645
- state.listDepth = -1
646
- }
647
-
648
- private fun closeRemainingContainers(
649
- html: StringBuilder,
650
- state: GeneratorState,
651
- styles: CachedStyles,
652
- ) {
653
- closeCodeBlockIfOpen(html, state, styles)
654
- closeAllBlockquotes(html, state)
655
- closeListsIfOpen(html, state)
656
- }
657
-
658
- private fun generateInlineHTML(
659
- text: Spannable,
660
- start: Int,
661
- end: Int,
662
- styles: CachedStyles,
663
- isCodeBlock: Boolean,
664
- ): String {
665
- val html = StringBuilder(end - start + 32)
666
- var i = start
667
-
668
- while (i < end) {
669
- val char = text[i]
670
-
671
- when {
672
- char == FactoidPaddingSpan.MARK &&
673
- text.getSpans(i, i + 1, FactoidPaddingSpan::class.java).isNotEmpty() -> {
674
- i++
675
- }
676
-
677
- char == '\n' && i == end - 1 -> {
678
- i++
679
- }
680
-
681
- char == OBJECT_REPLACEMENT_CHAR -> {
682
- appendReplacementChar(html, text, i, styles)
683
- i++
684
- }
685
-
686
- else -> {
687
- val segmentEnd = minOf(text.nextSpanTransition(i, end, Any::class.java), end)
688
- val segmentText = text.subSequence(i, segmentEnd).toString()
689
-
690
- if (segmentText.isNotEmpty() && segmentText != "\n") {
691
- appendStyledSegment(html, text, i, segmentEnd, segmentText, styles, isCodeBlock)
692
- }
693
- i = segmentEnd
694
- }
695
- }
696
- }
697
-
698
- return html.toString()
699
- }
700
-
701
- private fun appendReplacementChar(
702
- html: StringBuilder,
703
- text: Spannable,
704
- pos: Int,
705
- styles: CachedStyles,
706
- ) {
707
- val imageSpans = text.getSpans(pos, pos + 1, ImageSpan::class.java)
708
- if (imageSpans.isNotEmpty()) {
709
- val imgSpan = imageSpans[0]
710
-
711
- if (imgSpan.isInline) {
712
- html.append("<img src=\"")
713
- escapeHTMLTo(html, imgSpan.imageUrl)
714
- html
715
- .append("\" style=\"height: ")
716
- .append(styles.inlineImageHeight)
717
- .append("; width: auto; vertical-align: ")
718
- .append(styles.inlineImageVerticalAlign)
719
- .append(";\">")
720
- } else {
721
- html
722
- .append("</p><div style=\"margin-bottom: ")
723
- .append(styles.imageMarginBottom)
724
- .append("px;\"><img src=\"")
725
- escapeHTMLTo(html, imgSpan.imageUrl)
726
- html
727
- .append("\" style=\"max-width: 100%; border-radius: ")
728
- .append(styles.imageBorderRadius)
729
- .append("px;\"></div><p>")
730
- }
731
- return
732
- }
733
-
734
- val mathLatex = extractMathLatex(text, pos, pos + 1)
735
- if (mathLatex != null) {
736
- html
737
- .append("<code style=\"background-color: ")
738
- .append(styles.codeBgColor)
739
- .append("; color: ")
740
- .append(styles.codeColor)
741
- .append("; padding: ")
742
- .append(styles.codePadding)
743
- .append("; border-radius: ")
744
- .append(styles.codeBorderRadius)
745
- .append("; font-size: ")
746
- .append(styles.codeFontSize)
747
- .append("; font-family: Menlo, Monaco, Consolas, monospace;\">")
748
- escapeHTMLTo(html, mathLatex)
749
- html.append("</code>")
750
- }
751
- }
752
-
753
- private fun appendStyledSegment(
754
- html: StringBuilder,
755
- text: Spannable,
756
- start: Int,
757
- end: Int,
758
- content: String,
759
- styles: CachedStyles,
760
- isCodeBlock: Boolean,
761
- ) {
762
- val strongSpans = text.getSpans(start, end, StrongSpan::class.java)
763
- val styleSpans = text.getSpans(start, end, StyleSpan::class.java)
764
- val emphasisSpans = text.getSpans(start, end, EmphasisSpan::class.java)
765
- val underlineSpans = text.getSpans(start, end, UnderlineSpan::class.java)
766
- val strikethroughSpans = text.getSpans(start, end, StrikethroughSpan::class.java)
767
- val linkSpans = text.getSpans(start, end, LinkSpan::class.java)
768
- val codeSpans = text.getSpans(start, end, CodeSpan::class.java)
769
- val highlightSpans = text.getSpans(start, end, HighlightSpan::class.java)
770
-
771
- val isBold =
772
- strongSpans.isNotEmpty() ||
773
- styleSpans.any { it.style == Typeface.BOLD || it.style == Typeface.BOLD_ITALIC }
774
- val isItalic =
775
- emphasisSpans.isNotEmpty() ||
776
- styleSpans.any { it.style == Typeface.ITALIC || it.style == Typeface.BOLD_ITALIC }
777
- val isUnderline = underlineSpans.isNotEmpty()
778
- val isStrikethrough = strikethroughSpans.isNotEmpty()
779
- val link = linkSpans.firstOrNull()
780
- val isCode = codeSpans.isNotEmpty() && !isCodeBlock
781
- val isHighlight = highlightSpans.isNotEmpty()
782
-
783
- if (isHighlight) {
784
- html
785
- .append("<mark style=\"background-color: ")
786
- .append(styles.highlightBackgroundColor)
787
- if (styles.highlightColor != styles.paragraphColor) {
788
- html
789
- .append("; color: ")
790
- .append(styles.highlightColor)
791
- } else {
792
- html.append("; color: inherit")
793
- }
794
- html.append(";\">")
795
- }
796
-
797
- link?.let {
798
- html.append("<a href=\"")
799
- escapeHTMLTo(html, it.url)
800
- html
801
- .append("\" style=\"color: ")
802
- .append(styles.linkColor)
803
- .append("; text-decoration: ")
804
- .append(if (styles.linkUnderline) "underline" else "none")
805
- if (styles.linkFontFamily.isNotEmpty()) {
806
- html.append("; font-family: '").append(styles.linkFontFamily).append("'")
807
- }
808
- html.append(";\">")
809
- }
810
-
811
- if (isCode) {
812
- html
813
- .append("<code style=\"background-color: ")
814
- .append(styles.codeBgColor)
815
- .append("; color: ")
816
- .append(styles.codeColor)
817
- .append("; padding: ")
818
- .append(styles.codePadding)
819
- .append("; border-radius: ")
820
- .append(styles.codeBorderRadius)
821
- .append("; font-size: ")
822
- .append(styles.codeFontSize)
823
- .append("; font-family: Menlo, Monaco, Consolas, monospace;\">")
824
- }
825
-
826
- if (isBold) {
827
- if (styles.strongColor != null) {
828
- html.append("<strong style=\"color: ").append(styles.strongColor).append(";\">")
829
- } else {
830
- html.append("<strong>")
831
- }
832
- }
833
-
834
- if (isItalic) {
835
- if (styles.emphasisColor != null) {
836
- html.append("<em style=\"color: ").append(styles.emphasisColor).append(";\">")
837
- } else {
838
- html.append("<em>")
839
- }
840
- }
841
-
842
- if (isStrikethrough) {
843
- if (styles.strikethroughColor != null) {
844
- html.append("<s style=\"text-decoration-color: ").append(styles.strikethroughColor).append(";\">")
845
- } else {
846
- html.append("<s>")
847
- }
848
- }
849
- if (isUnderline && link == null) {
850
- if (styles.underlineColor != null) {
851
- html.append("<u style=\"text-decoration-color: ").append(styles.underlineColor).append(";\">")
852
- } else {
853
- html.append("<u>")
854
- }
855
- }
856
-
857
- escapeHTMLTo(html, content.trimEnd('\n'), preserveLineBreaks = !isCodeBlock)
858
-
859
- if (isUnderline && link == null) html.append("</u>")
860
- if (isStrikethrough) html.append("</s>")
861
- if (isItalic) html.append("</em>")
862
- if (isBold) html.append("</strong>")
863
- if (isCode) html.append("</code>")
864
- if (link != null) html.append("</a>")
865
- if (isHighlight) html.append("</mark>")
866
- }
867
-
868
- private fun collectParagraphs(text: Spannable): ArrayList<ParagraphInfo> {
869
- val string = text.toString()
870
- val paragraphs = ArrayList<ParagraphInfo>(string.length / 40 + 1) // Estimate
871
- var currentIndex = 0
872
-
873
- while (currentIndex < string.length) {
874
- var lineEnd = string.indexOf('\n', currentIndex)
875
- if (lineEnd == -1) lineEnd = string.length else lineEnd++
876
-
877
- val listSpan = innermostListSpan(text, currentIndex)
878
- val type = getParagraphType(text, currentIndex, listSpan)
879
- val depth = getDepthForType(text, currentIndex, type, listSpan)
880
- val isTaskChecked = type == TYPE_TASK_LIST && (listSpan as? TaskListSpan)?.isChecked == true
881
-
882
- paragraphs.add(ParagraphInfo(currentIndex, lineEnd, type, depth, isTaskChecked))
883
- currentIndex = lineEnd
884
- }
885
-
886
- return paragraphs
887
- }
888
-
889
- /**
890
- * The list span that owns the line starting at [start]. A list item's span covers its nested
891
- * children, so every ancestor is returned here as well - the innermost span is the item that
892
- * actually starts on this line.
893
- */
894
- private fun innermostListSpan(
895
- text: Spannable,
896
- start: Int,
897
- ): BaseListSpan? =
898
- text
899
- .getSpans(start, minOf(start + 1, text.length), BaseListSpan::class.java)
900
- .maxByOrNull { it.depth }
901
-
902
- private fun getParagraphType(
903
- text: Spannable,
904
- start: Int,
905
- listSpan: BaseListSpan?,
906
- ): Int {
907
- val end = minOf(start + 1, text.length)
908
-
909
- if (text.getSpans(start, end, CodeBlockSpan::class.java).isNotEmpty()) return TYPE_CODE_BLOCK
910
-
911
- val headingSpans = text.getSpans(start, end, HeadingSpan::class.java)
912
- if (headingSpans.isNotEmpty()) return headingSpans[0].level.coerceIn(1, 6)
913
-
914
- if (text.getSpans(start, end, BlockquoteSpan::class.java).isNotEmpty()) return TYPE_BLOCKQUOTE
915
-
916
- return when (listSpan) {
917
- is TaskListSpan -> TYPE_TASK_LIST
918
- is OrderedListSpan -> TYPE_ORDERED_LIST
919
- is UnorderedListSpan -> TYPE_UNORDERED_LIST
920
- else -> TYPE_NORMAL
921
- }
922
- }
923
-
924
- private fun getDepthForType(
925
- text: Spannable,
926
- start: Int,
927
- type: Int,
928
- listSpan: BaseListSpan?,
929
- ): Int =
930
- when (type) {
931
- TYPE_BLOCKQUOTE -> {
932
- text.getSpans(start, start + 1, BlockquoteSpan::class.java).maxOfOrNull { it.depth } ?: 0
933
- }
934
-
935
- TYPE_ORDERED_LIST, TYPE_UNORDERED_LIST, TYPE_TASK_LIST -> {
936
- listSpan?.depth ?: 0
937
- }
938
-
939
- else -> {
940
- 0
941
- }
942
- }
943
-
944
- fun generateTableHTML(
945
- rows: List<List<Triple<CharSequence, Boolean, Layout.Alignment>>>,
946
- style: StyleConfig,
947
- scaledDensity: Float = 1f,
948
- density: Float = 1f,
949
- ): String {
950
- if (rows.isEmpty()) return ""
951
-
952
- val tableStyle = style.tableStyle
953
- val cachedStyles = CachedStyles(style, scaledDensity, density)
954
-
955
- val borderColorCSS = colorToCSS(tableStyle.borderColor)
956
- val borderWidthPx = tableStyle.borderWidth / density
957
- val borderRadiusPx = tableStyle.borderRadius / density
958
- val fontSizePx = tableStyle.fontSize / scaledDensity
959
- val cellPaddingVerticalPx = tableStyle.cellPaddingVertical / density
960
- val cellPaddingHorizontalPx = tableStyle.cellPaddingHorizontal / density
961
- val bodyFontWeight = CachedStyles.fontWeightToCSS(tableStyle.fontWeight)
962
-
963
- return buildString(rows.size * 250) {
964
- append("<table style=\"border-collapse: separate; border-spacing: 0; ")
965
- append("border: ${borderWidthPx.toInt()}px solid $borderColorCSS; ")
966
- append("border-radius: ${borderRadiusPx.toInt()}px; ")
967
- append("overflow: hidden; font-size: ${fontSizePx.toInt()}px;\">")
968
-
969
- var hasOpenedBody = false
970
- var bodyRowIndex = 0
971
-
972
- for (row in rows) {
973
- if (row.isEmpty()) continue
974
-
975
- val isHeaderRow = row.first().second
976
-
977
- if (isHeaderRow) {
978
- append("<thead>")
979
- } else if (!hasOpenedBody) {
980
- append("<tbody>")
981
- hasOpenedBody = true
982
- }
983
-
984
- append("<tr>")
985
-
986
- for ((cellText, isHeader, alignment) in row) {
987
- val backgroundColor =
988
- when {
989
- isHeader -> colorToCSS(tableStyle.headerBackgroundColor)
990
- bodyRowIndex % 2 == 0 -> colorToCSS(tableStyle.rowEvenBackgroundColor)
991
- else -> colorToCSS(tableStyle.rowOddBackgroundColor)
992
- }
993
- val textColor = if (isHeader) colorToCSS(tableStyle.headerTextColor) else colorToCSS(tableStyle.color)
994
- val textAlignment = alignmentToCSS(alignment)
995
- val fontWeight = if (isHeader) "bold" else bodyFontWeight
996
-
997
- val htmlTag = if (isHeader) "th" else "td"
998
- append("<$htmlTag style=\"")
999
- append("padding: ${cellPaddingVerticalPx.toInt()}px ${cellPaddingHorizontalPx.toInt()}px; ")
1000
- append("text-align: $textAlignment; ")
1001
- append("background-color: $backgroundColor; ")
1002
- append("color: $textColor; ")
1003
- append("border: ${borderWidthPx.toInt()}px solid $borderColorCSS; ")
1004
- append("font-weight: $fontWeight;\">")
1005
-
1006
- if (cellText.isNotEmpty()) {
1007
- if (cellText is Spannable) {
1008
- append(generateInlineHTML(cellText, 0, cellText.length, cachedStyles, false))
1009
- } else {
1010
- escapeHTMLTo(this, cellText.toString(), preserveLineBreaks = true)
1011
- }
1012
- }
1013
-
1014
- append("</$htmlTag>")
1015
- }
1016
-
1017
- append("</tr>")
1018
-
1019
- if (isHeaderRow) {
1020
- append("</thead>")
1021
- } else {
1022
- bodyRowIndex++
1023
- }
1024
- }
1025
-
1026
- if (hasOpenedBody) append("</tbody>")
1027
- append("</table>")
1028
- }
1029
- }
1030
-
1031
- private fun alignmentToCSS(alignment: Layout.Alignment): String =
1032
- when (alignment) {
1033
- Layout.Alignment.ALIGN_CENTER -> "center"
1034
- Layout.Alignment.ALIGN_OPPOSITE -> "right"
1035
- else -> "left"
1036
- }
1037
-
1038
- private fun colorToCSS(color: Int): String {
1039
- if (color == 0) return "inherit"
1040
- val alpha = (color shr 24) and 0xFF
1041
- val red = (color shr 16) and 0xFF
1042
- val green = (color shr 8) and 0xFF
1043
- val blue = color and 0xFF
1044
- return if (alpha < 255) "rgba($red, $green, $blue, ${alpha / 255f})" else String.format("#%02X%02X%02X", red, green, blue)
1045
- }
1046
-
1047
- /** Fast HTML escape using single-pass character scanning. */
1048
- private fun escapeHTMLTo(
1049
- output: StringBuilder,
1050
- text: String,
1051
- preserveLineBreaks: Boolean = false,
1052
- ) {
1053
- val len = text.length
1054
-
1055
- // Fast path: skip if no escaping needed
1056
- var needsEscape = false
1057
- for (i in 0 until len) {
1058
- val c = text[i]
1059
- if (c == '&' || c == '<' || c == '>' || c == '"' || c == '\'' ||
1060
- (preserveLineBreaks && (c == '\r' || c == '\n' || c == '\u2028' || c == '\u2029'))
1061
- ) {
1062
- needsEscape = true
1063
- break
1064
- }
1065
- }
1066
- if (!needsEscape) {
1067
- output.append(text)
1068
- return
1069
- }
1070
-
1071
- var index = 0
1072
- while (index < len) {
1073
- val c = text[index++]
1074
- if (preserveLineBreaks && (c == '\r' || c == '\n' || c == '\u2028' || c == '\u2029')) {
1075
- if (c == '\r' && index < len && text[index] == '\n') index++
1076
- output.append("<br>")
1077
- continue
1078
- }
1079
- when (c) {
1080
- '&' -> output.append("&amp;")
1081
- '<' -> output.append("&lt;")
1082
- '>' -> output.append("&gt;")
1083
- '"' -> output.append("&quot;")
1084
- '\'' -> output.append("&#39;")
1085
- else -> output.append(c)
1086
- }
1087
- }
1088
- }
1089
-
1090
- private fun extractMathLatex(
1091
- text: android.text.Spannable,
1092
- start: Int,
1093
- end: Int,
1094
- ): String? {
1095
- return try {
1096
- val mathInlineSpanClass = Class.forName("com.renderrichtext.spans.MathInlineSpan")
1097
- val spans = text.getSpans(start, end, mathInlineSpanClass)
1098
- if (spans.isEmpty()) return null
1099
- val latexField = mathInlineSpanClass.getDeclaredField("latex").apply { isAccessible = true }
1100
- latexField.get(spans[0]) as? String
1101
- } catch (_: Exception) {
1102
- null
1103
- }
1104
- }
1105
- }
1
+ package com.renderrichtext.utils.text.conversion
2
+
3
+ import android.graphics.Typeface
4
+ import android.text.Layout
5
+ import android.text.Spannable
6
+ import android.text.style.StyleSpan
7
+ import android.text.style.UnderlineSpan
8
+ import com.renderrichtext.spans.BaseListSpan
9
+ import com.renderrichtext.spans.BlockquoteSpan
10
+ import com.renderrichtext.spans.CodeBlockSpan
11
+ import com.renderrichtext.spans.CodeSpan
12
+ import com.renderrichtext.spans.EmphasisSpan
13
+ import com.renderrichtext.spans.FactoidPaddingSpan
14
+ import com.renderrichtext.spans.HeadingSpan
15
+ import com.renderrichtext.spans.HighlightSpan
16
+ import com.renderrichtext.spans.ImageSpan
17
+ import com.renderrichtext.spans.LinkSpan
18
+ import com.renderrichtext.spans.OrderedListSpan
19
+ import com.renderrichtext.spans.StrikethroughSpan
20
+ import com.renderrichtext.spans.StrongSpan
21
+ import com.renderrichtext.spans.TaskListSpan
22
+ import com.renderrichtext.spans.UnorderedListSpan
23
+ import com.renderrichtext.styles.StyleConfig
24
+
25
+ /** Generates semantic HTML with inline styles from Spannable text. */
26
+ object HTMLGenerator {
27
+ private const val OBJECT_REPLACEMENT_CHAR = '\uFFFC'
28
+
29
+ /** Pre-computed styles to avoid repeated StyleConfig method calls. */
30
+ private class CachedStyles(
31
+ style: StyleConfig,
32
+ private val fontDensity: Float,
33
+ private val dimDensity: Float,
34
+ ) {
35
+ // Convert device pixels back to CSS pixels
36
+ private fun fontPx(px: Float) = (px / fontDensity).toInt()
37
+
38
+ private fun dimPx(px: Float) = (px / dimDensity).toInt()
39
+
40
+ // Paragraph
41
+ val paragraphColor: String
42
+ val paragraphFontSize: Int
43
+ val paragraphMarginBottom: Int
44
+
45
+ // Code block
46
+ val codeBlockColor: String
47
+ val codeBlockBgColor: String
48
+ val codeBlockFontSize: Int
49
+ val codeBlockPadding: Int
50
+ val codeBlockBorderRadius: Int
51
+ val codeBlockMarginBottom: Int
52
+
53
+ // Inline code
54
+ val codeColor: String
55
+ val codeBgColor: String
56
+
57
+ // Blockquote
58
+ val blockquoteColor: String
59
+ val blockquoteBgColor: String
60
+ val blockquoteBorderColor: String
61
+ val blockquoteBorderWidth: Int
62
+ val blockquoteGapWidth: Int
63
+ val blockquoteMarginBottom: Int
64
+ val blockquoteFontSize: Int
65
+
66
+ // List
67
+ val listColor: String
68
+ val listFontSize: Int
69
+ val listMarginBottom: Int
70
+ val listMarginLeft: Int
71
+
72
+ // Task list checkbox
73
+ val taskCheckedColor: String
74
+ val taskBorderColor: String
75
+ val taskCheckmarkColor: String
76
+ val taskCheckboxSize: Int
77
+ val taskCheckboxBorderRadius: Int
78
+
79
+ // Link
80
+ val linkFontFamily: String
81
+ val linkColor: String
82
+ val linkUnderline: Boolean
83
+
84
+ // Strong/Emphasis/Strikethrough/Underline
85
+ val strongColor: String?
86
+ val emphasisColor: String?
87
+ val strikethroughColor: String?
88
+ val underlineColor: String?
89
+ val highlightColor: String
90
+ val highlightBackgroundColor: String
91
+
92
+ // Image
93
+ val imageMarginBottom: Int
94
+ val imageBorderRadius: Int
95
+
96
+ // Fixed HTML values (not from StyleConfig)
97
+ val blockquotePaddingVertical = "8px"
98
+ val blockquoteBorderRadiusCorners = "border-start-end-radius: 8px; border-end-end-radius: 8px"
99
+ val blockquoteNestedMargin = "8px 0 0 0"
100
+ val blockquoteParagraphMargin = "0 0 4px 0"
101
+ val inlineImageHeight = "1.2em"
102
+ val inlineImageVerticalAlign = "-0.2em"
103
+ val codePadding = "2px 4px"
104
+ val codeBorderRadius = "4px"
105
+ val codeFontSize = "1em"
106
+
107
+ // Headings (array for O(1) lookup)
108
+ val headingFontSizes: IntArray
109
+ val headingFontWeights: Array<String>
110
+ val headingColors: Array<String>
111
+ val headingMarginBottoms: IntArray
112
+
113
+ init {
114
+ // Paragraph
115
+ val pStyle = style.paragraphStyle
116
+ paragraphColor = colorToCSS(pStyle.color)
117
+ paragraphFontSize = fontPx(pStyle.fontSize)
118
+ paragraphMarginBottom = dimPx(pStyle.marginBottom)
119
+
120
+ // Code block
121
+ val cbStyle = style.codeBlockStyle
122
+ codeBlockColor = colorToCSS(cbStyle.color)
123
+ codeBlockBgColor = colorToCSS(cbStyle.backgroundColor)
124
+ codeBlockFontSize = fontPx(cbStyle.fontSize)
125
+ codeBlockPadding = dimPx(cbStyle.padding)
126
+ codeBlockBorderRadius = dimPx(cbStyle.borderRadius)
127
+ codeBlockMarginBottom = dimPx(cbStyle.marginBottom)
128
+
129
+ // Inline code
130
+ val cStyle = style.codeStyle
131
+ codeColor = colorToCSS(cStyle.color)
132
+ codeBgColor = colorToCSS(cStyle.backgroundColor)
133
+
134
+ // Blockquote
135
+ val bqStyle = style.blockquoteStyle
136
+ blockquoteColor = colorToCSS(bqStyle.color)
137
+ blockquoteBgColor = colorToCSS(bqStyle.backgroundColor ?: 0)
138
+ blockquoteBorderColor = colorToCSS(bqStyle.borderColor)
139
+ blockquoteBorderWidth = dimPx(bqStyle.borderWidth)
140
+ blockquoteGapWidth = dimPx(bqStyle.gapWidth)
141
+ blockquoteMarginBottom = dimPx(bqStyle.marginBottom)
142
+ blockquoteFontSize = fontPx(bqStyle.fontSize)
143
+
144
+ // List
145
+ val lStyle = style.listStyle
146
+ listColor = colorToCSS(lStyle.color)
147
+ listFontSize = fontPx(lStyle.fontSize)
148
+ listMarginBottom = dimPx(lStyle.marginBottom)
149
+ listMarginLeft = dimPx(lStyle.marginLeft)
150
+
151
+ // Task list checkbox
152
+ val tlStyle = style.taskListStyle
153
+ taskCheckedColor = colorToCSS(tlStyle.checkedColor)
154
+ taskBorderColor = colorToCSS(tlStyle.borderColor)
155
+ taskCheckmarkColor = colorToCSS(tlStyle.checkmarkColor)
156
+ taskCheckboxSize = dimPx(tlStyle.checkboxSize)
157
+ taskCheckboxBorderRadius = dimPx(tlStyle.checkboxBorderRadius)
158
+
159
+ // Link
160
+ linkFontFamily = style.linkStyle.fontFamily
161
+ linkColor = colorToCSS(style.linkStyle.color)
162
+ linkUnderline = style.linkStyle.underline
163
+
164
+ // Strong/Emphasis/Strikethrough/Underline (nullable for inherit)
165
+ val sc = style.strongStyle.color
166
+ strongColor = if (sc != null && sc != 0) colorToCSS(sc) else null
167
+ val ec = style.emphasisStyle.color
168
+ emphasisColor = if (ec != null && ec != 0) colorToCSS(ec) else null
169
+ val strikeColor = style.strikethroughStyle.color
170
+ strikethroughColor = if (strikeColor != 0) colorToCSS(strikeColor) else null
171
+ val underline = style.underlineStyle.color
172
+ underlineColor = if (underline != 0) colorToCSS(underline) else null
173
+ highlightColor = colorToCSS(style.highlightStyle.color)
174
+ highlightBackgroundColor = colorToCSS(style.highlightStyle.backgroundColor)
175
+
176
+ // Image
177
+ val imgStyle = style.imageStyle
178
+ imageMarginBottom = dimPx(imgStyle.marginBottom)
179
+ imageBorderRadius = dimPx(imgStyle.borderRadius)
180
+
181
+ // Headings (1-6, index 0-5)
182
+ headingFontSizes = IntArray(6)
183
+ headingFontWeights = Array(6) { "" }
184
+ headingColors = Array(6) { "" }
185
+ headingMarginBottoms = IntArray(6)
186
+
187
+ for (level in 1..6) {
188
+ val hStyle = style.headingStyles[level]!!
189
+ val idx = level - 1
190
+ headingFontSizes[idx] = fontPx(hStyle.fontSize)
191
+ headingFontWeights[idx] = fontWeightToCSS(hStyle.fontWeight)
192
+ headingColors[idx] = colorToCSS(hStyle.color)
193
+ headingMarginBottoms[idx] = dimPx(hStyle.marginBottom)
194
+ }
195
+ }
196
+
197
+ companion object {
198
+ private fun colorToCSS(color: Int): String {
199
+ if (color == 0) return "inherit"
200
+ val alpha = (color shr 24) and 0xFF
201
+ val red = (color shr 16) and 0xFF
202
+ val green = (color shr 8) and 0xFF
203
+ val blue = color and 0xFF
204
+
205
+ return if (alpha < 255) {
206
+ "rgba($red, $green, $blue, ${alpha / 255f})"
207
+ } else {
208
+ String.format("#%02X%02X%02X", red, green, blue)
209
+ }
210
+ }
211
+
212
+ fun fontWeightToCSS(fontWeight: String): String =
213
+ when {
214
+ fontWeight.equals("bold", ignoreCase = true) -> "700"
215
+ fontWeight.equals("semibold", ignoreCase = true) -> "600"
216
+ fontWeight.equals("medium", ignoreCase = true) -> "500"
217
+ fontWeight.isEmpty() || fontWeight.equals("normal", ignoreCase = true) -> "normal"
218
+ else -> fontWeight
219
+ }
220
+ }
221
+ }
222
+
223
+ private class GeneratorState {
224
+ var inCodeBlock = false
225
+ var previousWasCodeBlock = false
226
+ val codeBlockLines = ArrayList<String>(16) // Pre-sized
227
+
228
+ var inBlockquote = false
229
+ var blockquoteDepth = -1
230
+ var previousWasBlockquote = false
231
+
232
+ var listDepth = -1
233
+ val openListTypes = ArrayList<Boolean>(4) // true = ol, false = ul
234
+ }
235
+
236
+ // Paragraph type constants (H1-H6 = 1-6 directly from HeadingSpan.level)
237
+ private const val TYPE_NORMAL = 0
238
+ private const val TYPE_H1 = 1
239
+ private const val TYPE_H6 = 6
240
+ private const val TYPE_CODE_BLOCK = 7
241
+ private const val TYPE_BLOCKQUOTE = 8
242
+ private const val TYPE_ORDERED_LIST = 9
243
+ private const val TYPE_UNORDERED_LIST = 10
244
+ private const val TYPE_TASK_LIST = 11
245
+
246
+ private data class ParagraphInfo(
247
+ val start: Int,
248
+ val end: Int,
249
+ val type: Int,
250
+ val depth: Int = 0,
251
+ val isTaskChecked: Boolean = false,
252
+ )
253
+
254
+ fun generateHTML(
255
+ text: Spannable,
256
+ style: StyleConfig,
257
+ scaledDensity: Float = 1f,
258
+ density: Float = 1f,
259
+ isRTL: Boolean = false,
260
+ ): String {
261
+ if (text.isEmpty()) return "<html></html>"
262
+
263
+ // Pre-cache all styles (single allocation)
264
+ val styles = CachedStyles(style, scaledDensity, density)
265
+ val state = GeneratorState()
266
+
267
+ // Estimate capacity (average 2x text length)
268
+ val html = StringBuilder(text.length * 2)
269
+ if (isRTL) {
270
+ html.append("<html dir=\"rtl\">")
271
+ html.append("<div dir=\"rtl\" style=\"direction: rtl; text-align: right;\">")
272
+ } else {
273
+ html.append("<html>")
274
+ }
275
+
276
+ // Collect paragraphs
277
+ val paragraphs = collectParagraphs(text)
278
+
279
+ for (para in paragraphs) {
280
+ processParagraph(html, text, para, styles, state)
281
+ }
282
+
283
+ closeRemainingContainers(html, state, styles)
284
+ if (isRTL) {
285
+ html.append("</div>")
286
+ }
287
+ html.append("</html>")
288
+
289
+ return html.toString()
290
+ }
291
+
292
+ private fun processParagraph(
293
+ html: StringBuilder,
294
+ text: Spannable,
295
+ para: ParagraphInfo,
296
+ styles: CachedStyles,
297
+ state: GeneratorState,
298
+ ) {
299
+ val paraText = text.subSequence(para.start, para.end).toString().trimEnd('\n')
300
+
301
+ // Handle empty paragraphs
302
+ if (paraText.isEmpty() && para.type == TYPE_NORMAL) {
303
+ closeAllBlockquotes(html, state)
304
+ state.previousWasBlockquote = false
305
+ return
306
+ }
307
+
308
+ // Get content range (trim trailing newline)
309
+ val contentEnd = if (para.end > para.start && text[para.end - 1] == '\n') para.end - 1 else para.end
310
+ val isCodeBlock = para.type == TYPE_CODE_BLOCK
311
+ val inlineContent = generateInlineHTML(text, para.start, contentEnd, styles, isCodeBlock)
312
+
313
+ // Handle different paragraph types
314
+ when (para.type) {
315
+ TYPE_CODE_BLOCK -> handleCodeBlock(inlineContent, state)
316
+ TYPE_BLOCKQUOTE -> handleBlockquote(html, inlineContent, para, styles, state)
317
+ TYPE_ORDERED_LIST, TYPE_UNORDERED_LIST, TYPE_TASK_LIST -> handleList(html, inlineContent, para, styles, state)
318
+ in TYPE_H1..TYPE_H6 -> handleHeading(html, inlineContent, para.type, styles, state)
319
+ else -> handleNormalParagraph(html, inlineContent, styles, state)
320
+ }
321
+ }
322
+
323
+ private fun handleCodeBlock(
324
+ content: String,
325
+ state: GeneratorState,
326
+ ) {
327
+ if (!state.inCodeBlock) {
328
+ state.inCodeBlock = true
329
+ state.codeBlockLines.clear()
330
+ }
331
+
332
+ state.codeBlockLines.add(content.trimStart())
333
+ state.previousWasCodeBlock = true
334
+ state.previousWasBlockquote = false
335
+ }
336
+
337
+ private fun outputCodeBlock(
338
+ html: StringBuilder,
339
+ lines: List<String>,
340
+ styles: CachedStyles,
341
+ ) {
342
+ if (lines.isEmpty()) return
343
+
344
+ html
345
+ .append("<pre dir=\"ltr\" style=\"background-color: ")
346
+ .append(styles.codeBlockBgColor)
347
+ .append("; padding: ")
348
+ .append(styles.codeBlockPadding)
349
+ .append("px; border-radius: ")
350
+ .append(styles.codeBlockBorderRadius)
351
+ .append("px; margin: 0 0 ")
352
+ .append(styles.codeBlockMarginBottom)
353
+ .append("px 0; overflow-x: auto; text-align: left;\"><code style=\"font-family: Menlo, Monaco, Consolas, monospace; font-size: ")
354
+ .append(styles.codeBlockFontSize)
355
+ .append("px; color: ")
356
+ .append(styles.codeBlockColor)
357
+ .append(";\">")
358
+
359
+ for (i in lines.indices) {
360
+ if (i > 0) html.append('\n')
361
+ html.append(lines[i])
362
+ }
363
+
364
+ html.append("</code></pre>")
365
+ }
366
+
367
+ private fun handleBlockquote(
368
+ html: StringBuilder,
369
+ content: String,
370
+ para: ParagraphInfo,
371
+ styles: CachedStyles,
372
+ state: GeneratorState,
373
+ ) {
374
+ closeCodeBlockIfOpen(html, state, styles)
375
+
376
+ val depth = para.depth
377
+
378
+ // Reset if coming from non-blockquote content
379
+ if (!state.previousWasBlockquote && state.inBlockquote) {
380
+ closeAllBlockquotes(html, state)
381
+ }
382
+
383
+ while (state.blockquoteDepth > depth) {
384
+ html.append("</blockquote>")
385
+ state.blockquoteDepth--
386
+ }
387
+
388
+ while (state.blockquoteDepth < depth) {
389
+ state.blockquoteDepth++
390
+ state.inBlockquote = true
391
+
392
+ if (state.blockquoteDepth == 0) {
393
+ html
394
+ .append("<blockquote style=\"background-color: ")
395
+ .append(styles.blockquoteBgColor)
396
+ .append("; border-inline-start: ")
397
+ .append(styles.blockquoteBorderWidth)
398
+ .append("px solid ")
399
+ .append(styles.blockquoteBorderColor)
400
+ .append("; padding: ")
401
+ .append(styles.blockquotePaddingVertical)
402
+ .append(" ")
403
+ .append(styles.blockquoteGapWidth)
404
+ .append("px; margin: 0 0 ")
405
+ .append(styles.blockquoteMarginBottom)
406
+ .append("px 0; ")
407
+ .append(styles.blockquoteBorderRadiusCorners)
408
+ .append(";\">")
409
+ } else {
410
+ html
411
+ .append("<blockquote style=\"border-inline-start: ")
412
+ .append(styles.blockquoteBorderWidth)
413
+ .append("px solid ")
414
+ .append(styles.blockquoteBorderColor)
415
+ .append("; padding-inline-start: ")
416
+ .append(styles.blockquoteGapWidth)
417
+ .append("px; margin: ")
418
+ .append(styles.blockquoteNestedMargin)
419
+ .append(";\">")
420
+ }
421
+ }
422
+
423
+ html
424
+ .append("<p style=\"margin: ")
425
+ .append(styles.blockquoteParagraphMargin)
426
+ .append("; color: ")
427
+ .append(styles.blockquoteColor)
428
+ .append("; font-size: ")
429
+ .append(styles.blockquoteFontSize)
430
+ .append("px;\">")
431
+ .append(content)
432
+ .append("</p>")
433
+
434
+ state.previousWasBlockquote = true
435
+ state.previousWasCodeBlock = false
436
+ }
437
+
438
+ private fun handleList(
439
+ html: StringBuilder,
440
+ content: String,
441
+ para: ParagraphInfo,
442
+ styles: CachedStyles,
443
+ state: GeneratorState,
444
+ ) {
445
+ closeCodeBlockIfOpen(html, state, styles)
446
+ closeAllBlockquotes(html, state)
447
+
448
+ val depth = para.depth
449
+ val isOrdered = para.type == TYPE_ORDERED_LIST
450
+ val isTask = para.type == TYPE_TASK_LIST
451
+
452
+ // Close lists to shallower depth
453
+ while (state.listDepth > depth) {
454
+ html.append(if (state.openListTypes.lastOrNull() == true) "</ol>" else "</ul>")
455
+ if (state.openListTypes.isNotEmpty()) state.openListTypes.removeAt(state.openListTypes.lastIndex)
456
+ state.listDepth--
457
+ }
458
+
459
+ // Handle list type change at same depth (ul <-> ol)
460
+ if (state.listDepth == depth && state.openListTypes.isNotEmpty()) {
461
+ if (state.openListTypes.last() != isOrdered) {
462
+ html.append(if (state.openListTypes.last()) "</ol>" else "</ul>")
463
+ state.openListTypes.removeAt(state.openListTypes.lastIndex)
464
+ state.listDepth--
465
+ }
466
+ }
467
+
468
+ // Open lists to deeper depth
469
+ while (state.listDepth < depth) {
470
+ state.listDepth++
471
+ val marginStyle =
472
+ if (state.listDepth == 0) {
473
+ "margin: 0 0 ${styles.paragraphMarginBottom}px 0; "
474
+ } else {
475
+ "margin: 0; "
476
+ }
477
+
478
+ if (isOrdered) {
479
+ html
480
+ .append("<ol style=\"")
481
+ .append(marginStyle)
482
+ .append("padding-inline-start: ")
483
+ .append(styles.listMarginLeft)
484
+ .append("px;\">")
485
+ state.openListTypes.add(true)
486
+ } else {
487
+ html
488
+ .append("<ul style=\"")
489
+ .append(marginStyle)
490
+ .append("padding-inline-start: ")
491
+ .append(styles.listMarginLeft)
492
+ .append("px; list-style-type: disc;\">")
493
+ state.openListTypes.add(false)
494
+ }
495
+ }
496
+
497
+ html
498
+ .append("<li style=\"margin-bottom: ")
499
+ .append(styles.listMarginBottom)
500
+ .append("px; color: ")
501
+ .append(styles.listColor)
502
+ .append("; font-size: ")
503
+ .append(styles.listFontSize)
504
+ .append("px;")
505
+
506
+ if (isTask) {
507
+ html.append(" list-style-type: none;")
508
+ }
509
+
510
+ html.append("\">")
511
+
512
+ if (isTask) {
513
+ val size = styles.taskCheckboxSize
514
+ val radius = styles.taskCheckboxBorderRadius
515
+ if (para.isTaskChecked) {
516
+ html
517
+ .append("<span style=\"display: inline-block; width: ")
518
+ .append(size)
519
+ .append("px; height: ")
520
+ .append(size)
521
+ .append("px; border-radius: ")
522
+ .append(radius)
523
+ .append("px; background-color: ")
524
+ .append(styles.taskCheckedColor)
525
+ .append("; color: ")
526
+ .append(styles.taskCheckmarkColor)
527
+ .append("; font-size: ")
528
+ .append(size - 2)
529
+ .append("px; line-height: ")
530
+ .append(size)
531
+ .append("px; text-align: center; vertical-align: middle; margin-inline-end: 4px;\">&#10003;</span> ")
532
+ } else {
533
+ html
534
+ .append("<span style=\"display: inline-block; width: ")
535
+ .append(size)
536
+ .append("px; height: ")
537
+ .append(size)
538
+ .append("px; border-radius: ")
539
+ .append(radius)
540
+ .append("px; border: 1.5px solid ")
541
+ .append(styles.taskBorderColor)
542
+ .append("; vertical-align: middle; margin-inline-end: 4px;\"></span> ")
543
+ }
544
+ }
545
+
546
+ html
547
+ .append(content)
548
+ .append("</li>")
549
+
550
+ state.previousWasBlockquote = false
551
+ state.previousWasCodeBlock = false
552
+ }
553
+
554
+ private fun handleHeading(
555
+ html: StringBuilder,
556
+ content: String,
557
+ type: Int,
558
+ styles: CachedStyles,
559
+ state: GeneratorState,
560
+ ) {
561
+ closeCodeBlockIfOpen(html, state, styles)
562
+ closeAllBlockquotes(html, state)
563
+ closeListsIfOpen(html, state)
564
+
565
+ val level = type // TYPE_H1..TYPE_H6 = 1..6
566
+ val idx = level - 1
567
+
568
+ html
569
+ .append("<h")
570
+ .append(level)
571
+ .append(" style=\"font-size: ")
572
+ .append(styles.headingFontSizes[idx])
573
+ .append("px; font-weight: ")
574
+ .append(styles.headingFontWeights[idx])
575
+ .append("; color: ")
576
+ .append(styles.headingColors[idx])
577
+ .append("; margin: 0 0 ")
578
+ .append(styles.headingMarginBottoms[idx])
579
+ .append("px 0;\">")
580
+ .append(content)
581
+ .append("</h")
582
+ .append(level)
583
+ .append('>')
584
+
585
+ state.previousWasBlockquote = false
586
+ state.previousWasCodeBlock = false
587
+ }
588
+
589
+ private fun handleNormalParagraph(
590
+ html: StringBuilder,
591
+ content: String,
592
+ styles: CachedStyles,
593
+ state: GeneratorState,
594
+ ) {
595
+ closeCodeBlockIfOpen(html, state, styles)
596
+ closeAllBlockquotes(html, state)
597
+ closeListsIfOpen(html, state)
598
+
599
+ html
600
+ .append("<p style=\"margin: 0 0 ")
601
+ .append(styles.paragraphMarginBottom)
602
+ .append("px 0; color: ")
603
+ .append(styles.paragraphColor)
604
+ .append("; font-size: ")
605
+ .append(styles.paragraphFontSize)
606
+ .append("px;\">")
607
+ .append(content)
608
+ .append("</p>")
609
+
610
+ state.previousWasBlockquote = false
611
+ state.previousWasCodeBlock = false
612
+ }
613
+
614
+ private fun closeCodeBlockIfOpen(
615
+ html: StringBuilder,
616
+ state: GeneratorState,
617
+ styles: CachedStyles,
618
+ ) {
619
+ if (state.inCodeBlock) {
620
+ state.inCodeBlock = false
621
+ outputCodeBlock(html, state.codeBlockLines, styles)
622
+ state.codeBlockLines.clear()
623
+ }
624
+ }
625
+
626
+ private fun closeAllBlockquotes(
627
+ html: StringBuilder,
628
+ state: GeneratorState,
629
+ ) {
630
+ while (state.blockquoteDepth >= 0) {
631
+ html.append("</blockquote>")
632
+ state.blockquoteDepth--
633
+ }
634
+ state.inBlockquote = false
635
+ }
636
+
637
+ private fun closeListsIfOpen(
638
+ html: StringBuilder,
639
+ state: GeneratorState,
640
+ ) {
641
+ while (state.openListTypes.isNotEmpty()) {
642
+ html.append(if (state.openListTypes.last()) "</ol>" else "</ul>")
643
+ state.openListTypes.removeAt(state.openListTypes.lastIndex)
644
+ }
645
+ state.listDepth = -1
646
+ }
647
+
648
+ private fun closeRemainingContainers(
649
+ html: StringBuilder,
650
+ state: GeneratorState,
651
+ styles: CachedStyles,
652
+ ) {
653
+ closeCodeBlockIfOpen(html, state, styles)
654
+ closeAllBlockquotes(html, state)
655
+ closeListsIfOpen(html, state)
656
+ }
657
+
658
+ private fun generateInlineHTML(
659
+ text: Spannable,
660
+ start: Int,
661
+ end: Int,
662
+ styles: CachedStyles,
663
+ isCodeBlock: Boolean,
664
+ ): String {
665
+ val html = StringBuilder(end - start + 32)
666
+ var i = start
667
+
668
+ while (i < end) {
669
+ val char = text[i]
670
+
671
+ when {
672
+ char == FactoidPaddingSpan.MARK &&
673
+ text.getSpans(i, i + 1, FactoidPaddingSpan::class.java).isNotEmpty() -> {
674
+ i++
675
+ }
676
+
677
+ char == '\n' && i == end - 1 -> {
678
+ i++
679
+ }
680
+
681
+ char == OBJECT_REPLACEMENT_CHAR -> {
682
+ appendReplacementChar(html, text, i, styles)
683
+ i++
684
+ }
685
+
686
+ else -> {
687
+ val segmentEnd = minOf(text.nextSpanTransition(i, end, Any::class.java), end)
688
+ val segmentText = text.subSequence(i, segmentEnd).toString()
689
+
690
+ if (segmentText.isNotEmpty() && segmentText != "\n") {
691
+ appendStyledSegment(html, text, i, segmentEnd, segmentText, styles, isCodeBlock)
692
+ }
693
+ i = segmentEnd
694
+ }
695
+ }
696
+ }
697
+
698
+ return html.toString()
699
+ }
700
+
701
+ private fun appendReplacementChar(
702
+ html: StringBuilder,
703
+ text: Spannable,
704
+ pos: Int,
705
+ styles: CachedStyles,
706
+ ) {
707
+ val imageSpans = text.getSpans(pos, pos + 1, ImageSpan::class.java)
708
+ if (imageSpans.isNotEmpty()) {
709
+ val imgSpan = imageSpans[0]
710
+
711
+ if (imgSpan.isInline) {
712
+ html.append("<img src=\"")
713
+ escapeHTMLTo(html, imgSpan.imageUrl)
714
+ html
715
+ .append("\" style=\"height: ")
716
+ .append(styles.inlineImageHeight)
717
+ .append("; width: auto; vertical-align: ")
718
+ .append(styles.inlineImageVerticalAlign)
719
+ .append(";\">")
720
+ } else {
721
+ html
722
+ .append("</p><div style=\"margin-bottom: ")
723
+ .append(styles.imageMarginBottom)
724
+ .append("px;\"><img src=\"")
725
+ escapeHTMLTo(html, imgSpan.imageUrl)
726
+ html
727
+ .append("\" style=\"max-width: 100%; border-radius: ")
728
+ .append(styles.imageBorderRadius)
729
+ .append("px;\"></div><p>")
730
+ }
731
+ return
732
+ }
733
+
734
+ val mathLatex = extractMathLatex(text, pos, pos + 1)
735
+ if (mathLatex != null) {
736
+ html
737
+ .append("<code style=\"background-color: ")
738
+ .append(styles.codeBgColor)
739
+ .append("; color: ")
740
+ .append(styles.codeColor)
741
+ .append("; padding: ")
742
+ .append(styles.codePadding)
743
+ .append("; border-radius: ")
744
+ .append(styles.codeBorderRadius)
745
+ .append("; font-size: ")
746
+ .append(styles.codeFontSize)
747
+ .append("; font-family: Menlo, Monaco, Consolas, monospace;\">")
748
+ escapeHTMLTo(html, mathLatex)
749
+ html.append("</code>")
750
+ }
751
+ }
752
+
753
+ private fun appendStyledSegment(
754
+ html: StringBuilder,
755
+ text: Spannable,
756
+ start: Int,
757
+ end: Int,
758
+ content: String,
759
+ styles: CachedStyles,
760
+ isCodeBlock: Boolean,
761
+ ) {
762
+ val strongSpans = text.getSpans(start, end, StrongSpan::class.java)
763
+ val styleSpans = text.getSpans(start, end, StyleSpan::class.java)
764
+ val emphasisSpans = text.getSpans(start, end, EmphasisSpan::class.java)
765
+ val underlineSpans = text.getSpans(start, end, UnderlineSpan::class.java)
766
+ val strikethroughSpans = text.getSpans(start, end, StrikethroughSpan::class.java)
767
+ val linkSpans = text.getSpans(start, end, LinkSpan::class.java)
768
+ val codeSpans = text.getSpans(start, end, CodeSpan::class.java)
769
+ val highlightSpans = text.getSpans(start, end, HighlightSpan::class.java)
770
+
771
+ val isBold =
772
+ strongSpans.isNotEmpty() ||
773
+ styleSpans.any { it.style == Typeface.BOLD || it.style == Typeface.BOLD_ITALIC }
774
+ val isItalic =
775
+ emphasisSpans.isNotEmpty() ||
776
+ styleSpans.any { it.style == Typeface.ITALIC || it.style == Typeface.BOLD_ITALIC }
777
+ val isUnderline = underlineSpans.isNotEmpty()
778
+ val isStrikethrough = strikethroughSpans.isNotEmpty()
779
+ val link = linkSpans.firstOrNull()
780
+ val isCode = codeSpans.isNotEmpty() && !isCodeBlock
781
+ val isHighlight = highlightSpans.isNotEmpty()
782
+
783
+ if (isHighlight) {
784
+ html
785
+ .append("<mark style=\"background-color: ")
786
+ .append(styles.highlightBackgroundColor)
787
+ if (styles.highlightColor != styles.paragraphColor) {
788
+ html
789
+ .append("; color: ")
790
+ .append(styles.highlightColor)
791
+ } else {
792
+ html.append("; color: inherit")
793
+ }
794
+ html.append(";\">")
795
+ }
796
+
797
+ link?.let {
798
+ html.append("<a href=\"")
799
+ escapeHTMLTo(html, it.url)
800
+ html
801
+ .append("\" style=\"color: ")
802
+ .append(styles.linkColor)
803
+ .append("; text-decoration: ")
804
+ .append(if (styles.linkUnderline) "underline" else "none")
805
+ if (styles.linkFontFamily.isNotEmpty()) {
806
+ html.append("; font-family: '").append(styles.linkFontFamily).append("'")
807
+ }
808
+ html.append(";\">")
809
+ }
810
+
811
+ if (isCode) {
812
+ html
813
+ .append("<code style=\"background-color: ")
814
+ .append(styles.codeBgColor)
815
+ .append("; color: ")
816
+ .append(styles.codeColor)
817
+ .append("; padding: ")
818
+ .append(styles.codePadding)
819
+ .append("; border-radius: ")
820
+ .append(styles.codeBorderRadius)
821
+ .append("; font-size: ")
822
+ .append(styles.codeFontSize)
823
+ .append("; font-family: Menlo, Monaco, Consolas, monospace;\">")
824
+ }
825
+
826
+ if (isBold) {
827
+ if (styles.strongColor != null) {
828
+ html.append("<strong style=\"color: ").append(styles.strongColor).append(";\">")
829
+ } else {
830
+ html.append("<strong>")
831
+ }
832
+ }
833
+
834
+ if (isItalic) {
835
+ if (styles.emphasisColor != null) {
836
+ html.append("<em style=\"color: ").append(styles.emphasisColor).append(";\">")
837
+ } else {
838
+ html.append("<em>")
839
+ }
840
+ }
841
+
842
+ if (isStrikethrough) {
843
+ if (styles.strikethroughColor != null) {
844
+ html.append("<s style=\"text-decoration-color: ").append(styles.strikethroughColor).append(";\">")
845
+ } else {
846
+ html.append("<s>")
847
+ }
848
+ }
849
+ if (isUnderline && link == null) {
850
+ if (styles.underlineColor != null) {
851
+ html.append("<u style=\"text-decoration-color: ").append(styles.underlineColor).append(";\">")
852
+ } else {
853
+ html.append("<u>")
854
+ }
855
+ }
856
+
857
+ escapeHTMLTo(html, content.trimEnd('\n'), preserveLineBreaks = !isCodeBlock)
858
+
859
+ if (isUnderline && link == null) html.append("</u>")
860
+ if (isStrikethrough) html.append("</s>")
861
+ if (isItalic) html.append("</em>")
862
+ if (isBold) html.append("</strong>")
863
+ if (isCode) html.append("</code>")
864
+ if (link != null) html.append("</a>")
865
+ if (isHighlight) html.append("</mark>")
866
+ }
867
+
868
+ private fun collectParagraphs(text: Spannable): ArrayList<ParagraphInfo> {
869
+ val string = text.toString()
870
+ val paragraphs = ArrayList<ParagraphInfo>(string.length / 40 + 1) // Estimate
871
+ var currentIndex = 0
872
+
873
+ while (currentIndex < string.length) {
874
+ var lineEnd = string.indexOf('\n', currentIndex)
875
+ if (lineEnd == -1) lineEnd = string.length else lineEnd++
876
+
877
+ val listSpan = innermostListSpan(text, currentIndex)
878
+ val type = getParagraphType(text, currentIndex, listSpan)
879
+ val depth = getDepthForType(text, currentIndex, type, listSpan)
880
+ val isTaskChecked = type == TYPE_TASK_LIST && (listSpan as? TaskListSpan)?.isChecked == true
881
+
882
+ paragraphs.add(ParagraphInfo(currentIndex, lineEnd, type, depth, isTaskChecked))
883
+ currentIndex = lineEnd
884
+ }
885
+
886
+ return paragraphs
887
+ }
888
+
889
+ /**
890
+ * The list span that owns the line starting at [start]. A list item's span covers its nested
891
+ * children, so every ancestor is returned here as well - the innermost span is the item that
892
+ * actually starts on this line.
893
+ */
894
+ private fun innermostListSpan(
895
+ text: Spannable,
896
+ start: Int,
897
+ ): BaseListSpan? =
898
+ text
899
+ .getSpans(start, minOf(start + 1, text.length), BaseListSpan::class.java)
900
+ .maxByOrNull { it.depth }
901
+
902
+ private fun getParagraphType(
903
+ text: Spannable,
904
+ start: Int,
905
+ listSpan: BaseListSpan?,
906
+ ): Int {
907
+ val end = minOf(start + 1, text.length)
908
+
909
+ if (text.getSpans(start, end, CodeBlockSpan::class.java).isNotEmpty()) return TYPE_CODE_BLOCK
910
+
911
+ val headingSpans = text.getSpans(start, end, HeadingSpan::class.java)
912
+ if (headingSpans.isNotEmpty()) return headingSpans[0].level.coerceIn(1, 6)
913
+
914
+ if (text.getSpans(start, end, BlockquoteSpan::class.java).isNotEmpty()) return TYPE_BLOCKQUOTE
915
+
916
+ return when (listSpan) {
917
+ is TaskListSpan -> TYPE_TASK_LIST
918
+ is OrderedListSpan -> TYPE_ORDERED_LIST
919
+ is UnorderedListSpan -> TYPE_UNORDERED_LIST
920
+ else -> TYPE_NORMAL
921
+ }
922
+ }
923
+
924
+ private fun getDepthForType(
925
+ text: Spannable,
926
+ start: Int,
927
+ type: Int,
928
+ listSpan: BaseListSpan?,
929
+ ): Int =
930
+ when (type) {
931
+ TYPE_BLOCKQUOTE -> {
932
+ text.getSpans(start, start + 1, BlockquoteSpan::class.java).maxOfOrNull { it.depth } ?: 0
933
+ }
934
+
935
+ TYPE_ORDERED_LIST, TYPE_UNORDERED_LIST, TYPE_TASK_LIST -> {
936
+ listSpan?.depth ?: 0
937
+ }
938
+
939
+ else -> {
940
+ 0
941
+ }
942
+ }
943
+
944
+ fun generateTableHTML(
945
+ rows: List<List<Triple<CharSequence, Boolean, Layout.Alignment>>>,
946
+ style: StyleConfig,
947
+ scaledDensity: Float = 1f,
948
+ density: Float = 1f,
949
+ ): String {
950
+ if (rows.isEmpty()) return ""
951
+
952
+ val tableStyle = style.tableStyle
953
+ val cachedStyles = CachedStyles(style, scaledDensity, density)
954
+
955
+ val borderColorCSS = colorToCSS(tableStyle.borderColor)
956
+ val borderWidthPx = tableStyle.borderWidth / density
957
+ val borderRadiusPx = tableStyle.borderRadius / density
958
+ val fontSizePx = tableStyle.fontSize / scaledDensity
959
+ val cellPaddingVerticalPx = tableStyle.cellPaddingVertical / density
960
+ val cellPaddingHorizontalPx = tableStyle.cellPaddingHorizontal / density
961
+ val bodyFontWeight = CachedStyles.fontWeightToCSS(tableStyle.fontWeight)
962
+
963
+ return buildString(rows.size * 250) {
964
+ append("<table style=\"border-collapse: separate; border-spacing: 0; ")
965
+ append("border: ${borderWidthPx.toInt()}px solid $borderColorCSS; ")
966
+ append("border-radius: ${borderRadiusPx.toInt()}px; ")
967
+ append("overflow: hidden; font-size: ${fontSizePx.toInt()}px;\">")
968
+
969
+ var hasOpenedBody = false
970
+ var bodyRowIndex = 0
971
+
972
+ for (row in rows) {
973
+ if (row.isEmpty()) continue
974
+
975
+ val isHeaderRow = row.first().second
976
+
977
+ if (isHeaderRow) {
978
+ append("<thead>")
979
+ } else if (!hasOpenedBody) {
980
+ append("<tbody>")
981
+ hasOpenedBody = true
982
+ }
983
+
984
+ append("<tr>")
985
+
986
+ for ((cellText, isHeader, alignment) in row) {
987
+ val backgroundColor =
988
+ when {
989
+ isHeader -> colorToCSS(tableStyle.headerBackgroundColor)
990
+ bodyRowIndex % 2 == 0 -> colorToCSS(tableStyle.rowEvenBackgroundColor)
991
+ else -> colorToCSS(tableStyle.rowOddBackgroundColor)
992
+ }
993
+ val textColor = if (isHeader) colorToCSS(tableStyle.headerTextColor) else colorToCSS(tableStyle.color)
994
+ val textAlignment = alignmentToCSS(alignment)
995
+ val fontWeight = if (isHeader) "bold" else bodyFontWeight
996
+
997
+ val htmlTag = if (isHeader) "th" else "td"
998
+ append("<$htmlTag style=\"")
999
+ append("padding: ${cellPaddingVerticalPx.toInt()}px ${cellPaddingHorizontalPx.toInt()}px; ")
1000
+ append("text-align: $textAlignment; ")
1001
+ append("background-color: $backgroundColor; ")
1002
+ append("color: $textColor; ")
1003
+ append("border: ${borderWidthPx.toInt()}px solid $borderColorCSS; ")
1004
+ append("font-weight: $fontWeight;\">")
1005
+
1006
+ if (cellText.isNotEmpty()) {
1007
+ if (cellText is Spannable) {
1008
+ append(generateInlineHTML(cellText, 0, cellText.length, cachedStyles, false))
1009
+ } else {
1010
+ escapeHTMLTo(this, cellText.toString(), preserveLineBreaks = true)
1011
+ }
1012
+ }
1013
+
1014
+ append("</$htmlTag>")
1015
+ }
1016
+
1017
+ append("</tr>")
1018
+
1019
+ if (isHeaderRow) {
1020
+ append("</thead>")
1021
+ } else {
1022
+ bodyRowIndex++
1023
+ }
1024
+ }
1025
+
1026
+ if (hasOpenedBody) append("</tbody>")
1027
+ append("</table>")
1028
+ }
1029
+ }
1030
+
1031
+ private fun alignmentToCSS(alignment: Layout.Alignment): String =
1032
+ when (alignment) {
1033
+ Layout.Alignment.ALIGN_CENTER -> "center"
1034
+ Layout.Alignment.ALIGN_OPPOSITE -> "right"
1035
+ else -> "left"
1036
+ }
1037
+
1038
+ private fun colorToCSS(color: Int): String {
1039
+ if (color == 0) return "inherit"
1040
+ val alpha = (color shr 24) and 0xFF
1041
+ val red = (color shr 16) and 0xFF
1042
+ val green = (color shr 8) and 0xFF
1043
+ val blue = color and 0xFF
1044
+ return if (alpha < 255) "rgba($red, $green, $blue, ${alpha / 255f})" else String.format("#%02X%02X%02X", red, green, blue)
1045
+ }
1046
+
1047
+ /** Fast HTML escape using single-pass character scanning. */
1048
+ private fun escapeHTMLTo(
1049
+ output: StringBuilder,
1050
+ text: String,
1051
+ preserveLineBreaks: Boolean = false,
1052
+ ) {
1053
+ val len = text.length
1054
+
1055
+ // Fast path: skip if no escaping needed
1056
+ var needsEscape = false
1057
+ for (i in 0 until len) {
1058
+ val c = text[i]
1059
+ if (c == '&' || c == '<' || c == '>' || c == '"' || c == '\'' ||
1060
+ (preserveLineBreaks && (c == '\r' || c == '\n' || c == '\u2028' || c == '\u2029'))
1061
+ ) {
1062
+ needsEscape = true
1063
+ break
1064
+ }
1065
+ }
1066
+ if (!needsEscape) {
1067
+ output.append(text)
1068
+ return
1069
+ }
1070
+
1071
+ var index = 0
1072
+ while (index < len) {
1073
+ val c = text[index++]
1074
+ if (preserveLineBreaks && (c == '\r' || c == '\n' || c == '\u2028' || c == '\u2029')) {
1075
+ if (c == '\r' && index < len && text[index] == '\n') index++
1076
+ output.append("<br>")
1077
+ continue
1078
+ }
1079
+ when (c) {
1080
+ '&' -> output.append("&amp;")
1081
+ '<' -> output.append("&lt;")
1082
+ '>' -> output.append("&gt;")
1083
+ '"' -> output.append("&quot;")
1084
+ '\'' -> output.append("&#39;")
1085
+ else -> output.append(c)
1086
+ }
1087
+ }
1088
+ }
1089
+
1090
+ private fun extractMathLatex(
1091
+ text: android.text.Spannable,
1092
+ start: Int,
1093
+ end: Int,
1094
+ ): String? {
1095
+ return try {
1096
+ val mathInlineSpanClass = Class.forName("com.renderrichtext.spans.MathInlineSpan")
1097
+ val spans = text.getSpans(start, end, mathInlineSpanClass)
1098
+ if (spans.isEmpty()) return null
1099
+ val latexField = mathInlineSpanClass.getDeclaredField("latex").apply { isAccessible = true }
1100
+ latexField.get(spans[0]) as? String
1101
+ } catch (_: Exception) {
1102
+ null
1103
+ }
1104
+ }
1105
+ }