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.
This commit is contained in:
AlexDev_
2026-08-19 22:41:50 +03:00
committed by GitHub
parent caed4db316
commit bb4ca6bfa9
4 changed files with 311 additions and 85 deletions
+5
View File
@@ -165,6 +165,11 @@ dependencies {
implementation(libs.retrofit.converter.kotlinx.serialization) implementation(libs.retrofit.converter.kotlinx.serialization)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// Native Compose LaTeX rendering
implementation(libs.latex.base)
implementation(libs.latex.parser)
implementation(libs.latex.renderer)
// Image loading // Image loading
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.gif) implementation(libs.coil.gif)
@@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icons
import androidx.compose.material.icons.outlined.CheckBox import androidx.compose.material.icons.outlined.CheckBox
import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank 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.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle 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.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.local.AuthManager
import com.m57.hermescontrol.data.remote.GatewayFileClient import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.theme.LocalHermesStatusColors 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*, * Supports: fenced ```code``` blocks (horizontal scroll + copy), inline `code`, **bold**, *italic*,
* ***bold italic***, ~~strike~~, ==highlight==, ^sup^ / ~sub~, <kbd>keys</kbd>, headings, * ***bold italic***, ~~strike~~, ==highlight==, ^sup^ / ~sub~, <kbd>keys</kbd>, headings,
* bullet/ordered/task lists, > blockquotes, definition lists, tables, --- rules, footnotes, and * 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 @Composable
fun MarkdownText( fun MarkdownText(
@@ -91,6 +99,7 @@ fun MarkdownText(
val linkColor = MaterialTheme.colorScheme.primary val linkColor = MaterialTheme.colorScheme.primary
val blocks = remember(text) { parseBlocks(text) } val blocks = remember(text) { parseBlocks(text) }
val latexMeasurer = rememberLatexMeasurer()
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
for (block in blocks) { 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 -> { is MdBlock.Hr -> {
HorizontalDivider( HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp), modifier = Modifier.padding(vertical = 8.dp),
@@ -120,15 +142,17 @@ fun MarkdownText(
5 -> 15.sp 5 -> 15.sp
else -> 14.sp else -> 14.sp
} }
Text( MarkdownInlineText(
text = text = block.text,
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { textColor = textColor,
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor, highlights) latexMeasurer = latexMeasurer,
},
color = textColor,
style = style =
MaterialTheme.typography.bodyMedium MaterialTheme.typography.bodyMedium
.copy(fontSize = fontSize, fontWeight = FontWeight.Bold), .copy(fontSize = fontSize, fontWeight = FontWeight.Bold),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.padding(vertical = 2.dp), modifier = Modifier.padding(vertical = 2.dp),
) )
} }
@@ -144,20 +168,15 @@ fun MarkdownText(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp), modifier = Modifier.padding(end = 6.dp),
) )
Text( MarkdownInlineText(
text = text = block.text,
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { textColor = textColor,
parseInline( latexMeasurer = latexMeasurer,
block.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
},
color = textColor,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@@ -186,20 +205,15 @@ fun MarkdownText(
}, },
modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp), modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp),
) )
Text( MarkdownInlineText(
text = text = block.text,
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { textColor = textColor,
parseInline( latexMeasurer = latexMeasurer,
block.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
},
color = textColor,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@@ -216,20 +230,15 @@ fun MarkdownText(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp), modifier = Modifier.padding(end = 6.dp),
) )
Text( MarkdownInlineText(
text = text = block.text,
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { textColor = textColor,
parseInline( latexMeasurer = latexMeasurer,
block.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
},
color = textColor,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@@ -249,20 +258,15 @@ fun MarkdownText(
.background(textColor.copy(alpha = 0.35f)), .background(textColor.copy(alpha = 0.35f)),
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( MarkdownInlineText(
text = text = block.text,
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { textColor = textColor,
parseInline( latexMeasurer = latexMeasurer,
block.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
},
color = textColor,
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@@ -364,20 +368,15 @@ fun MarkdownText(
color = textColor, color = textColor,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold), style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
) )
Text( MarkdownInlineText(
text = text = note.text,
remember(note.text, searchQuery, isCurrentMatch, textColor, linkColor) { textColor = textColor,
parseInline( latexMeasurer = latexMeasurer,
note.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
},
color = textColor,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@@ -386,13 +385,15 @@ fun MarkdownText(
} }
is MdBlock.Paragraph -> { is MdBlock.Paragraph -> {
Text( MarkdownInlineText(
text = text = block.text,
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { textColor = textColor,
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor, highlights) latexMeasurer = latexMeasurer,
},
color = textColor,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
) )
} }
@@ -427,6 +428,77 @@ private fun tryParseVideo(line: String): MdBlock.Video? {
return null 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<String, InlineTextContent>()
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 @Composable
private fun MarkdownTable( private fun MarkdownTable(
block: MdBlock.Table, block: MdBlock.Table,
@@ -485,6 +557,93 @@ private fun tableTextAlign(align: TableAlign?): TextAlign? =
else -> null 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<InlineMathSegment> {
val segments = mutableListOf<InlineMathSegment>()
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<String>,
start: Int,
): Pair<MdBlock.Math, Int>? {
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; * Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first;
* everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs. * everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs.
@@ -506,6 +665,13 @@ internal fun parseBlocks(src: String): List<MdBlock> {
continue continue
} }
val displayMath = tryParseDisplayMath(lines, i)
if (displayMath != null) {
blocks.add(displayMath.first)
i = displayMath.second
continue
}
when { when {
line.startsWith("```") -> { line.startsWith("```") -> {
val lang = line.removePrefix("```").trim().ifBlank { null } val lang = line.removePrefix("```").trim().ifBlank { null }
@@ -712,6 +878,7 @@ private fun fallthroughToParagraph(
!isHorizontalRule(lines[i]) && !isHorizontalRule(lines[i]) &&
!isTableStart(lines, i) && !isTableStart(lines, i) &&
!isDefListStart(lines, i) && !isDefListStart(lines, i) &&
tryParseDisplayMath(lines, i) == null &&
FN_DEF_RE.matchAt(lines[i], 0) == null FN_DEF_RE.matchAt(lines[i], 0) == null
) { ) {
para.add(lines[i]) para.add(lines[i])
@@ -733,6 +900,29 @@ internal fun parseInline(
isCurrentMatch: Boolean, isCurrentMatch: Boolean,
linkColor: Color, linkColor: Color,
highlights: SearchHighlightColors, 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 { ): AnnotatedString {
val searchHighlightColor = val searchHighlightColor =
if (isCurrentMatch) { if (isCurrentMatch) {
@@ -979,6 +1169,10 @@ internal sealed interface MdBlock {
val language: String? = null, val language: String? = null,
) : MdBlock ) : MdBlock
data class Math(
val latex: String,
) : MdBlock
data class Heading( data class Heading(
val level: Int, val level: Int,
val text: String, val text: String,
@@ -35,8 +35,8 @@ private val DEFAULT_HIGHLIGHTS =
) )
/** /**
* Verifies the hand-rolled Markdown parser covers the feature set requested for issue #572 * 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 — * Each test asserts the block/inline structure actually parses —
* not just that it compiles. Inline assertions avoid referencing Compose text types (FontWeight, * 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 * BaselineShift, etc.) that aren't on the unit-test classpath; we inspect SpanStyle fields
* structurally instead. * structurally instead.
@@ -64,12 +64,33 @@ class MarkdownTextFeatureTest {
assertEquals(TableAlign.RIGHT, table.alignments[2]) assertEquals(TableAlign.RIGHT, table.alignments[2])
} }
// 2. MATH — excluded; ensure a math-looking string stays plain, not crashed // 2. MATH
@Test @Test
fun testMath_isTreatedAsPlainText() { fun testDisplayMath_parsesToMathBlock() {
val md = "E = mc^2^ and `x = (-b ± √(b²-4ac)) / 2a`" val block = parseBlocks("\$\$\\frac{1}{2}\$\$").single()
val block = parseBlocks(md).single() as MdBlock.Paragraph
assertEquals(md, block.text) 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 // 3. STRIKETHROUGH
+6
View File
@@ -29,6 +29,7 @@ coil = "2.7.0"
room = "2.7.1" room = "2.7.1"
sqlcipher = "4.17.0" sqlcipher = "4.17.0"
datastore = "1.1.2" datastore = "1.1.2"
latex = "1.5.3"
[libraries] [libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } 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" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
sqlcipher = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher" } 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 # Image loading
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" } coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }