diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 6fe554fccf..22cd9f66ac 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -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) 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..ea0f43dffc 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 @@ -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()) { 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 }) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 424d1294d6..bc655df85e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }