mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
Merge pull request #3152 from vitorpamplona/claude/equation-rendering-klegT
Add LaTeX math rendering for $...$ and $$...$$ equations
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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.Row
|
||||
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.Alignment
|
||||
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,
|
||||
trailing: String = "",
|
||||
) {
|
||||
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) {
|
||||
// Couldn't parse — show the raw delimited source so nothing is lost.
|
||||
Text((if (displayMode) "$$$latex$$" else "$$latex$") + trailing)
|
||||
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 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(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TextUnit.isUsable(): Boolean = this != TextUnit.Unspecified && this.type == TextUnitType.Sp
|
||||
@@ -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, word.trailing)
|
||||
|
||||
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, word.trailing)
|
||||
is PhoneSegment -> ClickablePhone(word.segmentText)
|
||||
is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
is HashTagSegment -> HashTag(word, nav)
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Splits a line into the space-delimited words consumed by [RichTextParser],
|
||||
* keeping LaTeX math spans (`$...$` inline, `$$...$$` display) whole even though
|
||||
* they may contain spaces.
|
||||
*
|
||||
* 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:
|
||||
* - 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).
|
||||
*
|
||||
* 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 {
|
||||
/** 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. [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
|
||||
}
|
||||
|
||||
/** 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] 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<String> {
|
||||
if (!mightContainMath(line)) return line.split(' ')
|
||||
|
||||
val cells = ArrayList<String>()
|
||||
val current = StringBuilder()
|
||||
val len = line.length
|
||||
var i = 0
|
||||
while (i < len) {
|
||||
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++
|
||||
}
|
||||
}
|
||||
}
|
||||
cells.add(current.toString())
|
||||
return cells
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
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
|
||||
}
|
||||
}
|
||||
+9
-6
@@ -240,12 +240,15 @@ class RichTextParser {
|
||||
lines.forEach { paragraph ->
|
||||
val isRTL = isArabic(paragraph)
|
||||
|
||||
val wordList = paragraph.trimEnd().split(' ')
|
||||
|
||||
val segments = ArrayList<Segment>(wordList.size)
|
||||
wordList.forEach { word ->
|
||||
segments.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags))
|
||||
}
|
||||
// split() behaves like `line.split(' ')`, but keeps math spans
|
||||
// (`$...$`, `$$...$$`) whole instead of tearing them at internal spaces.
|
||||
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.Word -> wordIdentifier(token.text, images, videos, pdfs, urls, emojis, tags)
|
||||
}
|
||||
}
|
||||
|
||||
paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL))
|
||||
}
|
||||
|
||||
+17
@@ -169,3 +169,20 @@ 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. [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)
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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 constants keeps the test
|
||||
// strings readable instead of peppering them with `\$`.
|
||||
private val d = "$"
|
||||
private val bs = "\\"
|
||||
|
||||
private fun math(tokens: List<Token>) = tokens.filterIsInstance<Token.Math>()
|
||||
|
||||
private fun joinWords(tokens: List<Token>) = tokens.filterIsInstance<Token.Word>().joinToString(" ") { it.text }
|
||||
|
||||
@Test
|
||||
fun simpleInlineMath() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
Token.Word("before"),
|
||||
Token.Math("${d}x_1$d", "x_1", false),
|
||||
Token.Word("after"),
|
||||
),
|
||||
MathParser.split("before ${d}x_1$d after"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayMath() {
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mathWithInternalSpacesStaysWhole() {
|
||||
// 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<Token.Word>().map { it.text },
|
||||
)
|
||||
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)
|
||||
// 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 line = "It costs ${d}5 and ${d}10 total"
|
||||
val tokens = MathParser.split(line)
|
||||
assertTrue(math(tokens).isEmpty())
|
||||
assertEquals(line, joinWords(tokens))
|
||||
}
|
||||
|
||||
@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 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"))
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
+108
@@ -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<MathSegment>().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<HashTagSegment>().size)
|
||||
assertEquals(1, words.filterIsInstance<MathSegment>().size)
|
||||
assertEquals(1, words.filterIsInstance<LinkSegment>().size)
|
||||
assertEquals("physics", words.filterIsInstance<HashTagSegment>().single().hashtag)
|
||||
assertEquals("E=mc^2", words.filterIsInstance<MathSegment>().single().latex)
|
||||
assertEquals("https://nostr.com", words.filterIsInstance<LinkSegment>().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<MathSegment>().size)
|
||||
assertEquals(1, words.filterIsInstance<ImageSegment>().size)
|
||||
assertEquals("https://i.imgur.com/abc.jpg", words.filterIsInstance<ImageSegment>().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<MathSegment>().single()
|
||||
assertEquals("x=0", math.latex)
|
||||
assertEquals(".", math.trailing)
|
||||
assertEquals("done", words.filterIsInstance<HashTagSegment>().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<MathSegment>().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<HashTagSegment>().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<MathSegment>().single().latex)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user