Skip to content

Commit ee5cf96

Browse files
authored
feat: add support for LaTeX math rendering in markdown (#119)
* feat: add support for LaTeX math rendering in markdown * feat: enhance markdown extraction to support LaTeX math rendering in both Android and iOS * feat: add context menu for copying LaTeX and Markdown in MathContainerView for Android and iOS * feat: add support for LaTeX math rendering in HTML generation for Android and iOS * docs: update README and documentation to include LaTeX math rendering details and styling options * feat: add MathInlineRenderer, MathInlineSpan, InlineMathStyle, and MathStyle to support LaTeX * chore: remove sampleMarkdownLatex file * docs: clarify color property description in LaTeX math styling documentation * refactor(ios): simplify latex extraction check in ENRMMathInlineRenderer * fix(ios): update accessibility label to use cached LaTeX in ENRMMathContainerView * refactor(ios): remove unused context menu pragma
1 parent 6747e87 commit ee5cf96

41 files changed

Lines changed: 1203 additions & 42 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- 🎯 High-performance Markdown parsing with [md4c](https://github.com/mity/md4c)
99
- 📐 CommonMark standard compliant
1010
- 📊 GitHub Flavored Markdown (GFM)
11+
- 🧮 LaTeX math rendering (block `$$...$$` with `flavor="github"`, inline `$...$` in all flavors)
1112
- 🎨 Fully customizable styles for all elements
1213
- 📱 iOS and Android support
1314
- 🏛 Supports only the New Architecture (Fabric)
@@ -167,6 +168,38 @@ Task lists with interactive checkboxes are available when `flavor="github"` is s
167168
/>
168169
```
169170

171+
### LaTeX Math
172+
173+
LaTeX math rendering is supported for both block and inline equations:
174+
175+
- **Block math (`$$...$$`)**: Rendered as a standalone display element. Requires `flavor="github"`.
176+
- **Inline math (`$...$`)**: Rendered within the text flow. Works with both `flavor="commonmark"` and `flavor="github"`.
177+
178+
```tsx
179+
<EnrichedMarkdownText
180+
flavor="github"
181+
markdown={`
182+
The quadratic formula: $$x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$$
183+
184+
Einstein's mass-energy equivalence $E = mc^2$ is one of the most famous equations.
185+
`}
186+
markdownStyle={{
187+
math: {
188+
fontSize: 20,
189+
color: '#1F2937',
190+
backgroundColor: '#F3F4F6',
191+
padding: 12,
192+
textAlign: 'center',
193+
},
194+
inlineMath: {
195+
color: '#1F2937',
196+
},
197+
}}
198+
/>
199+
```
200+
201+
Block math equations are rendered as standalone display elements with spacing and an optional background. Inline math inherits the surrounding block's typography.
202+
170203
### Link Handling
171204

172205
Links in Markdown are interactive and can be handled with the `onLinkPress` and `onLinkLongPress` callbacks:
@@ -204,10 +237,8 @@ See the [API Reference](docs/API_REFERENCE.md) for a detailed overview of all th
204237

205238
We're actively working on expanding the capabilities of `react-native-enriched-markdown`. Here's what's on the roadmap:
206239

207-
- GFM (GitHub Flavored Markdown) Support
208-
- LaTeX / Math Rendering
209240
- `EnrichedMarkdownInput`
210-
- Web Implementation via `react-native-web`
241+
- Web implementation via `react-native-web`
211242

212243
## Contributing
213244

ReactNativeEnrichedMarkdown.podspec

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,7 @@ Pod::Spec.new do |s|
2323
'CLANG_CXX_LANGUAGE_STANDARD' => 'c++17'
2424
}
2525

26+
s.dependency 'iosMath', '~> 0.9'
27+
2628
install_modules_dependencies(s)
2729
end

android/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ android {
7070
repositories {
7171
mavenCentral()
7272
google()
73+
maven { url 'https://jitpack.io' }
7374
}
7475

7576
def kotlin_version = getExtOrDefault("kotlinVersion")
@@ -79,6 +80,10 @@ dependencies {
7980
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
8081
// Baseline Profile installer for AOT compilation hints
8182
implementation "androidx.profileinstaller:profileinstaller:1.3.1"
83+
// LaTeX math rendering
84+
implementation("com.github.gregcockroft:AndroidMath:v1.1.0") {
85+
exclude group: 'com.google.guava', module: 'guava'
86+
}
8287
}
8388

8489
ktlint {

android/src/main/baseline-prof.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Lcom/swmansion/enriched/markdown/renderer/LinkRenderer;
4040
Lcom/swmansion/enriched/markdown/renderer/ListContextManager;
4141
Lcom/swmansion/enriched/markdown/renderer/ListItemRenderer;
4242
Lcom/swmansion/enriched/markdown/renderer/ListRenderer;
43+
Lcom/swmansion/enriched/markdown/renderer/MathInlineRenderer;
4344
Lcom/swmansion/enriched/markdown/renderer/NodeRenderer;
4445
Lcom/swmansion/enriched/markdown/renderer/ParagraphRenderer;
4546
Lcom/swmansion/enriched/markdown/renderer/Renderer;
@@ -62,6 +63,7 @@ Lcom/swmansion/enriched/markdown/spans/ImageSpan;
6263
Lcom/swmansion/enriched/markdown/spans/LineHeightSpan;
6364
Lcom/swmansion/enriched/markdown/spans/LinkSpan;
6465
Lcom/swmansion/enriched/markdown/spans/MarginBottomSpan;
66+
Lcom/swmansion/enriched/markdown/spans/MathInlineSpan;
6567
Lcom/swmansion/enriched/markdown/spans/OrderedListSpan;
6668
Lcom/swmansion/enriched/markdown/spans/StrikethroughSpan;
6769
Lcom/swmansion/enriched/markdown/spans/StrongSpan;
@@ -79,8 +81,10 @@ Lcom/swmansion/enriched/markdown/styles/EmphasisStyle;
7981
Lcom/swmansion/enriched/markdown/styles/HeadingStyle;
8082
Lcom/swmansion/enriched/markdown/styles/ImageStyle;
8183
Lcom/swmansion/enriched/markdown/styles/InlineImageStyle;
84+
Lcom/swmansion/enriched/markdown/styles/InlineMathStyle;
8285
Lcom/swmansion/enriched/markdown/styles/LinkStyle;
8386
Lcom/swmansion/enriched/markdown/styles/ListStyle;
87+
Lcom/swmansion/enriched/markdown/styles/MathStyle;
8488
Lcom/swmansion/enriched/markdown/styles/ParagraphStyle;
8589
Lcom/swmansion/enriched/markdown/styles/StrikethroughStyle;
8690
Lcom/swmansion/enriched/markdown/styles/StrongStyle;
@@ -116,4 +120,5 @@ Lcom/swmansion/enriched/markdown/utils/text/view/TextViewSetupKt;
116120
# Views
117121
Lcom/swmansion/enriched/markdown/views/BlockSegmentView;
118122
Lcom/swmansion/enriched/markdown/views/ContextMenuPopup;
123+
Lcom/swmansion/enriched/markdown/views/MathContainerView;
119124
Lcom/swmansion/enriched/markdown/views/TableContainerView;

android/src/main/cpp/jni-adapter.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ static jint nodeTypeToJavaOrdinal(NodeType type) {
6060
return 22;
6161
case NodeType::TableCell:
6262
return 23;
63+
case NodeType::LatexMathInline:
64+
return 24;
65+
case NodeType::LatexMathDisplay:
66+
return 25;
6367
default:
6468
return 0;
6569
}

android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.swmansion.enriched.markdown.styles.StyleConfig
2020
import com.swmansion.enriched.markdown.utils.text.view.emitLinkLongPressEvent
2121
import com.swmansion.enriched.markdown.utils.text.view.emitLinkPressEvent
2222
import com.swmansion.enriched.markdown.views.BlockSegmentView
23+
import com.swmansion.enriched.markdown.views.MathContainerView
2324
import com.swmansion.enriched.markdown.views.TableContainerView
2425
import java.util.concurrent.ExecutorService
2526
import java.util.concurrent.Executors
@@ -35,6 +36,10 @@ private sealed interface RenderSegment {
3536
data class Table(
3637
val node: MarkdownASTNode,
3738
) : RenderSegment
39+
40+
data class Math(
41+
val latex: String,
42+
) : RenderSegment
3843
}
3944

4045
class EnrichedMarkdown
@@ -170,10 +175,24 @@ class EnrichedMarkdown
170175
splitASTIntoSegments(ast).map { segmentNode ->
171176
when (segmentNode) {
172177
is MarkdownASTNode -> {
173-
if (segmentNode.type == MarkdownASTNode.NodeType.Table) {
174-
RenderSegment.Table(segmentNode)
175-
} else {
176-
renderTextSegment(listOf(segmentNode), style)
178+
when (segmentNode.type) {
179+
MarkdownASTNode.NodeType.Table -> {
180+
RenderSegment.Table(segmentNode)
181+
}
182+
183+
MarkdownASTNode.NodeType.LatexMathDisplay -> {
184+
val latex =
185+
if (segmentNode.children.isNotEmpty()) {
186+
segmentNode.children.first().content
187+
} else {
188+
segmentNode.content
189+
}
190+
RenderSegment.Math(latex)
191+
}
192+
193+
else -> {
194+
renderTextSegment(listOf(segmentNode), style)
195+
}
177196
}
178197
}
179198

@@ -221,6 +240,7 @@ class EnrichedMarkdown
221240
when (segment) {
222241
is RenderSegment.Text -> createTextView(segment)
223242
is RenderSegment.Table -> createTableView(segment, style)
243+
is RenderSegment.Math -> createMathView(segment, style)
224244
}
225245
segmentViews.add(view)
226246
addView(view)
@@ -254,6 +274,13 @@ class EnrichedMarkdown
254274
applyTableNode(segment.node)
255275
}
256276

277+
private fun createMathView(
278+
segment: RenderSegment.Math,
279+
style: StyleConfig,
280+
) = MathContainerView(context, style).apply {
281+
applyLatex(segment.latex)
282+
}
283+
257284
private fun splitASTIntoSegments(root: MarkdownASTNode): List<Any> {
258285
val segments = mutableListOf<Any>()
259286
val currentTextBuffer = mutableListOf<MarkdownASTNode>()
@@ -269,6 +296,9 @@ class EnrichedMarkdown
269296
if (child.type == MarkdownASTNode.NodeType.Table) {
270297
flushTextBuffer()
271298
segments.add(child)
299+
} else if (child.type == MarkdownASTNode.NodeType.LatexMathDisplay) {
300+
flushTextBuffer()
301+
segments.add(child)
272302
} else {
273303
currentTextBuffer.add(child)
274304
}

android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.swmansion.enriched.markdown.styles.StyleConfig
1919
import com.swmansion.enriched.markdown.utils.common.getBooleanOrDefault
2020
import com.swmansion.enriched.markdown.utils.common.getMapOrNull
2121
import com.swmansion.enriched.markdown.utils.common.getStringOrDefault
22+
import com.swmansion.enriched.markdown.views.MathContainerView
2223
import com.swmansion.enriched.markdown.views.TableContainerView
2324
import java.util.concurrent.ConcurrentHashMap
2425
import kotlin.math.ceil
@@ -273,6 +274,11 @@ object MeasurementStore {
273274
data class Table(
274275
val node: MarkdownASTNode,
275276
) : MarkdownSegment
277+
278+
data class Math(
279+
val latex: String,
280+
val node: MarkdownASTNode,
281+
) : MarkdownSegment
276282
}
277283

278284
private fun measureAndCacheSplit(
@@ -331,6 +337,14 @@ object MeasurementStore {
331337
totalHeightPx += style.tableStyle.marginBottom
332338
}
333339
}
340+
341+
is MarkdownSegment.Math -> {
342+
totalHeightPx += style.mathStyle.marginTop
343+
totalHeightPx += MathContainerView.measureMathHeight(segment.latex, style.mathStyle, context)
344+
if (includeBottomMargin) {
345+
totalHeightPx += style.mathStyle.marginBottom
346+
}
347+
}
334348
}
335349
}
336350

@@ -379,11 +393,26 @@ object MeasurementStore {
379393
}
380394

381395
for (child in root.children) {
382-
if (child.type == MarkdownASTNode.NodeType.Table) {
383-
flushTextNodes()
384-
segments.add(MarkdownSegment.Table(child))
385-
} else {
386-
currentTextNodes.add(child)
396+
when (child.type) {
397+
MarkdownASTNode.NodeType.Table -> {
398+
flushTextNodes()
399+
segments.add(MarkdownSegment.Table(child))
400+
}
401+
402+
MarkdownASTNode.NodeType.LatexMathDisplay -> {
403+
flushTextNodes()
404+
val latex =
405+
if (child.children.isNotEmpty()) {
406+
child.children.first().content
407+
} else {
408+
child.content
409+
}
410+
segments.add(MarkdownSegment.Math(latex, child))
411+
}
412+
413+
else -> {
414+
currentTextNodes.add(child)
415+
}
387416
}
388417
}
389418
flushTextNodes()

android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ data class MarkdownASTNode(
3131
TableRow,
3232
TableHeaderCell,
3333
TableCell,
34+
LatexMathInline,
35+
LatexMathDisplay,
3436
}
3537

3638
fun getAttribute(key: String): String? = attributes[key]
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.swmansion.enriched.markdown.renderer
2+
3+
import android.content.Context
4+
import android.text.SpannableStringBuilder
5+
import com.swmansion.enriched.markdown.parser.MarkdownASTNode
6+
import com.swmansion.enriched.markdown.spans.MathInlineSpan
7+
import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE
8+
9+
class MathInlineRenderer(
10+
private val config: RendererConfig,
11+
private val context: Context,
12+
) : NodeRenderer {
13+
override fun render(
14+
node: MarkdownASTNode,
15+
builder: SpannableStringBuilder,
16+
onLinkPress: ((String) -> Unit)?,
17+
onLinkLongPress: ((String) -> Unit)?,
18+
factory: RendererFactory,
19+
) {
20+
val latex = extractLatex(node)
21+
if (latex.isEmpty()) return
22+
23+
val blockStyle = factory.blockStyleContext.requireBlockStyle()
24+
25+
val start = builder.length
26+
builder.append("\uFFFC")
27+
val end = builder.length
28+
29+
val span =
30+
MathInlineSpan(
31+
context = context,
32+
latex = latex,
33+
fontSize = blockStyle.fontSize,
34+
textColor = config.style.inlineMathStyle.color,
35+
)
36+
37+
builder.setSpan(span, start, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE)
38+
}
39+
40+
private fun extractLatex(node: MarkdownASTNode): String {
41+
if (!node.content.isNullOrEmpty()) return node.content!!
42+
return node.children.mapNotNull { it.content }.joinToString("")
43+
}
44+
}

android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class RendererFactory(
5656
MarkdownASTNode.NodeType.Image to ImageRenderer(config, context),
5757
MarkdownASTNode.NodeType.LineBreak to lineBreakRenderer,
5858
MarkdownASTNode.NodeType.ThematicBreak to ThematicBreakRenderer(config),
59+
MarkdownASTNode.NodeType.LatexMathInline to MathInlineRenderer(config, context),
5960
)
6061
}
6162

0 commit comments

Comments
 (0)