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.network.okhttp)
implementation(libs.ratex.android)
androidTestImplementation(libs.android.test.core)
androidTestImplementation(libs.android.test.rules)
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.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<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
// 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 = {},

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" }
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" }