From b4e2b5f651bff7afe9472024cff73382dbd0f093 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 15:31:46 +0000 Subject: [PATCH] refactor: cleaner math/parser integration via space-split tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amethyst/ui/components/LatexEquation.kt | 25 +++-- .../amethyst/ui/components/RichTextViewer.kt | 4 +- .../amethyst/commons/richtext/MathParser.kt | 103 ++++++++++++------ .../commons/richtext/RichTextParser.kt | 31 +----- .../richtext/RichTextParserSegments.kt | 5 +- .../commons/richtext/MathParserTest.kt | 70 +++++++++--- 6 files changed, 155 insertions(+), 83 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt index a3b851c2ba..434561608a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 938d4884bf..cd82b3d6f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParser.kt index 40440e8da4..96a4026b67 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParser.kt @@ -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 { - if (!mightContainMath(line)) return listOf(Token.Text(line)) + fun split(line: String): List = + 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() - 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 { + if (!mightContainMath(line)) return line.split(' ') + + val cells = ArrayList() + 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. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 85d494bf54..b5171e8e20 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -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() - 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(wordList.size) - wordList.forEach { word -> - list.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags)) - } - list } paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 57b2830a37..6b7f6ce993 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -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) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParserTest.kt index f914663fe6..fa21570120 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParserTest.kt @@ -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) = tokens.filterIsInstance() + private fun joinWords(tokens: List) = tokens.filterIsInstance().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().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