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:
1 parent
caed4db316
commit
bb4ca6bfa9
4 files changed
+311
-85
No files matched your search
@@ -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~, <kbd>keys</kbd>, 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<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
|
||||
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<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;
|
||||
* everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs.
|
||||
@@ -506,6 +665,13 @@ internal fun parseBlocks(src: String): List<MdBlock> {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user