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:
@@ -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
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user