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 06e74738ec..2042fb82a1 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 @@ -53,6 +53,7 @@ import ru.noties.jlatexmath.JLatexMathDrawable fun LatexEquation( latex: String, displayMode: Boolean, + leading: String = "", trailing: String = "", ) { val color = LocalContentColor.current.toArgb() @@ -86,7 +87,7 @@ fun LatexEquation( if (drawable == null) { // Couldn't parse — show the raw delimited source so nothing is lost. - Text((if (displayMode) "$$$latex$$" else "$$latex$") + trailing) + Text(leading + (if (displayMode) "$$$latex$$" else "$$latex$") + trailing) return } @@ -98,9 +99,12 @@ fun LatexEquation( val equationSize = Modifier.size(widthDp, heightDp) val equationModifier = if (displayMode) Modifier.horizontalScroll(rememberScrollState()).then(equationSize) else equationSize - // Row keeps trailing punctuation (the `.` in `$x$.`) hugging the equation - // rather than getting a word-gap from the parent FlowRow. + // Row keeps leading/trailing punctuation (the `(` and `)` in `($x$)`) hugging + // the equation rather than getting a word-gap from the parent FlowRow. Row(verticalAlignment = Alignment.CenterVertically) { + if (leading.isNotEmpty()) { + Text(leading) + } Canvas(modifier = equationModifier) { drawIntoCanvas { canvas -> drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) 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 7192c486a2..37319f0b51 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 @@ -510,7 +510,7 @@ private fun RenderWordWithoutPreview( is SecretEmoji -> Text(word.segmentText) - is MathSegment -> LatexEquation(word.latex, word.displayMode, word.trailing) + is MathSegment -> LatexEquation(word.latex, word.displayMode, word.leading, word.trailing) is PhoneSegment -> ClickablePhone(word.segmentText) @@ -554,7 +554,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, word.trailing) + is MathSegment -> LatexEquation(word.latex, word.displayMode, word.leading, 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 5dbd6b8744..25d5af671a 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 @@ -39,8 +39,10 @@ package com.vitorpamplona.amethyst.commons.richtext * - 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. + * Math glued to alphanumeric text without a separating space (e.g. `a$x$b`) + * stays a single plain word and renders literally. A span glued only to leading + * opening punctuation — a paren, bracket, or quote, as in `($x$)` — is still + * detected: the punctuation rides along as the equation's leading/trailing extras. */ object MathParser { sealed interface Token { @@ -51,14 +53,17 @@ object MathParser { /** * 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. + * formula. [leading] holds any opening punctuation glued right before the + * opening `$` (e.g. the `(` in `($x$)`) and [trailing] any punctuation + * glued right after the closing `$` (e.g. the `.` in `$x$.`), so both + * render 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 leading: String = "", val trailing: String = "", ) : Token } @@ -87,13 +92,46 @@ object MathParser { * Splits [line] on spaces into [Token.Word]s, with any whitespace-delimited * math span surfaced as a [Token.Math]. */ - 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) - } + fun split(line: String): List = splitKeepingMathWhole(line).map(::toToken) + + /** + * Turns one space-delimited [cell] into a token. A `$…$` span at the start of + * the cell — or behind a run of opening punctuation (`(`, `[`, `"`, …) — + * becomes math, with that punctuation kept as [Token.Math.leading] and any + * remainder (e.g. a closing `)` or `.`) as [Token.Math.trailing]. Anything + * else, including alphanumeric-glued math (`a$x$`), stays a plain word. + */ + private fun toToken(cell: String): Token { + val dollar = mathStartIn(cell) ?: return Token.Word(cell) + val math = matchMathAt(cell, dollar) ?: return Token.Word(cell) + return math.copy( + leading = cell.substring(0, dollar), + trailing = cell.substring(dollar + math.raw.length), + ) + } + + /** + * The index of a `$` that may open math in [cell]: index 0, or the first `$` + * when every character before it is opening punctuation. Null otherwise. + */ + private fun mathStartIn(cell: String): Int? { + if (cell.isEmpty() || cell[0] == '$') return 0 + var i = 0 + while (i < cell.length && isOpeningPunctuation(cell[i])) i++ + return if (i in 1 until cell.length && cell[i] == '$') i else null + } + + /** + * Punctuation that commonly opens a wrapped equation in prose. Covers every + * Unicode opening bracket and initial quote (so CJK/fullwidth `「『(` work for + * a global audience) plus the ASCII straight quotes, which Unicode classes as + * generic — not initial — punctuation and so must be named explicitly. + */ + private fun isOpeningPunctuation(c: Char): Boolean = + c == '"' || + c == '\'' || + c.category == CharCategory.START_PUNCTUATION || + c.category == CharCategory.INITIAL_QUOTE_PUNCTUATION /** * Splits [line] on single spaces the way `line.split(' ')` would, except that 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 b5171e8e20..959b8f4a31 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 @@ -245,7 +245,7 @@ class RichTextParser { val segments = MathParser.split(paragraph.trimEnd()).map { token -> when (token) { - is MathParser.Token.Math -> MathSegment(token.raw, token.latex, token.displayMode, token.trailing) + is MathParser.Token.Math -> MathSegment(token.raw, token.latex, token.displayMode, token.leading, token.trailing) is MathParser.Token.Word -> wordIdentifier(token.text, images, videos, pdfs, urls, emojis, tags) } } 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 6b7f6ce993..25d3eae75d 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,14 +175,16 @@ 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. [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. + * just the inner formula that gets handed to the math renderer. [leading] is any + * opening punctuation glued before the opening `$` (e.g. `(` in `($x$)`) and + * [trailing] any punctuation glued after the closing `$` (e.g. `.` in `$x$.`), + * both rendered right next to the equation so they don't drift off behind a space. */ @Immutable class MathSegment( segment: String, val latex: String, val displayMode: Boolean, + val leading: String = "", 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 67dbccf11a..fafd50fdf8 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 @@ -177,4 +177,36 @@ class MathParserTest { assertEquals("a", m[0].latex) assertEquals("b", m[1].latex) } + + @Test + fun parenWrappedMathIsDetected() { + // `($r = 1$)` is the common prose case: the opening paren rides along as + // leading, the closing paren as trailing, and the inner span is math. + val m = math(MathParser.split("translations (${d}r = 1$d) is cyclic")).single() + assertEquals("r = 1", m.latex) + assertEquals("(", m.leading) + assertEquals(")", m.trailing) + } + + @Test + fun bracketAndQuoteWrappedMathAreDetected() { + val tokens = MathParser.split("see [${d}x$d] and \"${d}y$d\" here") + val m = math(tokens) + assertEquals(2, m.size) + assertEquals("x", m[0].latex) + assertEquals("[", m[0].leading) + assertEquals("]", m[0].trailing) + assertEquals("y", m[1].latex) + assertEquals("\"", m[1].leading) + assertEquals("\"", m[1].trailing) + } + + @Test + fun alphanumericGluedPrefixStaysPlainWord() { + // A letter before `$` is not opening punctuation, so the existing + // glued-word behaviour is preserved (no false-firing on `a$x$`). + val tokens = MathParser.split("a${d}x$d" + " end") + assertTrue(math(tokens).isEmpty()) + assertEquals(listOf("a${d}x$d", "end"), tokens.filterIsInstance().map { it.text }) + } }