refactor: cleaner math/parser integration via space-split tokens

The first cut special-cased the whole per-line loop with an
`if (mightContainMath)` branch that duplicated the word-split +
wordIdentifier logic and filtered empty words inconsistently with the
non-math path (which preserves them to keep double-spaces).

Reframe MathParser.split as a drop-in replacement for `line.split(' ')`
that returns typed Word|Math tokens, keeping math spans whole instead of
tearing them at internal spaces. For a math-free line it yields exactly
the same words (empties included), so RichTextParser collapses to a
single uniform map with an exhaustive when — no branch, no duplication.

Also fixes end-of-sentence math: a span glued to trailing punctuation
(`$x$.`) now carries that punctuation as a `trailing` field rendered
adjacent to the equation, mirroring HashTagSegment's extras, instead of
being dropped to a plain word.

https://claude.ai/code/session_01N8ZhVv9912DLGNJiErVTR4
This commit is contained in:
Claude
2026-06-08 15:31:46 +00:00
parent 1096647191
commit b4e2b5f651
6 changed files with 155 additions and 83 deletions
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.LocalContentColor
@@ -29,6 +30,7 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
@@ -50,6 +52,7 @@ import ru.noties.jlatexmath.JLatexMathDrawable
fun LatexEquation(
latex: String,
displayMode: Boolean,
trailing: String = "",
) {
val color = LocalContentColor.current.toArgb()
val density = LocalDensity.current
@@ -76,7 +79,8 @@ fun LatexEquation(
}
if (drawable == null) {
Text(if (displayMode) "$$$latex$$" else "$$latex$")
// Couldn't parse — show the raw delimited source so nothing is lost.
Text((if (displayMode) "$$$latex$$" else "$$latex$") + trailing)
return
}
@@ -85,13 +89,20 @@ fun LatexEquation(
// Wide display equations can overflow the column; allow them to scroll
// horizontally instead of being clipped.
val sizeModifier = Modifier.size(widthDp, heightDp)
val modifier = if (displayMode) Modifier.horizontalScroll(rememberScrollState()).then(sizeModifier) else sizeModifier
val equationSize = Modifier.size(widthDp, heightDp)
val equationModifier = if (displayMode) Modifier.horizontalScroll(rememberScrollState()).then(equationSize) else equationSize
Canvas(modifier = modifier) {
drawIntoCanvas { canvas ->
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
drawable.draw(canvas.nativeCanvas)
// Row keeps trailing punctuation (the `.` in `$x$.`) hugging the equation
// rather than getting a word-gap from the parent FlowRow.
Row(verticalAlignment = Alignment.CenterVertically) {
Canvas(modifier = equationModifier) {
drawIntoCanvas { canvas ->
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
drawable.draw(canvas.nativeCanvas)
}
}
if (trailing.isNotEmpty()) {
Text(trailing)
}
}
}
@@ -506,7 +506,7 @@ private fun RenderWordWithoutPreview(
is SecretEmoji -> Text(word.segmentText)
is MathSegment -> LatexEquation(word.latex, word.displayMode)
is MathSegment -> LatexEquation(word.latex, word.displayMode, word.trailing)
is PhoneSegment -> ClickablePhone(word.segmentText)
@@ -550,7 +550,7 @@ private fun RenderWordWithPreview(
is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
is EmailSegment -> ClickableEmail(word.segmentText)
is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav)
is MathSegment -> LatexEquation(word.latex, word.displayMode)
is MathSegment -> LatexEquation(word.latex, word.displayMode, word.trailing)
is PhoneSegment -> ClickablePhone(word.segmentText)
is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
@@ -21,13 +21,15 @@
package com.vitorpamplona.amethyst.commons.richtext
/**
* Extracts LaTeX math spans delimited by `$...$` (inline) and `$$...$$` (display)
* from a single line of text.
* Splits a line into the space-delimited words consumed by [RichTextParser],
* keeping LaTeX math spans (`$...$` inline, `$$...$$` display) whole even though
* they may contain spaces.
*
* Math spans can contain spaces, so this runs *before* the regular whitespace
* word-splitter in [RichTextParser]: it tokenizes a line into atomic math spans
* (kept whole) interleaved with the surrounding plain text (which the caller
* then splits on spaces as usual).
* This is a drop-in replacement for `line.split(' ')`: for a line with no math
* it returns exactly the same words (including the empty strings that
* consecutive/leading/trailing spaces produce, which [RichTextParser] relies on
* to preserve double-spaces). A math span simply comes back as a single
* [Token.Math] word instead of being torn apart at its internal spaces.
*
* Delimiter rules follow the common "pandoc/remark-math dollar" convention so
* that ordinary prose with currency (`$5 and $10`) doesn't false-fire:
@@ -36,19 +38,28 @@ package com.vitorpamplona.amethyst.commons.richtext
* must not be immediately followed by a digit.
* - A `$` escaped with a backslash (`\$`) is a literal dollar, never a delimiter.
* - `$$...$$` (display) is matched before `$...$` (inline).
*
* Math glued to text without a separating space (e.g. `a$x$b`) stays a single
* plain word and renders literally — only whitespace-delimited spans become math.
*/
object MathParser {
sealed interface Token {
/** Plain text run; the caller splits this on spaces. */
data class Text(
/** A space-delimited word; may be empty for consecutive spaces. */
data class Word(
val text: String,
) : Token
/** A math span. [raw] includes the `$` delimiters; [latex] is the inner formula. */
/**
* A math span. [raw] includes the `$` delimiters; [latex] is the inner
* formula. [trailing] holds any punctuation glued right after the closing
* `$` (e.g. the `.` in `$x$.`) so it renders next to the equation instead
* of drifting off behind a space — same idea as [HashTagSegment]'s extras.
*/
data class Math(
val raw: String,
val latex: String,
val displayMode: Boolean,
val trailing: String = "",
) : Token
}
@@ -59,41 +70,63 @@ object MathParser {
}
/**
* Splits [line] into alternating [Token.Text] and [Token.Math] tokens.
* When no valid math span is found the whole line comes back as a single
* [Token.Text].
* Splits [line] on spaces into [Token.Word]s, with any whitespace-delimited
* math span surfaced as a [Token.Math].
*/
fun split(line: String): List<Token> {
if (!mightContainMath(line)) return listOf(Token.Text(line))
fun split(line: String): List<Token> =
splitKeepingMathWhole(line).map { cell ->
// A span at the start of the cell becomes math, carrying any trailing
// punctuation (`$x$.`). Leading-glued math (`a$x$`) stays a plain word.
val math = matchMathAt(cell, 0)
if (math != null) math.copy(trailing = cell.substring(math.raw.length)) else Token.Word(cell)
}
val tokens = ArrayList<Token>()
val text = StringBuilder()
/**
* Splits [line] on single spaces the way `line.split(' ')` would, except that
* spaces *inside* a math span don't act as delimiters.
*/
private fun splitKeepingMathWhole(line: String): List<String> {
if (!mightContainMath(line)) return line.split(' ')
val cells = ArrayList<String>()
val current = StringBuilder()
val len = line.length
var i = 0
fun flushText() {
if (text.isNotEmpty()) {
tokens.add(Token.Text(text.toString()))
text.clear()
}
}
while (i < len) {
if (line[i] == '$' && !isEscaped(line, i)) {
val match = matchDisplay(line, i) ?: matchInline(line, i)
if (match != null) {
flushText()
tokens.add(match)
i = match.raw.length + i
continue
val c = line[i]
when {
c == ' ' -> {
cells.add(current.toString())
current.clear()
i++
}
c == '$' -> {
val span = matchMathAt(line, i)?.raw
if (span != null) {
current.append(span)
i += span.length
} else {
current.append(c)
i++
}
}
else -> {
current.append(c)
i++
}
}
text.append(line[i])
i++
}
flushText()
cells.add(current.toString())
return cells
}
return tokens
/** Matches a `$$...$$` or `$...$` span starting at [start], or null. */
private fun matchMathAt(
line: String,
start: Int,
): Token.Math? {
if (start >= line.length || line[start] != '$' || isEscaped(line, start)) return null
return matchDisplay(line, start) ?: matchInline(line, start)
}
/** A `$` is escaped when preceded by an odd number of backslashes. */
@@ -239,34 +239,15 @@ class RichTextParser {
lines.forEach { paragraph ->
val isRTL = isArabic(paragraph)
val trimmed = paragraph.trimEnd()
// split() behaves like `line.split(' ')`, but keeps math spans
// (`$...$`, `$$...$$`) whole instead of tearing them at internal spaces.
val segments =
if (MathParser.mightContainMath(trimmed)) {
// Math spans (`$...$`, `$$...$$`) can contain spaces, so pull them out
// as atomic segments before the regular whitespace word-split.
val list = ArrayList<Segment>()
MathParser.split(trimmed).forEach { token ->
when (token) {
is MathParser.Token.Math ->
list.add(MathSegment(token.raw, token.latex, token.displayMode))
is MathParser.Token.Text ->
token.text.split(' ').forEach { word ->
if (word.isNotEmpty()) {
list.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags))
}
}
}
MathParser.split(paragraph.trimEnd()).map { token ->
when (token) {
is MathParser.Token.Math -> MathSegment(token.raw, token.latex, token.displayMode, token.trailing)
is MathParser.Token.Word -> wordIdentifier(token.text, images, videos, pdfs, urls, emojis, tags)
}
list
} else {
val wordList = trimmed.split(' ')
val list = ArrayList<Segment>(wordList.size)
wordList.forEach { word ->
list.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags))
}
list
}
paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL))
@@ -175,11 +175,14 @@ class RegularTextSegment(
*
* [segmentText] keeps the original text *including* the `$` delimiters so the
* raw form can be shown as a fallback when rendering fails, while [latex] holds
* just the inner formula that gets handed to the math renderer.
* just the inner formula that gets handed to the math renderer. [trailing] is any
* punctuation glued after the closing `$` (e.g. `.` in `$x$.`), rendered right
* next to the equation so it doesn't drift off behind a space.
*/
@Immutable
class MathSegment(
segment: String,
val latex: String,
val displayMode: Boolean,
val trailing: String = "",
) : Segment(segment)
@@ -27,30 +27,30 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MathParserTest {
// Kotlin uses `$` for string templates; using a constant keeps the test
// Kotlin uses `$` for string templates; using constants keeps the test
// strings readable instead of peppering them with `\$`.
private val d = "$"
private val bs = "\\"
private fun math(tokens: List<Token>) = tokens.filterIsInstance<Token.Math>()
private fun joinWords(tokens: List<Token>) = tokens.filterIsInstance<Token.Word>().joinToString(" ") { it.text }
@Test
fun simpleInlineMath() {
val tokens = MathParser.split("before ${d}x_1$d after")
assertEquals(
listOf(
Token.Text("before "),
Token.Word("before"),
Token.Math("${d}x_1$d", "x_1", false),
Token.Text(" after"),
Token.Word("after"),
),
tokens,
MathParser.split("before ${d}x_1$d after"),
)
}
@Test
fun displayMath() {
val tokens = MathParser.split("eq $d${d}E = mc^2$d$d end")
val m = math(tokens).single()
val m = math(MathParser.split("eq $d${d}E = mc^2$d$d end")).single()
assertEquals("E = mc^2", m.latex)
assertTrue(m.displayMode)
assertEquals("$d${d}E = mc^2$d$d", m.raw)
@@ -58,7 +58,12 @@ class MathParserTest {
@Test
fun mathWithInternalSpacesStaysWhole() {
val tokens = MathParser.split("Vectors ${d}A_1, ${bs}ldots, A_n$d are independent")
// The span keeps its internal spaces instead of being split into words.
val tokens = MathParser.split("Vectors ${d}A_1, ${bs}ldots, A_n$d are")
assertEquals(
listOf("Vectors", "are"),
tokens.filterIsInstance<Token.Word>().map { it.text },
)
val m = math(tokens).single()
assertEquals("A_1, ${bs}ldots, A_n", m.latex)
assertFalse(m.displayMode)
@@ -74,15 +79,26 @@ class MathParserTest {
assertEquals("A_1, ${bs}ldots, A_n", m[0].latex)
assertEquals("x_1 A_1 + ${bs}cdots + x_n A_n = 0", m[1].latex)
assertEquals("x_1 = ${bs}cdots = x_n = 0", m[2].latex)
// The closing span ends the sentence, so the period rides along as trailing.
assertEquals("", m[0].trailing)
assertEquals(".", m[2].trailing)
}
@Test
fun trailingPunctuationRidesWithMath() {
val m = math(MathParser.split("the value ${d}x$d, computed")).single()
assertEquals("x", m.latex)
assertEquals(",", m.trailing)
}
@Test
fun currencyIsNotMath() {
// Opening `$` of `$5` is followed by a digit (fine), but the closing `$`
// of `$10` is preceded by a space, so no valid span is formed.
val tokens = MathParser.split("It costs ${d}5 and ${d}10 total")
val line = "It costs ${d}5 and ${d}10 total"
val tokens = MathParser.split(line)
assertTrue(math(tokens).isEmpty())
assertEquals("It costs ${d}5 and ${d}10 total", (tokens.single() as Token.Text).text)
assertEquals(line, joinWords(tokens))
}
@Test
@@ -107,10 +123,38 @@ class MathParserTest {
}
@Test
fun noDollarsShortCircuits() {
fun gluedMathStaysPlainWord() {
// Math without a separating space is not whitespace-delimited, so it
// remains a single literal word rather than three rendered pieces.
val tokens = MathParser.split("a${d}x$d" + "b")
assertTrue(math(tokens).isEmpty())
assertEquals(listOf(Token.Word("a${d}x$d" + "b")), tokens)
}
@Test
fun noDollarsBehavesLikeSpaceSplit() {
assertFalse(MathParser.mightContainMath("just regular text"))
val tokens = MathParser.split("just regular text")
assertEquals(listOf(Token.Text("just regular text")), tokens)
assertEquals(
"just regular text".split(' ').map { Token.Word(it) },
MathParser.split("just regular text"),
)
}
@Test
fun doubleSpacesArePreservedAsEmptyWords() {
// RichTextParser relies on split(' ') semantics to keep double-spaces:
// each run of N spaces yields N-1 empty words, on both sides of math.
assertEquals(
listOf(
Token.Word("a"),
Token.Word(""),
Token.Word("b"),
Token.Math("${d}x$d", "x", false),
Token.Word(""),
Token.Word("c"),
),
MathParser.split("a b ${d}x$d c"),
)
}
@Test