react-native-enriched-markdown-scaffold 0.7.4-scaffold.1 → 0.7.4-scaffold.10

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 (103) hide show
  1. package/android/build.gradle +5 -0
  2. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerDelegate.java +3 -0
  3. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerInterface.java +1 -0
  4. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +5 -0
  5. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +49 -0
  6. package/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +6 -4
  7. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt +20 -0
  8. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +55 -9
  9. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockSyntaxSentinel.kt +69 -0
  10. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt +71 -12
  11. package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputLayoutManager.kt +30 -2
  12. package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt +7 -2
  13. package/android/src/main/java/com/swmansion/enriched/markdown/input/model/MathChipStyle.kt +3 -0
  14. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt +1 -0
  15. package/android/src/main/java/com/swmansion/enriched/markdown/spans/HeadingSpan.kt +8 -1
  16. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LineHeightSpan.kt +27 -1
  17. package/android/src/main/java/com/swmansion/enriched/markdown/styles/InlineMathStyle.kt +5 -1
  18. package/android/src/main/java/com/swmansion/enriched/markdown/styles/MathFontWeight.kt +25 -0
  19. package/android/src/main/java/com/swmansion/enriched/markdown/styles/MathStyle.kt +12 -1
  20. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +7 -7
  21. package/android/src/main/java/com/swmansion/enriched/markdown/utils/input/MarkdownStyleParser.kt +10 -0
  22. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/extensions/SpannableExtensions.kt +12 -4
  23. package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +1 -1
  24. package/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt +2 -0
  25. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathChipSpan.kt +1 -1
  26. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlinePlaceholderSpan.kt +7 -3
  27. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlineSpan.kt +8 -5
  28. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathTypeface.kt +30 -0
  29. package/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +3 -1
  30. package/docs/API_REFERENCE.md +9 -0
  31. package/docs/INPUT.md +48 -0
  32. package/docs/LATEX_MATH.md +62 -0
  33. package/docs/STYLES.md +3 -0
  34. package/ios/EnrichedMarkdown.mm +44 -12
  35. package/ios/EnrichedMarkdownText.mm +47 -11
  36. package/ios/attachments/ENRMMathInlineAttachment+macOS.m +5 -1
  37. package/ios/attachments/ENRMMathInlineAttachment.h +3 -0
  38. package/ios/attachments/ENRMMathInlineAttachment.m +2 -1
  39. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.cpp +5 -0
  40. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +49 -0
  41. package/ios/input/ENRMBlockSyntaxSentinel.h +25 -0
  42. package/ios/input/ENRMBlockSyntaxSentinel.mm +80 -0
  43. package/ios/input/ENRMInputFormatter.h +4 -0
  44. package/ios/input/ENRMInputFormatter.mm +2 -0
  45. package/ios/input/ENRMInputParser.h +5 -0
  46. package/ios/input/ENRMInputParser.mm +79 -6
  47. package/ios/input/EnrichedMarkdownTextInput.mm +55 -22
  48. package/ios/input/InputStylePropsUtils.h +13 -0
  49. package/ios/internals/MeasurementCache.h +1 -0
  50. package/ios/math/ENRMRaTeXBridge.swift +52 -3
  51. package/ios/parser/ENRMMarkdownParser.h +8 -0
  52. package/ios/parser/ENRMMarkdownParser.mm +11 -0
  53. package/ios/renderer/ENRMMathInlineRenderer.m +3 -0
  54. package/ios/renderer/ListItemRenderer.m +8 -5
  55. package/ios/styles/StyleConfig.h +6 -0
  56. package/ios/styles/StyleConfig.mm +36 -0
  57. package/ios/utils/ENRMMathFontWeight.h +18 -0
  58. package/ios/utils/StylePropsUtils.h +21 -0
  59. package/ios/views/ENRMMathContainerView.m +3 -1
  60. package/lib/module/EnrichedMarkdownNativeComponent.ts +3 -0
  61. package/lib/module/EnrichedMarkdownTextInput.js +2 -0
  62. package/lib/module/EnrichedMarkdownTextInput.js.map +1 -1
  63. package/lib/module/EnrichedMarkdownTextInputNativeComponent.ts +12 -0
  64. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +3 -0
  65. package/lib/module/normalizeMarkdownStyle.js +5 -2
  66. package/lib/module/normalizeMarkdownStyle.js.map +1 -1
  67. package/lib/module/normalizeMarkdownStyle.web.js +5 -2
  68. package/lib/module/normalizeMarkdownStyle.web.js.map +1 -1
  69. package/lib/module/normalizeMarkdownTextInputStyle.js +7 -3
  70. package/lib/module/normalizeMarkdownTextInputStyle.js.map +1 -1
  71. package/lib/module/web/globalStyles.js +8 -1
  72. package/lib/module/web/globalStyles.js.map +1 -1
  73. package/lib/module/web/styles.js +3 -1
  74. package/lib/module/web/styles.js.map +1 -1
  75. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +3 -0
  76. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
  77. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts +3 -1
  78. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts.map +1 -1
  79. package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts +12 -0
  80. package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts.map +1 -1
  81. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +3 -0
  82. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  83. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -1
  84. package/lib/typescript/src/normalizeMarkdownStyle.web.d.ts.map +1 -1
  85. package/lib/typescript/src/normalizeMarkdownTextInputStyle.d.ts +1 -0
  86. package/lib/typescript/src/normalizeMarkdownTextInputStyle.d.ts.map +1 -1
  87. package/lib/typescript/src/types/MarkdownStyle.d.ts +32 -0
  88. package/lib/typescript/src/types/MarkdownStyle.d.ts.map +1 -1
  89. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts +3 -0
  90. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts.map +1 -1
  91. package/lib/typescript/src/web/styles.d.ts.map +1 -1
  92. package/package.json +1 -1
  93. package/src/EnrichedMarkdownNativeComponent.ts +3 -0
  94. package/src/EnrichedMarkdownTextInput.tsx +4 -0
  95. package/src/EnrichedMarkdownTextInputNativeComponent.ts +12 -0
  96. package/src/EnrichedMarkdownTextNativeComponent.ts +3 -0
  97. package/src/normalizeMarkdownStyle.ts +6 -1
  98. package/src/normalizeMarkdownStyle.web.ts +6 -1
  99. package/src/normalizeMarkdownTextInputStyle.ts +5 -0
  100. package/src/types/MarkdownStyle.ts +32 -0
  101. package/src/types/MarkdownStyleInternal.ts +3 -0
  102. package/src/web/globalStyles.ts +7 -0
  103. package/src/web/styles.ts +5 -1
@@ -10,6 +10,8 @@ import com.facebook.react.uimanager.PixelUtil
10
10
  import com.facebook.yoga.YogaMeasureMode
11
11
  import com.facebook.yoga.YogaMeasureOutput
12
12
  import com.swmansion.enriched.markdown.input.formatting.InputParser
13
+ import com.swmansion.enriched.markdown.utils.common.getBooleanOrDefault
14
+ import com.swmansion.enriched.markdown.utils.common.getFloatOrDefault
13
15
  import java.util.concurrent.ConcurrentHashMap
14
16
 
15
17
  object InputMeasurementStore {
@@ -99,8 +101,11 @@ object InputMeasurementStore {
99
101
  // invisible characters and over-estimates the height into extra lines, until the next text
100
102
  // change forces a re-measure. Parsing first matches what the editor actually renders.
101
103
  val markdown = props?.getString("defaultValue") ?: props?.getString("placeholder") ?: "I"
102
- val text = InputParser.parseToPlainTextAndRanges(markdown).plainText
103
- val fontSize = props?.getDouble("fontSize")?.toFloat() ?: 16f
104
+ // Must read the same flag the view parses with, or the measured height and the
105
+ // rendered height disagree: `blockSyntax = false` keeps list markers and `#` as text.
106
+ val blockSyntax = props.getBooleanOrDefault("blockSyntax", true)
107
+ val text = InputParser.parseToPlainTextAndRanges(markdown, blockSyntax).plainText
108
+ val fontSize = props.getFloatOrDefault("fontSize", 16f)
104
109
  val spSize = kotlin.math.ceil(PixelUtil.toPixelFromSP(fontSize).toDouble()).toFloat()
105
110
 
106
111
  val defaultView = AppCompatEditText(context)
@@ -13,6 +13,7 @@ open class MathChipStyle(
13
13
  val paddingVertical: Float,
14
14
  val selectedBackgroundColor: Int?,
15
15
  val selectedBorderColor: Int?,
16
+ val fontWeight: String = "normal",
16
17
  ) {
17
18
  open fun isBlock(): Boolean = false
18
19
  }
@@ -28,6 +29,7 @@ class MathBlockStyle(
28
29
  paddingVertical: Float,
29
30
  selectedBackgroundColor: Int?,
30
31
  selectedBorderColor: Int?,
32
+ fontWeight: String = "normal",
31
33
  val marginTop: Float,
32
34
  val marginBottom: Float,
33
35
  val alignment: Layout.Alignment,
@@ -42,6 +44,7 @@ class MathBlockStyle(
42
44
  paddingVertical,
43
45
  selectedBackgroundColor,
44
46
  selectedBorderColor,
47
+ fontWeight,
45
48
  ) {
46
49
  override fun isBlock(): Boolean = true
47
50
  }
@@ -40,6 +40,7 @@ class HeadingRenderer(
40
40
  HeadingSpan(
41
41
  level,
42
42
  config.style,
43
+ factory.styleCache,
43
44
  ),
44
45
  start,
45
46
  end,
@@ -4,11 +4,14 @@ import android.annotation.SuppressLint
4
4
  import android.graphics.Typeface
5
5
  import android.text.TextPaint
6
6
  import android.text.style.MetricAffectingSpan
7
+ import com.swmansion.enriched.markdown.renderer.SpanStyleCache
7
8
  import com.swmansion.enriched.markdown.styles.StyleConfig
9
+ import com.swmansion.enriched.markdown.utils.text.extensions.applyColorPreserving
8
10
 
9
11
  class HeadingSpan(
10
12
  val level: Int,
11
13
  styleConfig: StyleConfig,
14
+ private val styleCache: SpanStyleCache,
12
15
  ) : MetricAffectingSpan() {
13
16
  private val fontSize: Float = styleConfig.headingStyles[level]!!.fontSize
14
17
  private val color: Int = styleConfig.headingStyles[level]!!.color
@@ -16,7 +19,11 @@ class HeadingSpan(
16
19
 
17
20
  override fun updateDrawState(tp: TextPaint) {
18
21
  applyHeadingStyle(tp)
19
- tp.color = color
22
+ // Inline spans (link, code, strong/em with their own color) are attached to the range
23
+ // BEFORE this block span, and TextLine replays every CharacterStyle in insertion order,
24
+ // so an unconditional `tp.color = color` here would repaint them. Same guard used by
25
+ // BlockquoteSpan, BaseListSpan, CodeBlockSpan, StrongSpan and EmphasisSpan.
26
+ tp.applyColorPreserving(color, *styleCache.colorsToPreserve)
20
27
  }
21
28
 
22
29
  override fun updateMeasureState(tp: TextPaint) {
@@ -1,6 +1,8 @@
1
1
  package com.swmansion.enriched.markdown.spans
2
2
 
3
3
  import android.graphics.Paint
4
+ import android.text.Spanned
5
+ import android.text.style.ReplacementSpan
4
6
  import kotlin.math.ceil
5
7
  import kotlin.math.floor
6
8
  import android.text.style.LineHeightSpan as AndroidLineHeightSpan
@@ -18,10 +20,34 @@ class LineHeightSpan(
18
20
  v: Int,
19
21
  fm: Paint.FontMetricsInt?,
20
22
  ) {
21
- if (fm == null) return
23
+ if (fm == null || lineHeight <= 0) return
22
24
 
23
25
  val leading = lineHeight - ((-fm.ascent) + fm.descent)
26
+
27
+ // A ReplacementSpan on this line — inline math, or the placeholder MeasurementStore
28
+ // swaps in to measure it off the main thread — reports its own metrics from getSize().
29
+ // Shrinking those back down to the configured line height cuts the line box below what
30
+ // the span actually draws, and can drive descent negative so the next line starts above
31
+ // this one's baseline. Those lines are expanded only, never compressed; every other line
32
+ // keeps the exact clamp it had before. The lookup only runs on the rare negative-leading
33
+ // path, so the common case stays allocation-free.
34
+ if (leading <= 0 && hasSelfMeasuringSpan(text, start, end)) return
35
+
24
36
  fm.ascent -= ceil(leading / 2.0f).toInt()
25
37
  fm.descent += floor(leading / 2.0f).toInt()
26
38
  }
39
+
40
+ /**
41
+ * Images are excluded on purpose. Block images already keep this span off their range via
42
+ * `applyLineHeightSkippingImages`, and inline ones never touch the metrics at all, so
43
+ * letting them through here would change image layout instead of math layout.
44
+ */
45
+ private fun hasSelfMeasuringSpan(
46
+ text: CharSequence?,
47
+ start: Int,
48
+ end: Int,
49
+ ): Boolean {
50
+ val spanned = text as? Spanned ?: return false
51
+ return spanned.getSpans(start, end, ReplacementSpan::class.java).any { it !is ImageSpan }
52
+ }
27
53
  }
@@ -4,6 +4,8 @@ import com.facebook.react.bridge.ReadableMap
4
4
 
5
5
  data class InlineMathStyle(
6
6
  val color: Int,
7
+ val paddingVertical: Float,
8
+ val fontWeight: String,
7
9
  ) {
8
10
  companion object {
9
11
  fun fromReadableMap(
@@ -11,7 +13,9 @@ data class InlineMathStyle(
11
13
  parser: StyleParser,
12
14
  ): InlineMathStyle {
13
15
  val color = parser.parseColor(map, "color")
14
- return InlineMathStyle(color)
16
+ val paddingVertical = parser.toPixelFromDIP(map.getDouble("paddingVertical").toFloat())
17
+ val fontWeight = parser.parseString(map, "fontWeight", "normal")
18
+ return InlineMathStyle(color, paddingVertical, fontWeight)
15
19
  }
16
20
  }
17
21
  }
@@ -0,0 +1,25 @@
1
+ package com.swmansion.enriched.markdown.styles
2
+
3
+ /**
4
+ * Weight a formula can actually be typeset at.
5
+ *
6
+ * The style model keeps `fontWeight` as a [String], the way every other
7
+ * `fontWeight` prop in the codebase does, so a value like `"600"` survives as
8
+ * far as the renderer. This enum is the narrowing step at that last hop — see
9
+ * `ratexTypefaceLoader`, the only caller — so adding a third weight is a
10
+ * compile error at the draw site rather than a value that silently falls back
11
+ * to regular. Mirrors ENRMMathFontWeight.h on iOS.
12
+ */
13
+ enum class MathFontWeight {
14
+ NORMAL,
15
+ BOLD,
16
+ ;
17
+
18
+ companion object {
19
+ fun fromString(value: String): MathFontWeight =
20
+ when (value.lowercase()) {
21
+ "bold" -> BOLD
22
+ else -> NORMAL
23
+ }
24
+ }
25
+ }
@@ -10,6 +10,7 @@ data class MathStyle(
10
10
  val marginTop: Float,
11
11
  val marginBottom: Float,
12
12
  val textAlign: String,
13
+ val fontWeight: String,
13
14
  ) {
14
15
  companion object {
15
16
  fun fromReadableMap(
@@ -23,7 +24,17 @@ data class MathStyle(
23
24
  val marginTop = parser.toPixelFromDIP(map.getDouble("marginTop").toFloat())
24
25
  val marginBottom = parser.toPixelFromDIP(map.getDouble("marginBottom").toFloat())
25
26
  val textAlign = parser.parseString(map, "textAlign", "center")
26
- return MathStyle(fontSize, color, backgroundColor, padding, marginTop, marginBottom, textAlign)
27
+ val fontWeight = parser.parseString(map, "fontWeight", "normal")
28
+ return MathStyle(
29
+ fontSize,
30
+ color,
31
+ backgroundColor,
32
+ padding,
33
+ marginTop,
34
+ marginBottom,
35
+ textAlign,
36
+ fontWeight,
37
+ )
27
38
  }
28
39
  }
29
40
  }
@@ -82,11 +82,11 @@ fun emitContextMenuItemPress(
82
82
 
83
83
  fun parseMd4cFlags(flags: ReadableMap?): Md4cFlags =
84
84
  Md4cFlags(
85
- underline = flags?.getBoolean("underline") ?: false,
86
- latexMath = FeatureFlags.IS_MATH_ENABLED && (flags?.getBoolean("latexMath") ?: true),
87
- superscript = flags?.getBoolean("superscript") ?: false,
88
- subscript = flags?.getBoolean("subscript") ?: false,
89
- highlight = flags?.getBoolean("highlight") ?: false,
85
+ underline = flags.getBooleanOrDefault("underline", false),
86
+ latexMath = FeatureFlags.IS_MATH_ENABLED && flags.getBooleanOrDefault("latexMath", true),
87
+ superscript = flags.getBooleanOrDefault("superscript", false),
88
+ subscript = flags.getBooleanOrDefault("subscript", false),
89
+ highlight = flags.getBooleanOrDefault("highlight", false),
90
90
  )
91
91
 
92
92
  fun parseContextMenuItems(value: ReadableArray?): List<String> =
@@ -95,8 +95,8 @@ fun parseContextMenuItems(value: ReadableArray?): List<String> =
95
95
  fun parseSelectionMenuConfig(value: ReadableMap?): SelectionMenuConfig {
96
96
  if (value == null) return SelectionMenuConfig()
97
97
  return SelectionMenuConfig(
98
- copyAsMarkdown = value.getBoolean("copyAsMarkdown"),
99
- copyImageUrl = value.getBoolean("copyImageUrl"),
98
+ copyAsMarkdown = value.getBooleanOrDefault("copyAsMarkdown", true),
99
+ copyImageUrl = value.getBooleanOrDefault("copyImageUrl", true),
100
100
  copyLabel = value.getString("copyLabel") ?: "",
101
101
  copyAsMarkdownLabel = value.getString("copyAsMarkdownLabel") ?: "",
102
102
  copyImageUrlLabel = value.getString("copyImageUrlLabel") ?: "",
@@ -76,6 +76,8 @@ object MarkdownStyleParser {
76
76
  if (mathMap?.hasKey("selectedBackgroundColor") == true) mathMap.getInt("selectedBackgroundColor") else null
77
77
  val selectedBorderColor =
78
78
  if (mathMap?.hasKey("selectedBorderColor") == true) mathMap.getInt("selectedBorderColor") else null
79
+ val fontWeight =
80
+ (if (mathMap?.hasKey("fontWeight") == true) mathMap.getString("fontWeight") else null) ?: "normal"
79
81
 
80
82
  return MathChipStyle(
81
83
  color = color,
@@ -88,6 +90,7 @@ object MarkdownStyleParser {
88
90
  paddingVertical = paddingVertical,
89
91
  selectedBackgroundColor = nullIfTransparent(selectedBackgroundColor),
90
92
  selectedBorderColor = nullIfTransparent(selectedBorderColor),
93
+ fontWeight = fontWeight,
91
94
  )
92
95
  }
93
96
 
@@ -143,6 +146,12 @@ object MarkdownStyleParser {
143
146
  } else {
144
147
  inlineFallback.selectedBorderColor
145
148
  }
149
+ val fontWeight =
150
+ if (mathBlockMap?.hasKey("fontWeight") == true) {
151
+ mathBlockMap.getString("fontWeight") ?: "normal"
152
+ } else {
153
+ inlineFallback.fontWeight
154
+ }
146
155
  val marginTop =
147
156
  PixelUtil.toPixelFromDIP(
148
157
  if (mathBlockMap?.hasKey("marginTop") == true) mathBlockMap.getDouble("marginTop").toFloat() else 8f,
@@ -170,6 +179,7 @@ object MarkdownStyleParser {
170
179
  paddingVertical = paddingVertical,
171
180
  selectedBackgroundColor = nullIfTransparent(selectedBackgroundColor),
172
181
  selectedBorderColor = nullIfTransparent(selectedBorderColor),
182
+ fontWeight = fontWeight,
173
183
  marginTop = marginTop,
174
184
  marginBottom = marginBottom,
175
185
  alignment = alignment,
@@ -14,8 +14,16 @@ fun SpannableStringBuilder.isInlineImage(): Boolean {
14
14
  return lastChar != '\n' && lastChar != '\u200B'
15
15
  }
16
16
 
17
- /** Swaps MathInlineSpans for MathInlinePlaceholderSpans safe for background-thread measurement. */
18
- fun SpannableString.replaceMathSpansWithPlaceholders(context: Context) {
17
+ /**
18
+ * Swaps MathInlineSpans for MathInlinePlaceholderSpans safe for background-thread measurement.
19
+ *
20
+ * [paddingVertical] must be the same `inlineMath.paddingVertical` (in px) the real spans were
21
+ * built with, or the measured line box won't match the drawn one.
22
+ */
23
+ fun SpannableString.replaceMathSpansWithPlaceholders(
24
+ context: Context,
25
+ paddingVertical: Float,
26
+ ) {
19
27
  if (!FeatureFlags.IS_MATH_ENABLED) return
20
28
 
21
29
  try {
@@ -42,7 +50,7 @@ fun SpannableString.replaceMathSpansWithPlaceholders(context: Context) {
42
50
  @Suppress("UNCHECKED_CAST")
43
51
  val results = measureMethod.invoke(null, context, requests) as? List<MathMetrics> ?: return
44
52
 
45
- val placeholderCtor = placeholderClass.getConstructor(MathMetrics::class.java)
53
+ val placeholderCtor = placeholderClass.getConstructor(MathMetrics::class.java, Float::class.javaPrimitiveType)
46
54
 
47
55
  mathSpans.forEachIndexed { i, oldSpan ->
48
56
  val metrics = results.getOrNull(i) ?: return@forEachIndexed
@@ -53,7 +61,7 @@ fun SpannableString.replaceMathSpansWithPlaceholders(context: Context) {
53
61
 
54
62
  if (start != -1 && end != -1) {
55
63
  removeSpan(oldSpan)
56
- val newSpan = placeholderCtor.newInstance(metrics)
64
+ val newSpan = placeholderCtor.newInstance(metrics, paddingVertical)
57
65
  setSpan(newSpan, start, end, flags)
58
66
  }
59
67
  }
@@ -456,7 +456,7 @@ class TableContainerView(
456
456
  .apply { configure(config, context) }
457
457
  .renderDocument(MarkdownASTNode(NodeType.Document, children = listOf(paragraph)), null, null)
458
458
  }
459
- styledText.replaceMathSpansWithPlaceholders(context)
459
+ styledText.replaceMathSpansWithPlaceholders(context, config.inlineMathStyle.paddingVertical)
460
460
  if (isHeader && styledText.isNotEmpty()) {
461
461
  styledText.setSpan(HeaderTypefaceSpan(headerTypeface), 0, styledText.length, 33)
462
462
  }
@@ -35,6 +35,8 @@ class MathInlineRenderer(
35
35
  latex = latex,
36
36
  fontSize = blockStyle.fontSize,
37
37
  textColor = config.style.inlineMathStyle.color,
38
+ paddingVertical = config.style.inlineMathStyle.paddingVertical,
39
+ fontWeight = config.style.inlineMathStyle.fontWeight,
38
40
  )
39
41
 
40
42
  builder.setSpan(span, start, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE)
@@ -66,7 +66,7 @@ class MathChipSpan(
66
66
  displayMode = displayMode,
67
67
  color = style.color,
68
68
  )
69
- val renderer = RaTeXRenderer(displayList, style.fontSize) { RaTeXFontLoader.getTypeface(it) }
69
+ val renderer = RaTeXRenderer(displayList, style.fontSize, ratexTypefaceLoader(style.fontWeight))
70
70
  formulaWidth = renderer.widthPx
71
71
  formulaAscent = renderer.heightPx
72
72
  formulaDescent = renderer.depthPx
@@ -3,11 +3,12 @@ package com.swmansion.enriched.markdown.spans
3
3
  import android.graphics.Canvas
4
4
  import android.graphics.Paint
5
5
  import android.text.style.ReplacementSpan
6
- import kotlin.math.roundToInt
6
+ import kotlin.math.ceil
7
7
 
8
8
  /** Holds pre-computed math metrics for background-thread measurement. */
9
9
  class MathInlinePlaceholderSpan(
10
10
  private val metrics: MathMetrics,
11
+ private val paddingVertical: Float,
11
12
  ) : ReplacementSpan() {
12
13
  override fun getSize(
13
14
  paint: Paint,
@@ -17,9 +18,12 @@ class MathInlinePlaceholderSpan(
17
18
  fm: Paint.FontMetricsInt?,
18
19
  ): Int {
19
20
  fm?.apply {
20
- ascent = -metrics.ascent.roundToInt()
21
+ // Must stay identical to MathInlineSpan.getSize — this span stands in for it
22
+ // during ShadowNode measurement, so any divergence shows up as a clipped or
23
+ // over-tall view. Same ceil, same padding, same expression.
24
+ ascent = -ceil(metrics.ascent + paddingVertical).toInt()
21
25
  top = ascent
22
- descent = metrics.descent.roundToInt()
26
+ descent = ceil(metrics.descent + paddingVertical).toInt()
23
27
  bottom = descent
24
28
  }
25
29
  return metrics.width
@@ -6,7 +6,6 @@ import android.graphics.Canvas
6
6
  import android.graphics.Paint
7
7
  import android.text.style.ReplacementSpan
8
8
  import io.ratex.RaTeXEngine
9
- import io.ratex.RaTeXFontLoader
10
9
  import io.ratex.RaTeXRenderer
11
10
  import kotlin.math.ceil
12
11
 
@@ -15,6 +14,8 @@ class MathInlineSpan(
15
14
  internal val latex: String,
16
15
  internal val fontSize: Float,
17
16
  private val textColor: Int,
17
+ private val paddingVertical: Float,
18
+ private val fontWeight: String = "normal",
18
19
  ) : ReplacementSpan() {
19
20
  private var cachedBitmap: Bitmap? = null
20
21
  private var cachedWidth = 0
@@ -28,7 +29,7 @@ class MathInlineSpan(
28
29
 
29
30
  try {
30
31
  val displayList = RaTeXEngine.parseBlocking(latex, displayMode = false, color = textColor)
31
- val renderer = RaTeXRenderer(displayList, fontSize) { RaTeXFontLoader.getTypeface(it) }
32
+ val renderer = RaTeXRenderer(displayList, fontSize, ratexTypefaceLoader(fontWeight))
32
33
 
33
34
  cachedWidth = renderer.widthPx.toInt().coerceAtLeast(1)
34
35
  mathAscent = renderer.heightPx
@@ -62,10 +63,12 @@ class MathInlineSpan(
62
63
  prepareResources()
63
64
 
64
65
  fm?.apply {
65
- val ascentPx = ceil(mathAscent).toInt()
66
- ascent = -ascentPx
66
+ // Must stay identical to MathInlinePlaceholderSpan.getSize: that span measures
67
+ // this one off the main thread, and a box that disagrees by even a pixel either
68
+ // clips the formula or eats the padding this field is here to add.
69
+ ascent = -ceil(mathAscent + paddingVertical).toInt()
67
70
  top = ascent
68
- descent = (cachedBitmap?.height ?: ceil(mathAscent + mathDescent).toInt()) - ascentPx
71
+ descent = ceil(mathDescent + paddingVertical).toInt()
69
72
  bottom = descent
70
73
  }
71
74
 
@@ -0,0 +1,30 @@
1
+ package com.swmansion.enriched.markdown.spans
2
+
3
+ import android.graphics.Typeface
4
+ import com.swmansion.enriched.markdown.styles.MathFontWeight
5
+ import io.ratex.RaTeXFontLoader
6
+
7
+ /**
8
+ * Typeface supplier for [io.ratex.RaTeXRenderer]. RaTeX resolves every glyph to
9
+ * a KaTeX font id and asks us for the face to draw it with.
10
+ *
11
+ * Takes the raw `fontWeight` string the style model carries — the same shape
12
+ * every other `fontWeight` prop keeps — and is the single place on Android that
13
+ * narrows it to a weight we can draw. Consumers pass the style through; nothing
14
+ * upstream has to know that only two weights are reachable today.
15
+ *
16
+ * Bold is SYNTHETIC: asking for [Typeface.BOLD] on a single-weight asset makes
17
+ * the platform embolden it, which preserves the advances RaTeX already laid the
18
+ * glyphs out with. Substituting the bundled KaTeX bold faces instead would not
19
+ * — their advances run up to 15% wider than the regular ones the layout
20
+ * reserved, so glyphs would collide. iOS reaches the same result by stroking
21
+ * the glyph outline; see ENRMRaTeXBridge.
22
+ */
23
+ internal fun ratexTypefaceLoader(fontWeight: String): (String) -> Typeface? =
24
+ when (MathFontWeight.fromString(fontWeight)) {
25
+ MathFontWeight.NORMAL -> { fontId -> RaTeXFontLoader.getTypeface(fontId) }
26
+
27
+ MathFontWeight.BOLD -> { fontId ->
28
+ RaTeXFontLoader.getTypeface(fontId)?.let { Typeface.create(it, Typeface.BOLD) }
29
+ }
30
+ }
@@ -15,6 +15,7 @@ import com.swmansion.enriched.markdown.accessibility.AccessibilityLabels
15
15
  import com.swmansion.enriched.markdown.spans.MathMeasureHelper
16
16
  import com.swmansion.enriched.markdown.spans.MathMeasureRequest
17
17
  import com.swmansion.enriched.markdown.spans.MathRenderMode
18
+ import com.swmansion.enriched.markdown.spans.ratexTypefaceLoader
18
19
  import com.swmansion.enriched.markdown.styles.MathStyle
19
20
  import com.swmansion.enriched.markdown.styles.StyleConfig
20
21
  import io.ratex.RaTeXEngine
@@ -100,7 +101,8 @@ class MathContainerView(
100
101
  cachedLatex = latex
101
102
  try {
102
103
  val displayList = RaTeXEngine.parseBlocking(latex, displayMode = true, color = mathStyle.color)
103
- mathView.renderer = RaTeXRenderer(displayList, mathStyle.fontSize) { RaTeXFontLoader.getTypeface(it) }
104
+ mathView.renderer =
105
+ RaTeXRenderer(displayList, mathStyle.fontSize, ratexTypefaceLoader(mathStyle.fontWeight))
104
106
  } catch (e: Exception) {
105
107
  Log.e(TAG, "Failed to render LaTeX", e)
106
108
  mathView.renderer = null
@@ -515,6 +515,15 @@ Whether the input is scrollable when content exceeds the visible area.
515
515
  | --------- | ------------- | -------- |
516
516
  | `boolean` | `true` | Both |
517
517
 
518
+ ### `blockSyntax`
519
+
520
+ Whether block-level Markdown syntax is interpreted. See
521
+ [Preserving block syntax](./INPUT.md#preserving-block-syntax).
522
+
523
+ | Type | Default Value | Platform |
524
+ | --------- | ------------- | -------- |
525
+ | `boolean` | `true` | Both |
526
+
518
527
  ### `autoCapitalize`
519
528
 
520
529
  Auto-capitalization behavior.
package/docs/INPUT.md CHANGED
@@ -122,6 +122,50 @@ The callback fires only for newly detected links — not for links that were alr
122
122
 
123
123
  When a manual link is applied (via `setLink` or `insertLink`) over an auto-detected link, the auto-detected link is replaced by the manual one. Auto-link detection skips ranges that already contain a manual link.
124
124
 
125
+ ## Preserving block syntax
126
+
127
+ By default the input interprets block-level Markdown: a line starting with `1) `, `- `,
128
+ `# ` or `> ` is consumed as list, heading or quote *structure*, and the marker itself is
129
+ not part of the text. The editor has no block styles to render that structure with and
130
+ the serializer has no way to write the marker back, so the marker is lost on save. Two
131
+ adjacent list items can end up with their formulas touching — `1) $a+b$` and `2) $c+d$`
132
+ save as `$a+b$$c+d$`, where the `$$` now reads as display math.
133
+
134
+ Set `blockSyntax={false}` when the text must survive the round trip unchanged:
135
+
136
+ ```tsx
137
+ <EnrichedMarkdownTextInput
138
+ blockSyntax={false}
139
+ defaultValue={markdown}
140
+ onChangeMarkdown={setMarkdown}
141
+ />
142
+ ```
143
+
144
+ In this mode the parser never sees block structure, so markers stay in the text verbatim
145
+ and `onChangeMarkdown` returns what was loaded, byte for byte. Inline syntax is unaffected:
146
+ `**bold**`, `*italic*`, `~~strike~~`, `||spoiler||`, links and `$math$` all still parse and
147
+ style as usual.
148
+
149
+ What changes:
150
+
151
+ - **Typing `3) ` no longer starts a list**, and `# ` no longer makes a heading. Nothing
152
+ block-level is interpreted while typing either — that is the point of the mode.
153
+ - The text is displayed with its markers showing, so an editor with `blockSyntax={false}`
154
+ and a `EnrichedMarkdownText` rendering the same source look different, even though the
155
+ stored string is identical.
156
+ - `\r\n` and `\r` are normalized to `\n`, and `U+000B` (vertical tab) is stripped from the
157
+ input. `U+000B` is used internally to hide line structure from the Markdown parser, so it
158
+ cannot be carried through as content.
159
+
160
+ Three inline constructs are still lossy in this mode, exactly as they are with
161
+ `blockSyntax={true}` — they are not regressions introduced by the flag:
162
+
163
+ | Input | Saved as | Why |
164
+ | --- | --- | --- |
165
+ | `` gõ `x` vào `` | `gõ x vào` | The editor has no inline code style, so the backticks have nothing to round-trip through |
166
+ | `Giá 5\$` | `Giá 5$` | The serializer does not re-escape `\$`, which can leave an unpaired `$` |
167
+ | `A &amp;amp; B` | `A B` | HTML entity nodes are dropped. A raw `&` is safe |
168
+
125
169
  ## Caret Position Tracking
126
170
 
127
171
  `EnrichedMarkdownTextInput` can report the caret's pixel position relative to the input, which is useful when the input is embedded in a scrollable container with `scrollEnabled={false}` and you need to keep the caret visible.
@@ -231,3 +275,7 @@ Available style properties:
231
275
  - `link.underline` — whether links are underlined (defaults to `true`).
232
276
  - `spoiler.color` — text color for spoiler text.
233
277
  - `spoiler.backgroundColor` — background color for spoiler text.
278
+ - `math.fontWeight` — `'bold'` | `'normal'` (default `'normal'`); typesets inline formula chips
279
+ (`$...$`) at bold weight. See [LATEX_MATH.md](./LATEX_MATH.md#bold-formulas).
280
+ - `mathBlock.fontWeight` — same for block formula chips (`$$...$$`); inherits from
281
+ `math.fontWeight` when unset.
@@ -27,6 +27,7 @@ Einstein's mass-energy equivalence $E = mc^2$ is one of the most famous equation
27
27
  },
28
28
  inlineMath: {
29
29
  color: '#1F2937',
30
+ paddingVertical: 4,
30
31
  },
31
32
  }}
32
33
  />
@@ -34,6 +35,62 @@ Einstein's mass-energy equivalence $E = mc^2$ is one of the most famous equation
34
35
 
35
36
  Block math equations are rendered as standalone display elements with spacing and an optional background. Inline math inherits the surrounding block's typography.
36
37
 
38
+ ### Spacing around inline formulas
39
+
40
+ An inline formula reports its own height, and tall constructs such as `\frac` are routinely taller than the
41
+ surrounding text. The block's `lineHeight` cannot loosen those lines: it is applied expand-only, so a value
42
+ below the formula's natural height is ignored rather than compressing the formula out of its box. Two wrapped
43
+ lines that each contain a fraction therefore sit flush against each other.
44
+
45
+ `inlineMath.paddingVertical` (points, default `4`) adds empty space above and below the formula box, which is
46
+ what pushes those lines apart. It affects only lines that actually contain inline math — prose-only lines keep
47
+ the line height they had. Set it to `0` to get the old flush behaviour back. Block math (`$$...$$`) has its own
48
+ `math.padding` instead.
49
+
50
+ ### Bold formulas
51
+
52
+ `math.fontWeight` / `inlineMath.fontWeight` (`'normal'` | `'bold'`, default `'normal'`) typeset a
53
+ formula at bold weight. Set it wherever the surrounding text is bold so the formula follows it.
54
+
55
+ The embolden is **synthetic on every platform** — no real bold face is ever used.
56
+
57
+ On **iOS and Android** it is a stroke on the glyph outline and the platform's fake-bold
58
+ respectively, rather than a switch to the bundled KaTeX bold faces. That is deliberate. The native
59
+ engine has no weight parameter, and by the time a style applies, glyph positions are already laid
60
+ out: the bold faces' advances run up to 15% wider than the regular ones the layout reserved, so
61
+ swapping them in would make glyphs collide. Stroking and fake-bold both preserve every advance.
62
+
63
+ Two consequences worth knowing:
64
+
65
+ - Fraction bars and rules keep their own thickness, so they do not thicken with the glyphs.
66
+ - On iOS the stroke width also applies to the handful of decorations the engine strokes itself
67
+ (`\cancel`, `\textcircled`, `\corresponds`, `\imageof`). Prefer turning bold on per surface
68
+ rather than globally.
69
+
70
+ On **web** the prop is a plain CSS declaration on the KaTeX output — but the library renders in
71
+ [MathML output mode](#web) and loads no KaTeX stylesheet or fonts, so `font-weight` is resolved by
72
+ the browser's own math font (STIX Two Math, Latin Modern Math, Cambria Math). Most of those ship no
73
+ bold face, so the browser synthesizes one there too. Treat web as synthetic as well, not as a
74
+ reference for how heavy bold "should" look.
75
+
76
+ `EnrichedMarkdownTextInput` honours the same prop for the formula chips typed into the editor, under
77
+ its own style keys — `math.fontWeight` for inline chips (`$...$`) and `mathBlock.fontWeight` for
78
+ block chips (`$$...$$`). `mathBlock` inherits the weight from `math` when it does not set one of its
79
+ own, the way it already inherits `color` and the border props:
80
+
81
+ ```tsx
82
+ <EnrichedMarkdownTextInput
83
+ markdownStyle={{
84
+ math: { fontWeight: 'bold' }, // inline chips bold…
85
+ mathBlock: { fontWeight: 'normal' }, // …block chips opt back out
86
+ }}
87
+ />
88
+ ```
89
+
90
+ The caveats above apply unchanged — the embolden is the same synthetic one, and the chip keeps its
91
+ measured width because fake-bold and stroking both preserve the glyph advances. Editor chips are
92
+ iOS + Android only; there is no web or macOS chip surface.
93
+
37
94
  > [!IMPORTANT]
38
95
  > LaTeX commands use backslashes (e.g. `\frac`, `\alpha`). In regular JS strings and template literals, backslashes are escape characters. Use `String.raw` or double backslashes (`\\frac`) to preserve them. Block math (`$$...$$`) must be on its own line to render as a display element.
39
96
 
@@ -53,6 +110,11 @@ yarn add katex
53
110
 
54
111
  KaTeX is loaded lazily the first time a LaTeX node is encountered, so it has no impact on pages that do not render math. No stylesheet or `<link>` tag is needed.
55
112
 
113
+ If you load `katex/dist/katex.min.css` anyway — some setups do — its `.katex` rule opens with the
114
+ `font` shorthand, which resets `font-weight` and would drop [`fontWeight`](#bold-formulas) on the
115
+ way down to the formula. The library injects `.katex { font-weight: inherit; }` to keep the prop
116
+ working in that case; nothing is required of you.
117
+
56
118
  > [!NOTE]
57
119
  > MathML is supported natively in Chrome 109+, Firefox, and Safari. Older browsers will display the raw LaTeX source as a text fallback.
58
120
 
package/docs/STYLES.md CHANGED
@@ -179,9 +179,12 @@ The library provides sensible default styles for all Markdown elements out of th
179
179
  padding: 12,
180
180
  marginBottom: 16,
181
181
  textAlign: 'center',
182
+ fontWeight: 'normal',
182
183
  },
183
184
  inlineMath: {
184
185
  color: '#1F2937',
186
+ paddingVertical: 4,
187
+ fontWeight: 'normal',
185
188
  },
186
189
  spoiler: {
187
190
  color: '#6B7280',