From bb4ca6bfa97c3ab333ccfa6cc4c1a2597392f379 Mon Sep 17 00:00:00 2001 From: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:41:50 +0200 Subject: [PATCH] feat(chat): render LaTeX formulas natively (#939) * feat(chat): render LaTeX formulas Support native inline and display math with accessible Compose rendering. * perf(chat): reuse LaTeX parsing state Share one measurer per message and skip redundant math splitting. --- app/build.gradle.kts | 5 + .../m57/hermescontrol/ui/chat/MarkdownText.kt | 350 ++++++++++++++---- .../ui/chat/MarkdownTextFeatureTest.kt | 35 +- gradle/libs.versions.toml | 6 + 4 files changed, 311 insertions(+), 85 deletions(-) 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" }