feat: inline and blocklevel katex in markdown

This commit is contained in:
infi 2026-06-05 22:29:26 +02:00
parent 39a025fcaa
commit a5b7a51fa8
3 changed files with 169 additions and 35 deletions

View File

@ -283,6 +283,8 @@ dependencies {
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)
implementation(libs.ratex.android)
androidTestImplementation(libs.android.test.core) androidTestImplementation(libs.android.test.core)
androidTestImplementation(libs.android.test.rules) androidTestImplementation(libs.android.test.rules)
androidTestImplementation(libs.compose.ui.test.junit4) androidTestImplementation(libs.compose.ui.test.junit4)

View File

@ -7,10 +7,12 @@ import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -18,9 +20,12 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale 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.style.TextDecoration
import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.core.net.toUri import androidx.core.net.toUri
import chat.stoat.R 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.markdownAnnotator
import com.mikepenz.markdown.model.markdownInlineContent import com.mikepenz.markdown.model.markdownInlineContent
import com.mikepenz.markdown.model.rememberMarkdownState import com.mikepenz.markdown.model.rememberMarkdownState
import java.util.concurrent.ConcurrentHashMap
import dev.snipme.highlights.Highlights import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes 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.launch
import kotlinx.coroutines.withContext
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.getTextInNode import org.intellij.markdown.ast.getTextInNode
import org.intellij.markdown.flavours.gfm.GFMElementTypes
import org.intellij.markdown.parser.MarkdownParser import org.intellij.markdown.parser.MarkdownParser
private data class MathEntry(val key: String, val latex: String, val displayMode: Boolean)
private val mathSizeCache = ConcurrentHashMap<Triple<String, Boolean, Float>, Size>()
private fun collectMathEntries(node: ASTNode, content: String): List<MathEntry> {
val entries = mutableListOf<MathEntry>()
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<String> {
val ulids = mutableListOf<String>()
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 // 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 // code blocks and double-newline paragraph breaks untouched, e.g. GitHub style line breaks
internal fun easyLineBreaks(content: String): String { internal fun easyLineBreaks(content: String): String {
@ -144,9 +192,49 @@ fun ChatMarkdown(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val fontSize = LocalTextStyle.current.fontSize * fontSizeMultiplier val fontSize = LocalTextStyle.current.fontSize * fontSizeMultiplier
val context = LocalContext.current
val density = LocalDensity.current
val fontSizePx = with(density) { fontSize.toPx() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val primaryColor = MaterialTheme.colorScheme.primary val primaryColor = MaterialTheme.colorScheme.primary
val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant 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) { val mentionLinkStyle = remember(primaryColor) {
TextLinkStyles( TextLinkStyles(
SpanStyle( SpanStyle(
@ -160,7 +248,20 @@ fun ChatMarkdown(
when (child.type) { when (child.type) {
CUSTOM_EMOTE_ELEMENT_TYPE -> { CUSTOM_EMOTE_ELEMENT_TYPE -> {
val ulid = child.getTextInNode(content).toString().removeSurrounding(":") 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 true
} }
@ -249,7 +350,6 @@ fun ChatMarkdown(
} }
} }
} }
val context = LocalContext.current
val resources = LocalResources.current val resources = LocalResources.current
val toolbarColor = MaterialTheme.colorScheme.surfaceContainer.toArgb() val toolbarColor = MaterialTheme.colorScheme.surfaceContainer.toArgb()
val uriHandler = remember(scope, serverId, toolbarColor) { val uriHandler = remember(scope, serverId, toolbarColor) {
@ -347,42 +447,73 @@ fun ChatMarkdown(
), ),
), ),
inlineContent = markdownInlineContent( inlineContent = markdownInlineContent(
mapOf( buildMap {
"custom_emote" to InlineTextContent( mathEntries.forEach { entry ->
Placeholder( val measured = mathSizes[entry.key]
width = fontSize * 1.5f, val (widthSp, heightSp) = if (measured != null) {
height = fontSize * 1.5f, with(density) { measured.width.toSp() to measured.height.toSp() }
placeholderVerticalAlign = PlaceholderVerticalAlign.Center, } else if (entry.displayMode) {
) fontSize * 16f to fontSize * 3f
) { ulid -> } else {
val emote = StoatAPI.emojiCache[ulid] fontSize * 4f to fontSize * 1.5f
if (emote == null) { }
LaunchedEffect(ulid) { put(entry.key, InlineTextContent(
try { Placeholder(
StoatAPI.emojiCache[ulid] = fetchEmoji(ulid) width = widthSp,
} catch (_: Exception) { 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( components = markdownComponents(
image = {}, image = {},

View File

@ -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" } 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-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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }