mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
fix(math): render inline equations wrapped in opening punctuation
This commit is contained in:
@@ -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)
|
||||
|
||||
+50
-12
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -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)
|
||||
|
||||
+32
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user