mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
Merge pull request #3157 from davotoula/fix/latex-rendering
Inline LaTeX — render wrapped equations, upgrade renderer, baseline alignment
This commit is contained in:
@@ -414,6 +414,8 @@ dependencies {
|
||||
|
||||
// LaTeX math rendering ($...$ and $$...$$ inline equations)
|
||||
implementation(libs.jlatexmath.android)
|
||||
implementation(libs.jlatexmath.font.greek)
|
||||
implementation(libs.jlatexmath.font.cyrillic)
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
@@ -36,6 +36,7 @@ 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.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -53,6 +54,7 @@ import ru.noties.jlatexmath.JLatexMathDrawable
|
||||
fun LatexEquation(
|
||||
latex: String,
|
||||
displayMode: Boolean,
|
||||
leading: String = "",
|
||||
trailing: String = "",
|
||||
) {
|
||||
val color = LocalContentColor.current.toArgb()
|
||||
@@ -86,25 +88,54 @@ 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
|
||||
}
|
||||
|
||||
// Baseline alignment. The parent FlowRow centers each word vertically, so a
|
||||
// tight equation bitmap floats above the line — most of a formula's mass sits
|
||||
// above the baseline, so centering its box lifts the baseline above the text.
|
||||
// We pad the box so that, once centered, the equation's baseline coincides
|
||||
// with a surrounding text word's baseline. With text ascent/descent A/Dsc and
|
||||
// icon total height H and depth-below-baseline Dp, the padded box satisfies
|
||||
// `baseline-from-center = (A - Dsc) / 2` — the same value a Text box has — when
|
||||
// pad = (A - Dsc) - H + 2*Dp (on top if positive, on bottom if negative).
|
||||
val resolvedStyle = LocalTextStyle.current.let { if (fontSize.isUsable()) it else it.copy(fontSize = 16.sp) }
|
||||
val measurer = rememberTextMeasurer()
|
||||
val (drawTopPx, boxHeightPx) =
|
||||
remember(drawable, resolvedStyle) {
|
||||
val text = measurer.measure("Mg", resolvedStyle)
|
||||
val ascent = text.firstBaseline
|
||||
val descent = text.size.height - ascent
|
||||
val iconHeight = drawable.intrinsicHeight.toFloat()
|
||||
val iconDepth = drawable.icon().iconDepth.toFloat()
|
||||
val pad = (ascent - descent) - iconHeight + 2f * iconDepth
|
||||
maxOf(pad, 0f) to (iconHeight + maxOf(pad, 0f) + maxOf(-pad, 0f))
|
||||
}
|
||||
|
||||
val widthDp = with(density) { drawable.intrinsicWidth.toDp() }
|
||||
val heightDp = with(density) { drawable.intrinsicHeight.toDp() }
|
||||
val boxHeightDp = with(density) { boxHeightPx.toDp() }
|
||||
|
||||
// Wide display equations can overflow the column; allow them to scroll
|
||||
// horizontally instead of being clipped.
|
||||
val equationSize = Modifier.size(widthDp, heightDp)
|
||||
val equationSize = Modifier.size(widthDp, boxHeightDp)
|
||||
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 ->
|
||||
val native = canvas.nativeCanvas
|
||||
val checkpoint = native.save()
|
||||
// Position the icon's baseline on the text baseline within the padded box.
|
||||
native.translate(0f, drawTopPx)
|
||||
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
|
||||
drawable.draw(canvas.nativeCanvas)
|
||||
drawable.draw(native)
|
||||
native.restoreToCount(checkpoint)
|
||||
}
|
||||
}
|
||||
if (trailing.isNotEmpty()) {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ genaiImageDescription = "1.0.0-beta1"
|
||||
languageId = "17.0.6"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
lightcompressor-enhanced = "2.2.1"
|
||||
jlatexmath = "0.2.0"
|
||||
jlatexmath = "1.4"
|
||||
markdown = "f92ef49c9d"
|
||||
material3 = "1.9.0"
|
||||
media3 = "1.10.1"
|
||||
@@ -176,7 +176,9 @@ 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" }
|
||||
jlatexmath-android = { module = "com.github.rikkahub.jlatexmath-android:jlatexmath", version.ref = "jlatexmath" }
|
||||
jlatexmath-font-greek = { module = "com.github.rikkahub.jlatexmath-android:jlatexmath-font-greek", version.ref = "jlatexmath" }
|
||||
jlatexmath-font-cyrillic = { module = "com.github.rikkahub.jlatexmath-android:jlatexmath-font-cyrillic", 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