diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index cc03c1c2..0b9a301e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -165,6 +165,11 @@ dependencies {
implementation(libs.retrofit.converter.kotlinx.serialization)
implementation(libs.kotlinx.serialization.json)
+ // Native Compose LaTeX rendering
+ implementation(libs.latex.base)
+ implementation(libs.latex.parser)
+ implementation(libs.latex.renderer)
+
// Image loading
implementation(libs.coil.compose)
implementation(libs.coil.gif)
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt
index b1ecc297..c2bdd24e 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt
@@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.InlineTextContent
+import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckBox
import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank
@@ -34,6 +36,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
@@ -44,6 +47,11 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.hrm.latex.renderer.LatexAutoWrap
+import com.hrm.latex.renderer.measure.LatexMeasurerState
+import com.hrm.latex.renderer.measure.rememberLatexMeasurer
+import com.hrm.latex.renderer.model.LatexConfig
+import com.hrm.latex.renderer.model.LatexTheme
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.theme.LocalHermesStatusColors
@@ -65,7 +73,7 @@ private val ORDERED_RE = Regex("""^\s*\d+\.\s+(.*)""")
* Supports: fenced ```code``` blocks (horizontal scroll + copy), inline `code`, **bold**, *italic*,
* ***bold italic***, ~~strike~~, ==highlight==, ^sup^ / ~sub~, keys, headings,
* bullet/ordered/task lists, > blockquotes, definition lists, tables, --- rules, footnotes, and
- * [links](url) / bare URLs. (Math is intentionally out of scope for this hand-rolled renderer.)
+ * [links](url) / bare URLs, and inline/display LaTeX math using `$…$` / `$$…$$`.
*/
@Composable
fun MarkdownText(
@@ -91,6 +99,7 @@ fun MarkdownText(
val linkColor = MaterialTheme.colorScheme.primary
val blocks = remember(text) { parseBlocks(text) }
+ val latexMeasurer = rememberLatexMeasurer()
Column(modifier = modifier.fillMaxWidth()) {
for (block in blocks) {
@@ -103,6 +112,19 @@ fun MarkdownText(
)
}
+ is MdBlock.Math -> {
+ LatexAutoWrap(
+ latex = block.latex,
+ config =
+ LatexConfig(
+ fontSize = 18.sp,
+ theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent),
+ accessibilityEnabled = true,
+ ),
+ modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
+ )
+ }
+
is MdBlock.Hr -> {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
@@ -120,15 +142,17 @@ fun MarkdownText(
5 -> 15.sp
else -> 14.sp
}
- Text(
- text =
- remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
- parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor, highlights)
- },
- color = textColor,
+ MarkdownInlineText(
+ text = block.text,
+ textColor = textColor,
+ latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium
.copy(fontSize = fontSize, fontWeight = FontWeight.Bold),
+ searchQuery = searchQuery,
+ isCurrentMatch = isCurrentMatch,
+ linkColor = linkColor,
+ highlights = highlights,
modifier = Modifier.padding(vertical = 2.dp),
)
}
@@ -144,20 +168,15 @@ fun MarkdownText(
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
- Text(
- text =
- remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
- parseInline(
- block.text,
- textColor,
- searchQuery,
- isCurrentMatch,
- linkColor,
- highlights,
- )
- },
- color = textColor,
+ MarkdownInlineText(
+ text = block.text,
+ textColor = textColor,
+ latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium,
+ searchQuery = searchQuery,
+ isCurrentMatch = isCurrentMatch,
+ linkColor = linkColor,
+ highlights = highlights,
modifier = Modifier.weight(1f),
)
}
@@ -186,20 +205,15 @@ fun MarkdownText(
},
modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp),
)
- Text(
- text =
- remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
- parseInline(
- block.text,
- textColor,
- searchQuery,
- isCurrentMatch,
- linkColor,
- highlights,
- )
- },
- color = textColor,
+ MarkdownInlineText(
+ text = block.text,
+ textColor = textColor,
+ latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium,
+ searchQuery = searchQuery,
+ isCurrentMatch = isCurrentMatch,
+ linkColor = linkColor,
+ highlights = highlights,
modifier = Modifier.weight(1f),
)
}
@@ -216,20 +230,15 @@ fun MarkdownText(
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
- Text(
- text =
- remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
- parseInline(
- block.text,
- textColor,
- searchQuery,
- isCurrentMatch,
- linkColor,
- highlights,
- )
- },
- color = textColor,
+ MarkdownInlineText(
+ text = block.text,
+ textColor = textColor,
+ latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium,
+ searchQuery = searchQuery,
+ isCurrentMatch = isCurrentMatch,
+ linkColor = linkColor,
+ highlights = highlights,
modifier = Modifier.weight(1f),
)
}
@@ -249,20 +258,15 @@ fun MarkdownText(
.background(textColor.copy(alpha = 0.35f)),
)
Spacer(modifier = Modifier.width(8.dp))
- Text(
- text =
- remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
- parseInline(
- block.text,
- textColor,
- searchQuery,
- isCurrentMatch,
- linkColor,
- highlights,
- )
- },
- color = textColor,
+ MarkdownInlineText(
+ text = block.text,
+ textColor = textColor,
+ latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
+ searchQuery = searchQuery,
+ isCurrentMatch = isCurrentMatch,
+ linkColor = linkColor,
+ highlights = highlights,
modifier = Modifier.weight(1f),
)
}
@@ -364,20 +368,15 @@ fun MarkdownText(
color = textColor,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
)
- Text(
- text =
- remember(note.text, searchQuery, isCurrentMatch, textColor, linkColor) {
- parseInline(
- note.text,
- textColor,
- searchQuery,
- isCurrentMatch,
- linkColor,
- highlights,
- )
- },
- color = textColor,
+ MarkdownInlineText(
+ text = note.text,
+ textColor = textColor,
+ latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodySmall,
+ searchQuery = searchQuery,
+ isCurrentMatch = isCurrentMatch,
+ linkColor = linkColor,
+ highlights = highlights,
modifier = Modifier.weight(1f),
)
}
@@ -386,13 +385,15 @@ fun MarkdownText(
}
is MdBlock.Paragraph -> {
- Text(
- text =
- remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
- parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor, highlights)
- },
- color = textColor,
+ MarkdownInlineText(
+ text = block.text,
+ textColor = textColor,
+ latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium,
+ searchQuery = searchQuery,
+ isCurrentMatch = isCurrentMatch,
+ linkColor = linkColor,
+ highlights = highlights,
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
)
}
@@ -427,6 +428,77 @@ private fun tryParseVideo(line: String): MdBlock.Video? {
return null
}
+@Composable
+private fun MarkdownInlineText(
+ text: String,
+ textColor: Color,
+ latexMeasurer: LatexMeasurerState,
+ style: TextStyle,
+ searchQuery: String,
+ isCurrentMatch: Boolean,
+ linkColor: Color,
+ highlights: SearchHighlightColors,
+ modifier: Modifier = Modifier,
+) {
+ val segments = remember(text) { splitInlineMath(text) }
+ if (segments.none { it is InlineMathSegment.Math }) {
+ Text(
+ text =
+ remember(text, searchQuery, isCurrentMatch, textColor, linkColor) {
+ parseInlineSource(text, textColor, searchQuery, isCurrentMatch, linkColor, highlights)
+ },
+ color = textColor,
+ style = style,
+ modifier = modifier,
+ )
+ return
+ }
+
+ val config =
+ LatexConfig(
+ fontSize = style.fontSize,
+ theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent),
+ accessibilityEnabled = true,
+ )
+ val inlineContent = mutableMapOf()
+ segments.forEachIndexed { index, segment ->
+ if (segment is InlineMathSegment.Math) {
+ latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it }
+ }
+ }
+ val annotated =
+ buildAnnotatedString {
+ segments.forEachIndexed { index, segment ->
+ when (segment) {
+ is InlineMathSegment.Math -> {
+ val id = "latex-$index"
+ if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex)
+ }
+
+ is InlineMathSegment.Text -> {
+ append(
+ parseInlineSource(
+ segment.value,
+ textColor,
+ searchQuery,
+ isCurrentMatch,
+ linkColor,
+ highlights,
+ ),
+ )
+ }
+ }
+ }
+ }
+ Text(
+ text = annotated,
+ inlineContent = inlineContent,
+ color = textColor,
+ style = style,
+ modifier = modifier,
+ )
+}
+
@Composable
private fun MarkdownTable(
block: MdBlock.Table,
@@ -485,6 +557,93 @@ private fun tableTextAlign(align: TableAlign?): TextAlign? =
else -> null
}
+internal sealed interface InlineMathSegment {
+ data class Text(val value: String) : InlineMathSegment
+
+ data class Math(val latex: String) : InlineMathSegment
+}
+
+/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */
+internal fun splitInlineMath(text: String): List {
+ val segments = mutableListOf()
+ var plainStart = 0
+ var i = 0
+
+ while (i < text.length) {
+ if (text[i] == '`' && !text.isEscaped(i)) {
+ val codeEnd = text.indexOf('`', i + 1)
+ i = if (codeEnd == -1) text.length else codeEnd + 1
+ continue
+ }
+ if (text.startsWith("\\(", i)) {
+ val end = text.indexOf("\\)", i + 2)
+ if (end != -1 && end > i + 2) {
+ if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
+ segments.add(InlineMathSegment.Math(text.substring(i + 2, end)))
+ i = end + 2
+ plainStart = i
+ continue
+ }
+ }
+ if (text[i] != '$' || text.isEscaped(i) || text.getOrNull(i + 1) == '$') {
+ i++
+ continue
+ }
+
+ var end = i + 1
+ while (end < text.length && (text[end] != '$' || text.isEscaped(end))) end++
+ if (end >= text.length || end == i + 1 || text[end - 1].isWhitespace()) {
+ i++
+ continue
+ }
+
+ if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
+ segments.add(InlineMathSegment.Math(text.substring(i + 1, end)))
+ i = end + 1
+ plainStart = i
+ }
+
+ if (plainStart < text.length) segments.add(InlineMathSegment.Text(text.substring(plainStart)))
+ return segments.ifEmpty { listOf(InlineMathSegment.Text(text)) }
+}
+
+private fun String.isEscaped(index: Int): Boolean {
+ var slashes = 0
+ var i = index - 1
+ while (i >= 0 && this[i] == '\\') {
+ slashes++
+ i--
+ }
+ return slashes % 2 == 1
+}
+
+private fun tryParseDisplayMath(
+ lines: List,
+ start: Int,
+): Pair? {
+ val first = lines[start].trim()
+ if (first.startsWith("\\[")) {
+ if (first.length > 4 && first.endsWith("\\]")) {
+ val latex = first.substring(2, first.length - 2).trim()
+ return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
+ }
+ if (first != "\\[") return null
+ val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "\\]" } ?: return null
+ val latex = lines.subList(start + 1, end).joinToString("\n").trim()
+ return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
+ }
+ if (!first.startsWith("$$")) return null
+ if (first.length > 4 && first.endsWith("$$")) {
+ val latex = first.substring(2, first.length - 2).trim()
+ return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
+ }
+ if (first != "$$") return null
+
+ val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "$$" } ?: return null
+ val latex = lines.subList(start + 1, end).joinToString("\n").trim()
+ return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
+}
+
/**
* Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first;
* everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs.
@@ -506,6 +665,13 @@ internal fun parseBlocks(src: String): List {
continue
}
+ val displayMath = tryParseDisplayMath(lines, i)
+ if (displayMath != null) {
+ blocks.add(displayMath.first)
+ i = displayMath.second
+ continue
+ }
+
when {
line.startsWith("```") -> {
val lang = line.removePrefix("```").trim().ifBlank { null }
@@ -712,6 +878,7 @@ private fun fallthroughToParagraph(
!isHorizontalRule(lines[i]) &&
!isTableStart(lines, i) &&
!isDefListStart(lines, i) &&
+ tryParseDisplayMath(lines, i) == null &&
FN_DEF_RE.matchAt(lines[i], 0) == null
) {
para.add(lines[i])
@@ -733,6 +900,29 @@ internal fun parseInline(
isCurrentMatch: Boolean,
linkColor: Color,
highlights: SearchHighlightColors,
+): AnnotatedString =
+ parseInlineSource(
+ text =
+ splitInlineMath(text).joinToString(separator = "") { segment ->
+ when (segment) {
+ is InlineMathSegment.Math -> segment.latex
+ is InlineMathSegment.Text -> segment.value
+ }
+ },
+ textColor = textColor,
+ searchQuery = searchQuery,
+ isCurrentMatch = isCurrentMatch,
+ linkColor = linkColor,
+ highlights = highlights,
+ )
+
+private fun parseInlineSource(
+ text: String,
+ textColor: Color,
+ searchQuery: String,
+ isCurrentMatch: Boolean,
+ linkColor: Color,
+ highlights: SearchHighlightColors,
): AnnotatedString {
val searchHighlightColor =
if (isCurrentMatch) {
@@ -979,6 +1169,10 @@ internal sealed interface MdBlock {
val language: String? = null,
) : MdBlock
+ data class Math(
+ val latex: String,
+ ) : MdBlock
+
data class Heading(
val level: Int,
val text: String,
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt
index 8a0226dd..08745f49 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt
@@ -35,8 +35,8 @@ private val DEFAULT_HIGHLIGHTS =
)
/**
- * Verifies the hand-rolled Markdown parser covers the feature set requested for issue #572
- * (math excluded by decision). Each test asserts the block/inline structure actually parses —
+ * Verifies the hand-rolled Markdown parser covers the feature set requested for issue #572.
+ * Each test asserts the block/inline structure actually parses —
* not just that it compiles. Inline assertions avoid referencing Compose text types (FontWeight,
* BaselineShift, etc.) that aren't on the unit-test classpath; we inspect SpanStyle fields
* structurally instead.
@@ -64,12 +64,33 @@ class MarkdownTextFeatureTest {
assertEquals(TableAlign.RIGHT, table.alignments[2])
}
- // 2. MATH — excluded; ensure a math-looking string stays plain, not crashed
+ // 2. MATH
@Test
- fun testMath_isTreatedAsPlainText() {
- val md = "E = mc^2^ and `x = (-b ± √(b²-4ac)) / 2a`"
- val block = parseBlocks(md).single() as MdBlock.Paragraph
- assertEquals(md, block.text)
+ fun testDisplayMath_parsesToMathBlock() {
+ val block = parseBlocks("\$\$\\frac{1}{2}\$\$").single()
+
+ assertEquals("Math", block::class.simpleName)
+ }
+
+ @Test
+ fun testInlineMath_stripsDelimitersFromFallbackText() {
+ val parsed = parseInline("Energy \$E=mc^2\$.", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
+
+ assertEquals("Energy E=mc^2.", parsed.toString())
+ }
+
+ @Test
+ fun testBracketDisplayMath_parsesToMathBlock() {
+ val block = parseBlocks("\\[\n\\frac{1}{2}\n\\]").single()
+
+ assertEquals("Math", block::class.simpleName)
+ }
+
+ @Test
+ fun testParenthesizedInlineMath_stripsDelimitersFromFallbackText() {
+ val parsed = parseInline("Energy \\(E=mc^2\\).", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
+
+ assertEquals("Energy E=mc^2.", parsed.toString())
}
// 3. STRIKETHROUGH
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b5bcc250..e0ac261c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -29,6 +29,7 @@ coil = "2.7.0"
room = "2.7.1"
sqlcipher = "4.17.0"
datastore = "1.1.2"
+latex = "1.5.3"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
@@ -81,6 +82,11 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref =
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
sqlcipher = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher" }
+# Native Compose LaTeX rendering
+latex-base = { module = "io.github.huarangmeng:latex-base", version.ref = "latex" }
+latex-parser = { module = "io.github.huarangmeng:latex-parser", version.ref = "latex" }
+latex-renderer = { module = "io.github.huarangmeng:latex-renderer", version.ref = "latex" }
+
# Image loading
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }