fix(math): render inline equations wrapped in opening punctuation

This commit is contained in:
davotoula
2026-06-09 19:51:31 +02:00
parent fb31d5440a
commit 711ca547b5
6 changed files with 97 additions and 21 deletions
@@ -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)
@@ -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)
@@ -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<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)
}
fun split(line: String): List<Token> = 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
@@ -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)
}
}
@@ -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)
@@ -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<Token.Word>().map { it.text })
}
}