From a5b7a51fa843bfc44de89fea3b17e841ee0da2e4 Mon Sep 17 00:00:00 2001 From: infi Date: Fri, 5 Jun 2026 22:29:26 +0200 Subject: [PATCH] feat: inline and blocklevel katex in markdown --- app/build.gradle.kts | 2 + .../markdown/prose/ChatMarkdown.kt | 201 +++++++++++++++--- gradle/libs.versions.toml | 1 + 3 files changed, 169 insertions(+), 35 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7f4d9375..18042276 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -283,6 +283,8 @@ dependencies { implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) + implementation(libs.ratex.android) + androidTestImplementation(libs.android.test.core) androidTestImplementation(libs.android.test.rules) androidTestImplementation(libs.compose.ui.test.junit4) diff --git a/app/src/main/java/chat/stoat/composables/markdown/prose/ChatMarkdown.kt b/app/src/main/java/chat/stoat/composables/markdown/prose/ChatMarkdown.kt index 2e6a6846..48809d25 100644 --- a/app/src/main/java/chat/stoat/composables/markdown/prose/ChatMarkdown.kt +++ b/app/src/main/java/chat/stoat/composables/markdown/prose/ChatMarkdown.kt @@ -7,10 +7,12 @@ import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -18,9 +20,12 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale @@ -37,6 +42,7 @@ import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withStyle +import androidx.compose.ui.viewinterop.AndroidView import androidx.core.graphics.toColorInt import androidx.core.net.toUri import chat.stoat.R @@ -70,12 +76,54 @@ import com.mikepenz.markdown.model.State import com.mikepenz.markdown.model.markdownAnnotator import com.mikepenz.markdown.model.markdownInlineContent import com.mikepenz.markdown.model.rememberMarkdownState +import java.util.concurrent.ConcurrentHashMap import dev.snipme.highlights.Highlights import dev.snipme.highlights.model.SyntaxThemes +import io.ratex.RaTeXEngine +import io.ratex.RaTeXFontLoader +import io.ratex.RaTeXRenderer +import io.ratex.RaTeXView +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.intellij.markdown.ast.ASTNode import org.intellij.markdown.ast.getTextInNode +import org.intellij.markdown.flavours.gfm.GFMElementTypes import org.intellij.markdown.parser.MarkdownParser +private data class MathEntry(val key: String, val latex: String, val displayMode: Boolean) + +private val mathSizeCache = ConcurrentHashMap, Size>() + +private fun collectMathEntries(node: ASTNode, content: String): List { + val entries = mutableListOf() + node.children.forEach { child -> + when (child.type) { + GFMElementTypes.INLINE_MATH -> { + val latex = child.getTextInNode(content).toString().removeSurrounding("$") + entries += MathEntry("math:i:$latex", latex, false) + } + GFMElementTypes.BLOCK_MATH -> { + val latex = child.getTextInNode(content).toString().removeSurrounding("$$").trim() + entries += MathEntry("math:b:$latex", latex, true) + } + else -> entries += collectMathEntries(child, content) + } + } + return entries +} + +private fun collectEmoteUlids(node: ASTNode, content: String): List { + val ulids = mutableListOf() + node.children.forEach { child -> + when (child.type) { + CUSTOM_EMOTE_ELEMENT_TYPE -> ulids += child.getTextInNode(content).toString().removeSurrounding(":") + else -> ulids += collectEmoteUlids(child, content) + } + } + return ulids +} + // Converts single \n to hard line breaks so chat messages render line by line, while leaving fenced // code blocks and double-newline paragraph breaks untouched, e.g. GitHub style line breaks internal fun easyLineBreaks(content: String): String { @@ -144,9 +192,49 @@ fun ChatMarkdown( modifier: Modifier = Modifier ) { val fontSize = LocalTextStyle.current.fontSize * fontSizeMultiplier + val context = LocalContext.current + val density = LocalDensity.current + val fontSizePx = with(density) { fontSize.toPx() } val scope = rememberCoroutineScope() val primaryColor = MaterialTheme.colorScheme.primary val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant + val onSurfaceArgb = MaterialTheme.colorScheme.onSurface.toArgb() + val mathEntries = remember(state) { + (state as? State.Success)?.let { + collectMathEntries(it.node, it.content).distinctBy { e -> e.key } + } ?: emptyList() + } + val emoteUlids = remember(state) { + (state as? State.Success)?.let { + collectEmoteUlids(it.node, it.content).distinct() + } ?: emptyList() + } + var mathSizes by remember(mathEntries, fontSizePx) { + mutableStateOf(buildMap { + mathEntries.forEach { entry -> + mathSizeCache[Triple(entry.latex, entry.displayMode, fontSizePx)]?.let { put(entry.key, it) } + } + }) + } + val uncachedEntries = mathEntries.filter { mathSizeCache[Triple(it.latex, it.displayMode, fontSizePx)] == null } + LaunchedEffect(mathEntries, fontSizePx) { + if (uncachedEntries.isEmpty()) return@LaunchedEffect + withContext(Dispatchers.IO) { RaTeXFontLoader.ensureLoaded(context) } + val newSizes = withContext(Dispatchers.Default) { + buildMap { + uncachedEntries.forEach { entry -> + try { + val dl = RaTeXEngine.parseBlocking(entry.latex, entry.displayMode, onSurfaceArgb) + val r = RaTeXRenderer(dl, fontSizePx) { RaTeXFontLoader.getTypeface(it) } + val size = Size(r.widthPx, r.totalHeightPx) + mathSizeCache[Triple(entry.latex, entry.displayMode, fontSizePx)] = size + put(entry.key, size) + } catch (_: Exception) { } + } + } + } + if (newSizes.isNotEmpty()) mathSizes = mathSizes + newSizes + } val mentionLinkStyle = remember(primaryColor) { TextLinkStyles( SpanStyle( @@ -160,7 +248,20 @@ fun ChatMarkdown( when (child.type) { CUSTOM_EMOTE_ELEMENT_TYPE -> { val ulid = child.getTextInNode(content).toString().removeSurrounding(":") - appendInlineContent("custom_emote", ulid) + val name = StoatAPI.emojiCache[ulid]?.name ?: ":$ulid:" + appendInlineContent("emote:$ulid", name) + true + } + + GFMElementTypes.INLINE_MATH -> { + val latex = child.getTextInNode(content).toString().removeSurrounding("$") + appendInlineContent("math:i:$latex", latex) + true + } + + GFMElementTypes.BLOCK_MATH -> { + val latex = child.getTextInNode(content).toString().removeSurrounding("$$").trim() + appendInlineContent("math:b:$latex", latex) true } @@ -249,7 +350,6 @@ fun ChatMarkdown( } } } - val context = LocalContext.current val resources = LocalResources.current val toolbarColor = MaterialTheme.colorScheme.surfaceContainer.toArgb() val uriHandler = remember(scope, serverId, toolbarColor) { @@ -347,42 +447,73 @@ fun ChatMarkdown( ), ), inlineContent = markdownInlineContent( - mapOf( - "custom_emote" to InlineTextContent( - Placeholder( - width = fontSize * 1.5f, - height = fontSize * 1.5f, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, - ) - ) { ulid -> - val emote = StoatAPI.emojiCache[ulid] - if (emote == null) { - LaunchedEffect(ulid) { - try { - StoatAPI.emojiCache[ulid] = fetchEmoji(ulid) - } catch (_: Exception) { + buildMap { + mathEntries.forEach { entry -> + val measured = mathSizes[entry.key] + val (widthSp, heightSp) = if (measured != null) { + with(density) { measured.width.toSp() to measured.height.toSp() } + } else if (entry.displayMode) { + fontSize * 16f to fontSize * 3f + } else { + fontSize * 4f to fontSize * 1.5f + } + put(entry.key, InlineTextContent( + Placeholder( + width = widthSp, + height = heightSp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ) + ) { latex -> + val foreground = LocalContentColor.current + AndroidView( + factory = { ctx -> RaTeXView(ctx) }, + update = { view -> + view.displayMode = entry.displayMode + view.latex = latex + view.fontSize = with(density) { fontSize.toPx().toDp().value } + view.color = foreground.toArgb() + }, + modifier = Modifier.fillMaxSize(), + ) + }) + } + emoteUlids.forEach { ulid -> + put("emote:$ulid", InlineTextContent( + Placeholder( + width = fontSize * 1.5f, + height = fontSize * 1.5f, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ) + ) { _ -> + val emote = StoatAPI.emojiCache[ulid] + if (emote == null) { + LaunchedEffect(ulid) { + try { + StoatAPI.emojiCache[ulid] = fetchEmoji(ulid) + } catch (_: Exception) { + } + } + } else { + with(LocalDensity.current) { + RemoteImage( + url = "$STOAT_FILES/emojis/$ulid", + description = emote.name, + contentScale = ContentScale.Fit, + modifier = Modifier + .width((fontSize * 1.5f).toDp()) + .height((fontSize * 1.5f).toDp()) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + scope.launch { ActionChannel.send(Action.EmoteInfo(ulid)) } + }, + ) } } - } else { - with(LocalDensity.current) { - RemoteImage( - url = "$STOAT_FILES/emojis/$ulid", - description = emote.name, - contentScale = ContentScale.Fit, - modifier = Modifier - .width((fontSize * 1.5f).toDp()) - .height((fontSize * 1.5f).toDp()) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { - scope.launch { ActionChannel.send(Action.EmoteInfo(ulid)) } - }, - ) - } - } + }) } - ) + } ), components = markdownComponents( image = {}, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2faa37cc..4dcd20bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -109,6 +109,7 @@ multiplatform-markdown-coil3 = { module = "com.mikepenz:multiplatform-markdown-r multiplatform-markdown-code = { module = "com.mikepenz:multiplatform-markdown-renderer-code", version.ref = "multiplatform-markdown" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } +ratex-android = { module = "io.github.erweixin:ratex-android", version = "0.1.11" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }