From 10966471914fa1ffe49e22d45f8997f6056d4ea6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 14:47:56 +0000 Subject: [PATCH 1/3] feat: render LaTeX math in notes with $...$ and $$...$$ delimiters Posts that use the common dollar-delimiter convention (e.g. the math-academy "Linear Independence" note) now render their formulas as real equations instead of raw LaTeX text. - commons: new MathParser tokenizes a line into atomic math spans (kept whole, since they contain spaces) interleaved with plain text, following the pandoc/remark-math dollar rules so currency like "$5 and $10" and escaped "\$" don't false-fire. New MathSegment carries the inner LaTeX + display flag through the rich-text pipeline. - RichTextParser splits math out before the whitespace word-splitter when a line might contain math; non-math lines keep the existing path. - amethyst: LatexEquation renders a MathSegment via JLaTeXMath, tinted to the current text color and sized to the font, with a raw-text fallback when the formula fails to parse. Wired into both the preview and no-preview render paths of RichTextViewer. Scope: dollar delimiters only, regular (non-markdown) render path. https://claude.ai/code/session_01N8ZhVv9912DLGNJiErVTR4 --- amethyst/build.gradle.kts | 3 + .../amethyst/ui/components/LatexEquation.kt | 99 +++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 4 + .../amethyst/commons/richtext/MathParser.kt | 161 ++++++++++++++++++ .../commons/richtext/RichTextParser.kt | 32 +++- .../richtext/RichTextParserSegments.kt | 14 ++ .../commons/richtext/MathParserTest.kt | 123 +++++++++++++ gradle/libs.versions.toml | 2 + 8 files changed, 433 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParser.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParserTest.kt diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 5c99450c07..6fe554fccf 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -412,6 +412,9 @@ dependencies { implementation(libs.markdown.ui.material3) implementation(libs.markdown.commonmark) + // LaTeX math rendering ($...$ and $$...$$ inline equations) + implementation(libs.jlatexmath.android) + // Language picker and Theme chooser implementation(libs.androidx.appcompat) 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 new file mode 100644 index 0000000000..a3b851c2ba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LatexEquation.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.sp +import ru.noties.jlatexmath.JLatexMathDrawable + +/** + * Renders a LaTeX formula (the inner text of a `$...$` / `$$...$$` span) as an + * image, tinted to follow the current text color and sized to the current font. + * + * JLaTeXMath throws on malformed input, so anything it can't parse falls back to + * the raw delimited text rather than crashing the feed. + */ +@Composable +fun LatexEquation( + latex: String, + displayMode: Boolean, +) { + val color = LocalContentColor.current.toArgb() + val density = LocalDensity.current + val fontSize = LocalTextStyle.current.fontSize + + // Display math renders a touch larger than the surrounding prose, matching + // the visual weight KaTeX gives block equations. + val textSizePx = + with(density) { + val base = if (fontSize.isUsable()) fontSize.toPx() else 16.sp.toPx() + if (displayMode) base * 1.2f else base + } + + val drawable = + remember(latex, color, textSizePx) { + runCatching { + JLatexMathDrawable + .builder(latex) + .textSize(textSizePx) + .color(color) + .align(JLatexMathDrawable.ALIGN_LEFT) + .build() + }.getOrNull() + } + + if (drawable == null) { + Text(if (displayMode) "$$$latex$$" else "$$latex$") + return + } + + val widthDp = with(density) { drawable.intrinsicWidth.toDp() } + val heightDp = with(density) { drawable.intrinsicHeight.toDp() } + + // 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 + + Canvas(modifier = modifier) { + drawIntoCanvas { canvas -> + drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) + drawable.draw(canvas.nativeCanvas) + } + } +} + +private fun TextUnit.isUsable(): Boolean = this != TextUnit.Unspecified && this.type == TextUnitType.Sp 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 9822346a0b..938d4884bf 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 @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph import com.vitorpamplona.amethyst.commons.richtext.ImageSegment import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment import com.vitorpamplona.amethyst.commons.richtext.LinkSegment +import com.vitorpamplona.amethyst.commons.richtext.MathSegment import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment import com.vitorpamplona.amethyst.commons.richtext.ParagraphState import com.vitorpamplona.amethyst.commons.richtext.PdfSegment @@ -505,6 +506,8 @@ private fun RenderWordWithoutPreview( is SecretEmoji -> Text(word.segmentText) + is MathSegment -> LatexEquation(word.latex, word.displayMode) + is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav) @@ -547,6 +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 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 new file mode 100644 index 0000000000..40440e8da4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParser.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +/** + * Extracts LaTeX math spans delimited by `$...$` (inline) and `$$...$$` (display) + * from a single line of text. + * + * 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). + * + * Delimiter rules follow the common "pandoc/remark-math dollar" convention so + * that ordinary prose with currency (`$5 and $10`) doesn't false-fire: + * - An opening `$` must be immediately followed by a non-whitespace char. + * - A closing `$` must be immediately preceded by a non-whitespace char and + * 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). + */ +object MathParser { + sealed interface Token { + /** Plain text run; the caller splits this on spaces. */ + data class Text( + val text: String, + ) : Token + + /** A math span. [raw] includes the `$` delimiters; [latex] is the inner formula. */ + data class Math( + val raw: String, + val latex: String, + val displayMode: Boolean, + ) : Token + } + + /** Cheap gate: a line can only contain math if it has at least two `$`. */ + fun mightContainMath(line: String): Boolean { + val first = line.indexOf('$') + return first >= 0 && line.indexOf('$', first + 1) >= 0 + } + + /** + * 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]. + */ + fun split(line: String): List { + if (!mightContainMath(line)) return listOf(Token.Text(line)) + + val tokens = ArrayList() + val text = 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 + } + } + text.append(line[i]) + i++ + } + flushText() + + return tokens + } + + /** A `$` is escaped when preceded by an odd number of backslashes. */ + private fun isEscaped( + line: String, + index: Int, + ): Boolean { + var backslashes = 0 + var j = index - 1 + while (j >= 0 && line[j] == '\\') { + backslashes++ + j-- + } + return backslashes % 2 == 1 + } + + /** Matches `$$...$$` starting at [start] (which points at the first `$`). */ + private fun matchDisplay( + line: String, + start: Int, + ): Token.Math? { + if (start + 1 >= line.length || line[start + 1] != '$') return null + val contentStart = start + 2 + var j = contentStart + while (j + 1 < line.length) { + if (line[j] == '$' && line[j + 1] == '$' && !isEscaped(line, j)) { + val latex = line.substring(contentStart, j) + if (latex.isBlank()) return null + return Token.Math(line.substring(start, j + 2), latex.trim(), displayMode = true) + } + j++ + } + return null + } + + /** Matches `$...$` starting at [start] (which points at the opening `$`). */ + private fun matchInline( + line: String, + start: Int, + ): Token.Math? { + val contentStart = start + 1 + // Opening `$` must be followed by a non-whitespace character. + if (contentStart >= line.length || line[contentStart].isWhitespace()) return null + + var j = contentStart + while (j < line.length) { + if (line[j] == '$' && !isEscaped(line, j)) { + // Closing `$` must be preceded by a non-whitespace char and + // not be immediately followed by a digit (avoids `$5 ... $10`). + val prev = line[j - 1] + val nextIsDigit = j + 1 < line.length && line[j + 1].isDigit() + if (!prev.isWhitespace() && !nextIsDigit) { + val latex = line.substring(contentStart, j) + if (latex.isBlank()) return null + return Token.Math(line.substring(start, j + 1), latex, displayMode = false) + } + // This `$` can't close — and since it isn't escaped it also + // can't appear inside inline math, so stop scanning. + return null + } + j++ + } + return null + } +} 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 b8f8736716..85d494bf54 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,13 +239,35 @@ class RichTextParser { lines.forEach { paragraph -> val isRTL = isArabic(paragraph) + val trimmed = paragraph.trimEnd() - val wordList = paragraph.trimEnd().split(' ') + 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)) - val segments = ArrayList(wordList.size) - wordList.forEach { word -> - segments.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags)) - } + is MathParser.Token.Text -> + token.text.split(' ').forEach { word -> + if (word.isNotEmpty()) { + list.add(wordIdentifier(word, 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 e03b8a6dd5..57b2830a37 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 @@ -169,3 +169,17 @@ class NowhereLinkSegment( class RegularTextSegment( segment: String, ) : Segment(segment) + +/** + * A LaTeX math span delimited by `$...$` (inline) or `$$...$$` (display). + * + * [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. + */ +@Immutable +class MathSegment( + segment: String, + val latex: String, + val displayMode: Boolean, +) : 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 new file mode 100644 index 0000000000..f914663fe6 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MathParserTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import com.vitorpamplona.amethyst.commons.richtext.MathParser.Token +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MathParserTest { + // Kotlin uses `$` for string templates; using a constant keeps the test + // strings readable instead of peppering them with `\$`. + private val d = "$" + private val bs = "\\" + + private fun math(tokens: List) = tokens.filterIsInstance() + + @Test + fun simpleInlineMath() { + val tokens = MathParser.split("before ${d}x_1$d after") + assertEquals( + listOf( + Token.Text("before "), + Token.Math("${d}x_1$d", "x_1", false), + Token.Text(" after"), + ), + tokens, + ) + } + + @Test + fun displayMath() { + val tokens = MathParser.split("eq $d${d}E = mc^2$d$d end") + val m = math(tokens).single() + assertEquals("E = mc^2", m.latex) + assertTrue(m.displayMode) + assertEquals("$d${d}E = mc^2$d$d", m.raw) + } + + @Test + fun mathWithInternalSpacesStaysWhole() { + val tokens = MathParser.split("Vectors ${d}A_1, ${bs}ldots, A_n$d are independent") + val m = math(tokens).single() + assertEquals("A_1, ${bs}ldots, A_n", m.latex) + assertFalse(m.displayMode) + } + + @Test + fun theLinearIndependencePost() { + val content = + "Vectors ${d}A_1, ${bs}ldots, A_n$d are independent if the only choice of scalars for which " + + "${d}x_1 A_1 + ${bs}cdots + x_n A_n = 0$d is the trivial one ${d}x_1 = ${bs}cdots = x_n = 0$d." + val m = math(MathParser.split(content)) + assertEquals(3, m.size) + 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) + } + + @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") + assertTrue(math(tokens).isEmpty()) + assertEquals("It costs ${d}5 and ${d}10 total", (tokens.single() as Token.Text).text) + } + + @Test + fun openingDollarFollowedBySpaceIsNotMath() { + assertTrue(math(MathParser.split("a $d x + y $d b")).isEmpty()) + } + + @Test + fun escapedDollarIsLiteral() { + assertTrue(math(MathParser.split("price is $bs${d}5 to $bs${d}9")).isEmpty()) + } + + @Test + fun escapedDollarInsideMathDoesNotClose() { + val m = math(MathParser.split("cost ${d}a + $bs$d + b$d ok")).single() + assertEquals("a + $bs$d + b", m.latex) + } + + @Test + fun emptyMathIsIgnored() { + assertTrue(math(MathParser.split("empty $d$d here")).isEmpty()) + } + + @Test + fun noDollarsShortCircuits() { + assertFalse(MathParser.mightContainMath("just regular text")) + val tokens = MathParser.split("just regular text") + assertEquals(listOf(Token.Text("just regular text")), tokens) + } + + @Test + fun adjacentInlineSpans() { + val m = math(MathParser.split("${d}a$d ${d}b$d")) + assertEquals(2, m.size) + assertEquals("a", m[0].latex) + assertEquals("b", m[1].latex) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f965547bdd..21a7ef3d28 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,6 +43,7 @@ genaiImageDescription = "1.0.0-beta1" languageId = "17.0.6" lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "2.2.1" +jlatexmath = "0.2.0" markdown = "f92ef49c9d" material3 = "1.9.0" media3 = "1.10.1" @@ -173,6 +174,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutinesCore" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } +jlatexmath-android = { group = "ru.noties", name = "jlatexmath-android", version.ref = "jlatexmath" } markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" } markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" } markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" } From b4e2b5f651bff7afe9472024cff73382dbd0f093 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 15:31:46 +0000 Subject: [PATCH 2/3] 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 From b8ac919acdd295cf85363a9a5109b248adbb90e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 21:27:15 +0000 Subject: [PATCH 3/3] test(richtext): guard math/link/image/hashtag adjacency Pins that a MathSegment covers only its $-delimited span (not the paragraph), that space-separated hashtags, URLs and images next to math stay independently detected, that `$x$.` keeps its trailing period while a following hashtag remains its own segment, and that currency `$5` doesn't pair with a later equation. Also documents the glued (no-space) edge cases. --- .../RichTextParserMathAdjacencyTest.kt | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMathAdjacencyTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMathAdjacencyTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMathAdjacencyTest.kt new file mode 100644 index 0000000000..7dfa03d931 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMathAdjacencyTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Guards that math extraction stays inside its `$`-delimiters and doesn't disturb + * neighbouring links / images / hashtags when separated by a space (the normal + * case), and documents the glued (no-space) edge behaviour. + */ +class RichTextParserMathAdjacencyTest { + // Kotlin uses `$` for string templates; a constant keeps the test strings readable. + private val d = "$" + + private fun parse(content: String) = RichTextParser().parseText(content, EmptyTagList, null).paragraphs.flatMap { it.words } + + @Test + fun mathSegmentIsJustTheSpanNotTheParagraph() { + val content = "before ${d}x_1 + y$d after more words here" + val math = parse(content).filterIsInstance().single() + assertEquals("${d}x_1 + y$d", math.segmentText) + assertEquals("x_1 + y", math.latex) + } + + @Test + fun spaceSeparatedHashtagUrlAndMathAreAllDetected() { + val content = "#physics the law ${d}E=mc^2$d see https://nostr.com today" + val words = parse(content) + assertEquals(1, words.filterIsInstance().size) + assertEquals(1, words.filterIsInstance().size) + assertEquals(1, words.filterIsInstance().size) + assertEquals("physics", words.filterIsInstance().single().hashtag) + assertEquals("E=mc^2", words.filterIsInstance().single().latex) + assertEquals("https://nostr.com", words.filterIsInstance().single().segmentText) + } + + @Test + fun imageNextToMathStaysAnImage() { + val content = "${d}a^2$d https://i.imgur.com/abc.jpg done" + val words = parse(content) + assertEquals(1, words.filterIsInstance().size) + assertEquals(1, words.filterIsInstance().size) + assertEquals("https://i.imgur.com/abc.jpg", words.filterIsInstance().single().segmentText) + } + + @Test + fun mathEndingSentenceThenHashtag() { + // `$x$.` keeps the period as trailing; the hashtag after the space is its own segment. + val content = "result ${d}x=0$d. #done" + val words = parse(content) + val math = words.filterIsInstance().single() + assertEquals("x=0", math.latex) + assertEquals(".", math.trailing) + assertEquals("done", words.filterIsInstance().single().hashtag) + } + + // ---- glued (no-space) adjacency: documents the known limitation ---- + + @Test + fun hashtagGluedAfterMathBecomesTrailingText() { + // `$x$#tag` with no space: the hashtag is absorbed as trailing punctuation + // and is NOT a clickable hashtag. Acceptable edge case. + val content = "eq ${d}x$d#tag" + val words = parse(content) + val math = words.filterIsInstance().single() + assertEquals("x", math.latex) + assertEquals("#tag", math.trailing) + assertTrue(words.none { it is HashTagSegment }) + } + + @Test + fun mathGluedAfterHashtagDoesNotRenderAsMath() { + // `#tag$x$` with no space: the `#` token wins; the math stays literal text. + val content = "see #tag${d}x$d here" + val words = parse(content) + assertTrue(words.none { it is MathSegment }) + assertEquals("tag", words.filterIsInstance().single().hashtag) + } + + @Test + fun currencyBeforeRealEquationDoesNotSwallow() { + // `$5` must not pair with the later equation's `$`. + val content = "costs ${d}5 but the formula ${d}x=1$d holds" + assertEquals("x=1", parse(content).filterIsInstance().single().latex) + } +}