feat: render LaTeX math in notes with $...$ and $$...$$ delimiters

Posts that use the common dollar-delimiter convention (e.g. the
math-academy "Linear Independence" note) now render their formulas
as real equations instead of raw LaTeX text.

- commons: new MathParser tokenizes a line into atomic math spans
  (kept whole, since they contain spaces) interleaved with plain text,
  following the pandoc/remark-math dollar rules so currency like
  "$5 and $10" and escaped "\$" don't false-fire. New MathSegment
  carries the inner LaTeX + display flag through the rich-text pipeline.
- RichTextParser splits math out before the whitespace word-splitter
  when a line might contain math; non-math lines keep the existing path.
- amethyst: LatexEquation renders a MathSegment via JLaTeXMath, tinted
  to the current text color and sized to the font, with a raw-text
  fallback when the formula fails to parse. Wired into both the
  preview and no-preview render paths of RichTextViewer.

Scope: dollar delimiters only, regular (non-markdown) render path.

https://claude.ai/code/session_01N8ZhVv9912DLGNJiErVTR4
This commit is contained in:
Claude
2026-06-08 14:47:56 +00:00
parent f05500792c
commit 1096647191
8 changed files with 433 additions and 5 deletions
+3
View File
@@ -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,99 @@
/*
* 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.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.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,
) {
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) {
Text(if (displayMode) "$$$latex$$" else "$$latex$")
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 sizeModifier = Modifier.size(widthDp, heightDp)
val modifier = if (displayMode) Modifier.horizontalScroll(rememberScrollState()).then(sizeModifier) else sizeModifier
Canvas(modifier = modifier) {
drawIntoCanvas { canvas ->
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
drawable.draw(canvas.nativeCanvas)
}
}
}
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)
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)
is PhoneSegment -> ClickablePhone(word.segmentText)
is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
@@ -0,0 +1,161 @@
/*
* 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
/**
* Extracts LaTeX math spans delimited by `$...$` (inline) and `$$...$$` (display)
* from a single line of text.
*
* Math spans can contain spaces, so this runs *before* the regular whitespace
* word-splitter in [RichTextParser]: it tokenizes a line into atomic math spans
* (kept whole) interleaved with the surrounding plain text (which the caller
* then splits on spaces as usual).
*
* 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).
*/
object MathParser {
sealed interface Token {
/** Plain text run; the caller splits this on spaces. */
data class Text(
val text: String,
) : Token
/** A math span. [raw] includes the `$` delimiters; [latex] is the inner formula. */
data class Math(
val raw: String,
val latex: String,
val displayMode: Boolean,
) : 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] into alternating [Token.Text] and [Token.Math] tokens.
* When no valid math span is found the whole line comes back as a single
* [Token.Text].
*/
fun split(line: String): List<Token> {
if (!mightContainMath(line)) return listOf(Token.Text(line))
val tokens = ArrayList<Token>()
val text = StringBuilder()
val len = line.length
var i = 0
fun flushText() {
if (text.isNotEmpty()) {
tokens.add(Token.Text(text.toString()))
text.clear()
}
}
while (i < len) {
if (line[i] == '$' && !isEscaped(line, i)) {
val match = matchDisplay(line, i) ?: matchInline(line, i)
if (match != null) {
flushText()
tokens.add(match)
i = match.raw.length + i
continue
}
}
text.append(line[i])
i++
}
flushText()
return tokens
}
/** 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
}
}
@@ -239,13 +239,35 @@ class RichTextParser {
lines.forEach { paragraph ->
val isRTL = isArabic(paragraph)
val trimmed = paragraph.trimEnd()
val wordList = paragraph.trimEnd().split(' ')
val segments =
if (MathParser.mightContainMath(trimmed)) {
// Math spans (`$...$`, `$$...$$`) can contain spaces, so pull them out
// as atomic segments before the regular whitespace word-split.
val list = ArrayList<Segment>()
MathParser.split(trimmed).forEach { token ->
when (token) {
is MathParser.Token.Math ->
list.add(MathSegment(token.raw, token.latex, token.displayMode))
val segments = ArrayList<Segment>(wordList.size)
wordList.forEach { word ->
segments.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags))
}
is MathParser.Token.Text ->
token.text.split(' ').forEach { word ->
if (word.isNotEmpty()) {
list.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags))
}
}
}
}
list
} else {
val wordList = trimmed.split(' ')
val list = ArrayList<Segment>(wordList.size)
wordList.forEach { word ->
list.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags))
}
list
}
paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL))
}
@@ -169,3 +169,17 @@ 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.
*/
@Immutable
class MathSegment(
segment: String,
val latex: String,
val displayMode: Boolean,
) : Segment(segment)
@@ -0,0 +1,123 @@
/*
* 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 a constant 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>()
@Test
fun simpleInlineMath() {
val tokens = MathParser.split("before ${d}x_1$d after")
assertEquals(
listOf(
Token.Text("before "),
Token.Math("${d}x_1$d", "x_1", false),
Token.Text(" after"),
),
tokens,
)
}
@Test
fun displayMath() {
val tokens = MathParser.split("eq $d${d}E = mc^2$d$d end")
val m = math(tokens).single()
assertEquals("E = mc^2", m.latex)
assertTrue(m.displayMode)
assertEquals("$d${d}E = mc^2$d$d", m.raw)
}
@Test
fun mathWithInternalSpacesStaysWhole() {
val tokens = MathParser.split("Vectors ${d}A_1, ${bs}ldots, A_n$d are independent")
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)
}
@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 tokens = MathParser.split("It costs ${d}5 and ${d}10 total")
assertTrue(math(tokens).isEmpty())
assertEquals("It costs ${d}5 and ${d}10 total", (tokens.single() as Token.Text).text)
}
@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 noDollarsShortCircuits() {
assertFalse(MathParser.mightContainMath("just regular text"))
val tokens = MathParser.split("just regular text")
assertEquals(listOf(Token.Text("just regular text")), tokens)
}
@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)
}
}
+2
View File
@@ -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" }