fix: iOS LaTeX rendering by extracting shared markdown parser to commonMain

This commit is contained in:
Garfie 2026-04-06 19:32:54 -05:00
parent 4d2a074f56
commit fccc0ba0bb
4 changed files with 441 additions and 574 deletions

View file

@ -206,7 +206,7 @@ actual fun MarkdownContent(
occurrenceOffset += segmentOccurrences
} else {
val hasCitations = remember(segment.text) {
segment.text.contains(CITATION_REGEX)
segment.text.contains(CITATION_DETECT_REGEX)
}
if (hasCitations) {
CitationText(
@ -326,420 +326,6 @@ private fun MarkdownTextSegment(
}
}
internal sealed interface MarkdownSegment {
data class TextBlock(val text: String) : MarkdownSegment
data class CodeBlock(val code: String, val language: String?) : MarkdownSegment
data class LatexBlock(val latex: String) : MarkdownSegment
data class InlineLatexText(val segments: List<InlineSegment>) : MarkdownSegment
data class Table(
val headers: List<String>,
val alignments: List<TableCellAlignment>,
val rows: List<List<String>>,
) : MarkdownSegment
}
/** Column alignment parsed from the separator row of a markdown table. */
internal enum class TableCellAlignment {
LEFT, CENTER, RIGHT,
}
internal sealed interface InlineSegment {
data class Text(val text: String) : InlineSegment
data class Latex(val latex: String) : InlineSegment
}
internal sealed interface InlineSpan {
val text: String
data class Plain(override val text: String) : InlineSpan
data class Bold(override val text: String) : InlineSpan
data class Italic(override val text: String) : InlineSpan
data class InlineCode(override val text: String) : InlineSpan
}
// Pre-compiled regex patterns for markdown/LaTeX parsing (avoid recreating on every call)
private val CODE_BLOCK_REGEX = Regex("```(\\w*)[^\\S\\n]*\\n([\\s\\S]*?)```")
private val BLOCK_LATEX_REGEX = Regex("\\$\\$([\\s\\S]+?)\\$\\$|\\\\\\[([\\s\\S]+?)\\\\\\]")
private val INLINE_LATEX_REGEX = Regex("(?<!\\$)\\$(?!\\$)(.+?)(?<!\\$)\\$(?!\\$)|\\\\\\((.+?)\\\\\\)")
private val INLINE_MARKDOWN_REGEX = Regex("`([^`]+)`|\\*\\*(.+?)\\*\\*|\\*(.+?)\\*")
private val CITATION_REGEX = Regex("""\[\d+]|\u3010\d+\u2020""")
/**
* Matches a GFM-style markdown table separator row. The separator must contain only
* pipes, dashes, colons (for alignment), and whitespace.
*/
private val TABLE_SEPARATOR_REGEX = Regex("^\\|?\\s*:?-{1,}:?\\s*(\\|\\s*:?-{1,}:?\\s*)*\\|?$")
/**
* HTML tag names that indicate a raw HTML block when found at the start of a TextBlock.
* These are block-level elements that LLMs commonly produce when generating web pages.
*/
private val HTML_BLOCK_TAG_NAMES = setOf(
"html", "head", "body", "div", "section", "article", "main", "header", "footer",
"nav", "aside", "table", "form", "fieldset", "details", "dialog", "figure",
"figcaption", "template", "canvas", "svg", "video", "audio", "iframe",
"p", "ul", "ol", "li", "dl", "dt", "dd", "pre", "blockquote", "hr",
)
/**
* Matches an opening HTML tag at the start of a string (after optional whitespace).
* Group 1 captures the tag name. Case-insensitive.
*/
private val HTML_OPENING_TAG_REGEX = Regex(
"^\\s*<(!DOCTYPE\\s+html|\\w+)",
RegexOption.IGNORE_CASE,
)
/** Characters/keywords that indicate LaTeX content rather than a dollar amount. */
private val LATEX_INDICATORS = setOf('\\', '^', '_', '{', '}')
private val LATEX_KEYWORDS = listOf("frac", "sqrt", "sum", "int", "lim", "infty", "alpha", "beta")
/** Returns true if the content looks like LaTeX rather than a dollar amount. */
private fun looksLikeLatex(content: String): Boolean {
if (content.any { it in LATEX_INDICATORS }) return true
return LATEX_KEYWORDS.any { keyword -> content.contains(keyword) }
}
/**
* Returns true if the text appears to be a raw HTML block that should be rendered
* as a code block rather than passed to the markdown renderer (which would garble it).
*
* Heuristic: starts with an HTML tag whose name is in [HTML_BLOCK_TAG_NAMES] or is
* a DOCTYPE declaration, AND contains at least one closing tag or is self-contained.
*/
private fun looksLikeHtmlBlock(text: String): Boolean {
val match = HTML_OPENING_TAG_REGEX.find(text) ?: return false
val tagName = match.groupValues[1].lowercase().let {
// Normalize "!doctype html" to "html"
if (it.startsWith("!doctype")) "html" else it
}
if (tagName !in HTML_BLOCK_TAG_NAMES) return false
// Require a closing tag or self-closing indicator to avoid false positives
// on lines that just happen to start with "<p" etc.
val hasClosingTag = text.contains("</$tagName", ignoreCase = true) ||
text.contains("/>")
return hasClosingTag
}
/**
* Scans TextBlock segments for raw HTML blocks and converts them to CodeBlock
* segments with language="html". This prevents the mikepenz markdown renderer
* from silently dropping HTML tags.
*
* A TextBlock is treated as a raw HTML block if [looksLikeHtmlBlock] returns true.
* Mixed content (text + HTML) is split: lines before the HTML become a TextBlock,
* the HTML block becomes a CodeBlock, and lines after become a TextBlock.
*/
private fun extractHtmlBlocks(segments: List<MarkdownSegment>): List<MarkdownSegment> {
val result = mutableListOf<MarkdownSegment>()
for (segment in segments) {
if (segment !is MarkdownSegment.TextBlock) {
result.add(segment)
continue
}
val text = segment.text
if (looksLikeHtmlBlock(text)) {
// Entire block is HTML -- render as a code block
result.add(MarkdownSegment.CodeBlock(text.trim(), "html"))
continue
}
// Check if the block contains an embedded HTML section starting on its own line.
// Split into lines and find the first line that opens an HTML block.
val lines = text.split('\n')
var htmlStart = -1
for (i in lines.indices) {
val lineText = lines.subList(i, lines.size).joinToString("\n")
if (looksLikeHtmlBlock(lineText)) {
htmlStart = i
break
}
}
if (htmlStart < 0) {
// No HTML found; keep as-is
result.add(segment)
continue
}
// Flush preceding text lines
if (htmlStart > 0) {
val preceding = lines.subList(0, htmlStart).joinToString("\n").trim()
if (preceding.isNotEmpty()) {
result.add(MarkdownSegment.TextBlock(preceding))
}
}
// The rest from htmlStart onward is the HTML block
val htmlContent = lines.subList(htmlStart, lines.size).joinToString("\n").trim()
result.add(MarkdownSegment.CodeBlock(htmlContent, "html"))
}
return result
}
/**
* Splits raw text into code-block, LaTeX-block, table, inline-LaTeX, and text-block segments.
*
* Parsing order:
* 1. Extract fenced code blocks (``` ... ```)
* 2. Detect raw HTML blocks in remaining TextBlocks and convert to CodeBlocks
* 3. Within non-code segments, extract block LaTeX (`$$...$$`)
* 4. Within remaining text blocks, extract GFM-style tables
* 5. Within remaining text, detect inline LaTeX (`$...$`) and split accordingly
*/
internal fun parseMarkdownSegments(text: String): List<MarkdownSegment> {
// --- Pass 1: split on fenced code blocks ---
val afterCodeBlocks = mutableListOf<MarkdownSegment>()
var lastIndex = 0
CODE_BLOCK_REGEX.findAll(text).forEach { match ->
if (match.range.first > lastIndex) {
val textBefore = text.substring(lastIndex, match.range.first).trim()
if (textBefore.isNotEmpty()) {
afterCodeBlocks.add(MarkdownSegment.TextBlock(textBefore))
}
}
val language = match.groupValues[1].ifEmpty { null }
val code = match.groupValues[2].trimEnd()
val langLower = language?.lowercase()
if (langLower == "latex" || langLower == "tex" || langLower == "math") {
// Treat latex/tex/math code blocks as text so LaTeX extraction
// passes handle $...$ and $$...$$ delimiters properly
afterCodeBlocks.add(MarkdownSegment.TextBlock(code))
} else {
afterCodeBlocks.add(MarkdownSegment.CodeBlock(code, language))
}
lastIndex = match.range.last + 1
}
if (lastIndex < text.length) {
val remaining = text.substring(lastIndex).trim()
if (remaining.isNotEmpty()) {
afterCodeBlocks.add(MarkdownSegment.TextBlock(remaining))
}
}
if (afterCodeBlocks.isEmpty() && text.isNotBlank()) {
afterCodeBlocks.add(MarkdownSegment.TextBlock(text))
}
// --- Pass 2: detect raw HTML blocks and convert to CodeBlocks ---
val afterHtmlBlocks = extractHtmlBlocks(afterCodeBlocks)
// --- Pass 3: split TextBlocks on block LaTeX ($$...$$ or \[...\]) ---
val afterBlockLatex = mutableListOf<MarkdownSegment>()
for (segment in afterHtmlBlocks) {
if (segment !is MarkdownSegment.TextBlock) {
afterBlockLatex.add(segment)
continue
}
var segLastIndex = 0
val segText = segment.text
BLOCK_LATEX_REGEX.findAll(segText).forEach { match ->
if (match.range.first > segLastIndex) {
val before = segText.substring(segLastIndex, match.range.first).trim()
if (before.isNotEmpty()) {
afterBlockLatex.add(MarkdownSegment.TextBlock(before))
}
}
val latexContent = (match.groupValues[1].ifEmpty { match.groupValues[2] }).trim()
if (latexContent.isNotEmpty()) {
afterBlockLatex.add(MarkdownSegment.LatexBlock(latexContent))
}
segLastIndex = match.range.last + 1
}
if (segLastIndex < segText.length) {
val remaining = segText.substring(segLastIndex).trim()
if (remaining.isNotEmpty()) {
afterBlockLatex.add(MarkdownSegment.TextBlock(remaining))
}
} else if (segLastIndex == 0) {
// No block LaTeX found; keep original segment
afterBlockLatex.add(segment)
}
}
// --- Pass 4: extract markdown tables from TextBlocks ---
val afterTables = mutableListOf<MarkdownSegment>()
for (segment in afterBlockLatex) {
if (segment !is MarkdownSegment.TextBlock) {
afterTables.add(segment)
continue
}
afterTables.addAll(extractTables(segment.text))
}
// --- Pass 5: detect inline LaTeX ($...$ or \(...\)) within remaining TextBlocks ---
val finalSegments = mutableListOf<MarkdownSegment>()
for (segment in afterTables) {
if (segment !is MarkdownSegment.TextBlock) {
finalSegments.add(segment)
continue
}
val matches = INLINE_LATEX_REGEX.findAll(segment.text)
.filter { match ->
// \(...\) is always LaTeX; $...$ needs heuristic check
val dollarContent = match.groupValues[1]
val parenContent = match.groupValues[2]
if (parenContent.isNotBlank()) {
true
} else {
dollarContent.isNotBlank() && looksLikeLatex(dollarContent)
}
}
.toList()
if (matches.isEmpty()) {
finalSegments.add(segment)
continue
}
// Build mixed inline segments
val inlineSegments = mutableListOf<InlineSegment>()
var segLastIndex = 0
val segText = segment.text
for (match in matches) {
if (match.range.first > segLastIndex) {
val before = segText.substring(segLastIndex, match.range.first)
if (before.isNotEmpty()) {
inlineSegments.add(InlineSegment.Text(before))
}
}
val content = (match.groupValues[1].ifEmpty { match.groupValues[2] }).trim()
inlineSegments.add(InlineSegment.Latex(content))
segLastIndex = match.range.last + 1
}
if (segLastIndex < segText.length) {
val remaining = segText.substring(segLastIndex)
if (remaining.isNotEmpty()) {
inlineSegments.add(InlineSegment.Text(remaining))
}
}
finalSegments.add(MarkdownSegment.InlineLatexText(inlineSegments))
}
return finalSegments
}
/**
* Extracts markdown tables from a text block, splitting it into alternating
* TextBlock and Table segments. A valid table requires:
* - A header row with pipe-delimited cells
* - A separator row matching [TABLE_SEPARATOR_REGEX]
* - At least one data row
*/
private fun extractTables(text: String): List<MarkdownSegment> {
val lines = text.split('\n')
val result = mutableListOf<MarkdownSegment>()
val buffer = mutableListOf<String>()
var i = 0
while (i < lines.size) {
// Check if lines[i] could be a table header row and lines[i+1] a separator
if (i + 2 < lines.size && isTableRow(lines[i]) && isTableSeparator(lines[i + 1])) {
// Flush any buffered text before the table
if (buffer.isNotEmpty()) {
val preceding = buffer.joinToString("\n").trim()
if (preceding.isNotEmpty()) {
result.add(MarkdownSegment.TextBlock(preceding))
}
buffer.clear()
}
val headerCells = parseTableRow(lines[i])
val alignments = parseAlignments(lines[i + 1], headerCells.size)
val dataRows = mutableListOf<List<String>>()
var j = i + 2
while (j < lines.size && isTableRow(lines[j])) {
val rowCells = parseTableRow(lines[j])
// Pad or truncate to match header column count
val normalized = List(headerCells.size) { col ->
rowCells.getOrElse(col) { "" }
}
dataRows.add(normalized)
j++
}
if (dataRows.isNotEmpty()) {
result.add(MarkdownSegment.Table(headerCells, alignments, dataRows))
i = j
} else {
// Not a valid table (no data rows); treat header+separator as text
buffer.add(lines[i])
buffer.add(lines[i + 1])
i += 2
}
} else {
buffer.add(lines[i])
i++
}
}
// Flush remaining buffered text
if (buffer.isNotEmpty()) {
val remaining = buffer.joinToString("\n").trim()
if (remaining.isNotEmpty()) {
result.add(MarkdownSegment.TextBlock(remaining))
}
}
return result
}
/**
* Returns true if the line looks like a pipe-delimited table row.
* Requires either leading/trailing pipes or at least two pipe characters,
* to avoid false positives on prose that contains a single `|`.
*/
private fun isTableRow(line: String): Boolean {
val trimmed = line.trim()
if (trimmed.isEmpty()) return false
return trimmed.startsWith('|') || trimmed.endsWith('|') || trimmed.count { it == '|' } >= 2
}
/** Returns true if the line matches a GFM table separator pattern. */
private fun isTableSeparator(line: String): Boolean {
return TABLE_SEPARATOR_REGEX.matches(line.trim())
}
/** Splits a pipe-delimited table row into trimmed cell values. */
private fun parseTableRow(line: String): List<String> {
val trimmed = line.trim()
// Remove leading and trailing pipes if present
val inner = trimmed.removePrefix("|").removeSuffix("|")
return inner.split('|').map { it.trim() }
}
/**
* Parses alignment indicators from a separator row.
* `:---` = LEFT, `:---:` = CENTER, `---:` = RIGHT, `---` = LEFT (default)
*/
private fun parseAlignments(separatorLine: String, columnCount: Int): List<TableCellAlignment> {
val cells = parseTableRow(separatorLine)
return List(columnCount) { col ->
val cell = cells.getOrElse(col) { "---" }.trim()
val startsColon = cell.startsWith(':')
val endsColon = cell.endsWith(':')
when {
startsColon && endsColon -> TableCellAlignment.CENTER
endsColon -> TableCellAlignment.RIGHT
else -> TableCellAlignment.LEFT
}
}
}
/**
* Wraps a [MarkdownTable] in a [Box] with a fullscreen expand button overlaid on
@ -1005,32 +591,3 @@ private fun TableCell(
}
}
/**
* Parses inline markdown (bold, italic, inline code) within a text block.
* Calls [onSpan] for each discovered span.
*/
internal fun parseInlineMarkdown(text: String, onSpan: (InlineSpan) -> Unit) {
var lastIndex = 0
INLINE_MARKDOWN_REGEX.findAll(text).forEach { match ->
if (match.range.first > lastIndex) {
onSpan(InlineSpan.Plain(text.substring(lastIndex, match.range.first)))
}
when {
match.groupValues[1].isNotEmpty() -> {
onSpan(InlineSpan.InlineCode(match.groupValues[1]))
}
match.groupValues[2].isNotEmpty() -> {
onSpan(InlineSpan.Bold(match.groupValues[2]))
}
match.groupValues[3].isNotEmpty() -> {
onSpan(InlineSpan.Italic(match.groupValues[3]))
}
}
lastIndex = match.range.last + 1
}
if (lastIndex < text.length) {
onSpan(InlineSpan.Plain(text.substring(lastIndex)))
}
}

View file

@ -0,0 +1,329 @@
package com.garfiec.librechat.feature.chat.components
// Shared markdown + LaTeX parsing logic used by both Android and iOS MarkdownContent.
internal sealed interface MarkdownSegment {
data class TextBlock(val text: String) : MarkdownSegment
data class CodeBlock(val code: String, val language: String?) : MarkdownSegment
data class LatexBlock(val latex: String) : MarkdownSegment
data class InlineLatexText(val segments: List<InlineSegment>) : MarkdownSegment
data class Table(
val headers: List<String>,
val alignments: List<TableCellAlignment>,
val rows: List<List<String>>,
) : MarkdownSegment
}
internal enum class TableCellAlignment {
LEFT, CENTER, RIGHT,
}
internal sealed interface InlineSegment {
data class Text(val text: String) : InlineSegment
data class Latex(val latex: String) : InlineSegment
}
// Pre-compiled regex patterns
private val CODE_BLOCK_REGEX = Regex("```(\\w*)[^\\S\\n]*\\n([\\s\\S]*?)```")
private val BLOCK_LATEX_REGEX = Regex("\\$\\$([\\s\\S]+?)\\$\\$|\\\\\\[([\\s\\S]+?)\\\\\\]")
private val INLINE_LATEX_REGEX = Regex("(?<!\\$)\\$(?!\\$)(.+?)(?<!\\$)\\$(?!\\$)|\\\\\\((.+?)\\\\\\)")
internal val CITATION_DETECT_REGEX = Regex("""\[\d+]|\u3010\d+\u2020""")
private val TABLE_SEPARATOR_REGEX = Regex("^\\|?\\s*:?-{1,}:?\\s*(\\|\\s*:?-{1,}:?\\s*)*\\|?$")
private val HTML_BLOCK_TAG_NAMES = setOf(
"html", "head", "body", "div", "section", "article", "main", "header", "footer",
"nav", "aside", "table", "form", "fieldset", "details", "dialog", "figure",
"figcaption", "template", "canvas", "svg", "video", "audio", "iframe",
"p", "ul", "ol", "li", "dl", "dt", "dd", "pre", "blockquote", "hr",
)
private val HTML_OPENING_TAG_REGEX = Regex(
"^\\s*<(!DOCTYPE\\s+html|\\w+)",
RegexOption.IGNORE_CASE,
)
private val LATEX_INDICATORS = setOf('\\', '^', '_', '{', '}')
private val LATEX_KEYWORDS = listOf("frac", "sqrt", "sum", "int", "lim", "infty", "alpha", "beta")
internal fun looksLikeLatex(content: String): Boolean {
if (content.any { it in LATEX_INDICATORS }) return true
return LATEX_KEYWORDS.any { keyword -> content.contains(keyword) }
}
private fun looksLikeHtmlBlock(text: String): Boolean {
val match = HTML_OPENING_TAG_REGEX.find(text) ?: return false
val tagName = match.groupValues[1].lowercase().let {
if (it.startsWith("!doctype")) "html" else it
}
if (tagName !in HTML_BLOCK_TAG_NAMES) return false
val hasClosingTag = text.contains("</$tagName", ignoreCase = true) ||
text.contains("/>")
return hasClosingTag
}
private fun extractHtmlBlocks(segments: List<MarkdownSegment>): List<MarkdownSegment> {
val result = mutableListOf<MarkdownSegment>()
for (segment in segments) {
if (segment !is MarkdownSegment.TextBlock) {
result.add(segment)
continue
}
val text = segment.text
if (looksLikeHtmlBlock(text)) {
result.add(MarkdownSegment.CodeBlock(text.trim(), "html"))
continue
}
val lines = text.split('\n')
var htmlStart = -1
for (i in lines.indices) {
val lineText = lines.subList(i, lines.size).joinToString("\n")
if (looksLikeHtmlBlock(lineText)) {
htmlStart = i
break
}
}
if (htmlStart < 0) {
result.add(segment)
continue
}
if (htmlStart > 0) {
val preceding = lines.subList(0, htmlStart).joinToString("\n").trim()
if (preceding.isNotEmpty()) {
result.add(MarkdownSegment.TextBlock(preceding))
}
}
val htmlContent = lines.subList(htmlStart, lines.size).joinToString("\n").trim()
result.add(MarkdownSegment.CodeBlock(htmlContent, "html"))
}
return result
}
/**
* 5-phase markdown parser that extracts code blocks, HTML blocks, block LaTeX,
* tables, and inline LaTeX from raw text.
*/
internal fun parseMarkdownSegments(text: String): List<MarkdownSegment> {
// --- Pass 1: split on fenced code blocks ---
val afterCodeBlocks = mutableListOf<MarkdownSegment>()
var lastIndex = 0
CODE_BLOCK_REGEX.findAll(text).forEach { match ->
if (match.range.first > lastIndex) {
val textBefore = text.substring(lastIndex, match.range.first).trim()
if (textBefore.isNotEmpty()) {
afterCodeBlocks.add(MarkdownSegment.TextBlock(textBefore))
}
}
val language = match.groupValues[1].ifEmpty { null }
val code = match.groupValues[2].trimEnd()
val langLower = language?.lowercase()
if (langLower == "latex" || langLower == "tex" || langLower == "math") {
afterCodeBlocks.add(MarkdownSegment.TextBlock(code))
} else {
afterCodeBlocks.add(MarkdownSegment.CodeBlock(code, language))
}
lastIndex = match.range.last + 1
}
if (lastIndex < text.length) {
val remaining = text.substring(lastIndex).trim()
if (remaining.isNotEmpty()) {
afterCodeBlocks.add(MarkdownSegment.TextBlock(remaining))
}
}
if (afterCodeBlocks.isEmpty() && text.isNotBlank()) {
afterCodeBlocks.add(MarkdownSegment.TextBlock(text))
}
// --- Pass 2: detect raw HTML blocks and convert to CodeBlocks ---
val afterHtmlBlocks = extractHtmlBlocks(afterCodeBlocks)
// --- Pass 3: split TextBlocks on block LaTeX ($$...$$ or \[...\]) ---
val afterBlockLatex = mutableListOf<MarkdownSegment>()
for (segment in afterHtmlBlocks) {
if (segment !is MarkdownSegment.TextBlock) {
afterBlockLatex.add(segment)
continue
}
var segLastIndex = 0
val segText = segment.text
BLOCK_LATEX_REGEX.findAll(segText).forEach { match ->
if (match.range.first > segLastIndex) {
val before = segText.substring(segLastIndex, match.range.first).trim()
if (before.isNotEmpty()) {
afterBlockLatex.add(MarkdownSegment.TextBlock(before))
}
}
val latexContent = (match.groupValues[1].ifEmpty { match.groupValues[2] }).trim()
if (latexContent.isNotEmpty()) {
afterBlockLatex.add(MarkdownSegment.LatexBlock(latexContent))
}
segLastIndex = match.range.last + 1
}
if (segLastIndex < segText.length) {
val remaining = segText.substring(segLastIndex).trim()
if (remaining.isNotEmpty()) {
afterBlockLatex.add(MarkdownSegment.TextBlock(remaining))
}
} else if (segLastIndex == 0) {
afterBlockLatex.add(segment)
}
}
// --- Pass 4: extract markdown tables from TextBlocks ---
val afterTables = mutableListOf<MarkdownSegment>()
for (segment in afterBlockLatex) {
if (segment !is MarkdownSegment.TextBlock) {
afterTables.add(segment)
continue
}
afterTables.addAll(extractTables(segment.text))
}
// --- Pass 5: detect inline LaTeX ($...$ or \(...\)) within remaining TextBlocks ---
val finalSegments = mutableListOf<MarkdownSegment>()
for (segment in afterTables) {
if (segment !is MarkdownSegment.TextBlock) {
finalSegments.add(segment)
continue
}
val matches = INLINE_LATEX_REGEX.findAll(segment.text)
.filter { match ->
val dollarContent = match.groupValues[1]
val parenContent = match.groupValues[2]
if (parenContent.isNotBlank()) {
true
} else {
dollarContent.isNotBlank() && looksLikeLatex(dollarContent)
}
}
.toList()
if (matches.isEmpty()) {
finalSegments.add(segment)
continue
}
val inlineSegments = mutableListOf<InlineSegment>()
var segLastIndex = 0
val segText = segment.text
for (match in matches) {
if (match.range.first > segLastIndex) {
val before = segText.substring(segLastIndex, match.range.first)
if (before.isNotEmpty()) {
inlineSegments.add(InlineSegment.Text(before))
}
}
val content = (match.groupValues[1].ifEmpty { match.groupValues[2] }).trim()
inlineSegments.add(InlineSegment.Latex(content))
segLastIndex = match.range.last + 1
}
if (segLastIndex < segText.length) {
val remaining = segText.substring(segLastIndex)
if (remaining.isNotEmpty()) {
inlineSegments.add(InlineSegment.Text(remaining))
}
}
finalSegments.add(MarkdownSegment.InlineLatexText(inlineSegments))
}
return finalSegments
}
private fun extractTables(text: String): List<MarkdownSegment> {
val lines = text.split('\n')
val result = mutableListOf<MarkdownSegment>()
val buffer = mutableListOf<String>()
var i = 0
while (i < lines.size) {
if (i + 2 < lines.size && isTableRow(lines[i]) && isTableSeparator(lines[i + 1])) {
if (buffer.isNotEmpty()) {
val preceding = buffer.joinToString("\n").trim()
if (preceding.isNotEmpty()) {
result.add(MarkdownSegment.TextBlock(preceding))
}
buffer.clear()
}
val headerCells = parseTableRow(lines[i])
val alignments = parseAlignments(lines[i + 1], headerCells.size)
val dataRows = mutableListOf<List<String>>()
var j = i + 2
while (j < lines.size && isTableRow(lines[j])) {
val rowCells = parseTableRow(lines[j])
val normalized = List(headerCells.size) { col ->
rowCells.getOrElse(col) { "" }
}
dataRows.add(normalized)
j++
}
if (dataRows.isNotEmpty()) {
result.add(MarkdownSegment.Table(headerCells, alignments, dataRows))
i = j
} else {
buffer.add(lines[i])
buffer.add(lines[i + 1])
i += 2
}
} else {
buffer.add(lines[i])
i++
}
}
if (buffer.isNotEmpty()) {
val remaining = buffer.joinToString("\n").trim()
if (remaining.isNotEmpty()) {
result.add(MarkdownSegment.TextBlock(remaining))
}
}
return result
}
private fun isTableRow(line: String): Boolean {
val trimmed = line.trim()
if (trimmed.isEmpty()) return false
return trimmed.startsWith('|') || trimmed.endsWith('|') || trimmed.count { it == '|' } >= 2
}
private fun isTableSeparator(line: String): Boolean = TABLE_SEPARATOR_REGEX.matches(line.trim())
internal fun parseTableRow(line: String): List<String> {
val inner = line.trim().removePrefix("|").removeSuffix("|")
return inner.split('|').map { it.trim() }
}
internal fun parseAlignments(separatorLine: String, columnCount: Int): List<TableCellAlignment> {
val cells = parseTableRow(separatorLine)
return List(columnCount) { col ->
val cell = cells.getOrElse(col) { "---" }.trim()
when {
cell.startsWith(':') && cell.endsWith(':') -> TableCellAlignment.CENTER
cell.endsWith(':') -> TableCellAlignment.RIGHT
else -> TableCellAlignment.LEFT
}
}
}

View file

@ -78,6 +78,8 @@ import org.jetbrains.compose.resources.stringResource
private const val ACTION_AUTO_HIDE_MILLIS = 30_000L
private val GFM_FLAVOUR = GFMFlavourDescriptor()
// ─── MessageBubble ───────────────────────────────────────────────────
@Composable
@ -352,17 +354,13 @@ actual fun ContentPartRenderer(
// ─── MarkdownContent ─────────────────────────────────────────────────
private val TABLE_SEPARATOR_REGEX = Regex("^\\|?\\s*:?-{1,}:?\\s*(\\|\\s*:?-{1,}:?\\s*)*\\|?$")
private enum class TableCellAlignment { LEFT, CENTER, RIGHT }
@Composable
actual fun MarkdownContent(
text: String, modifier: Modifier, fontSizeMultiplier: Float, useKatex: Boolean,
searchQuery: String?, searchFocusedOccurrence: Int,
onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)?,
) {
val segments = remember(text) { splitTableSegments(text) }
val segments = remember(text) { parseMarkdownSegments(text) }
val colors = markdownColor(
text = MaterialTheme.colorScheme.onSurface,
@ -388,10 +386,59 @@ actual fun MarkdownContent(
Column(modifier = modifier.fillMaxWidth()) {
segments.forEachIndexed { index, segment ->
when (segment) {
is MdSegment.Text -> key(fontSizeMultiplier, index) {
Markdown(content = segment.content, colors = colors, typography = typography, flavour = GFMFlavourDescriptor(), modifier = Modifier.fillMaxWidth())
is MarkdownSegment.CodeBlock -> {
if (index > 0) Spacer(modifier = Modifier.height(8.dp))
if (segment.language?.lowercase() == "mermaid") {
MermaidDiagram(
code = segment.code,
modifier = Modifier.padding(vertical = 4.dp),
)
} else {
CodeBlock(
code = segment.code,
language = segment.language,
modifier = Modifier.padding(vertical = 4.dp),
)
}
if (index < segments.lastIndex) Spacer(modifier = Modifier.height(8.dp))
}
is MdSegment.Table -> {
is MarkdownSegment.LatexBlock -> {
if (index > 0) Spacer(modifier = Modifier.height(8.dp))
LatexBlock(
latex = segment.latex,
modifier = Modifier.padding(vertical = 4.dp),
useKatex = useKatex,
)
if (index < segments.lastIndex) Spacer(modifier = Modifier.height(8.dp))
}
is MarkdownSegment.InlineLatexText -> {
Column {
segment.segments.forEach { inlineSegment ->
when (inlineSegment) {
is InlineSegment.Text -> {
if (inlineSegment.text.isNotBlank()) {
key(fontSizeMultiplier) {
Markdown(
content = inlineSegment.text,
colors = colors,
typography = typography,
flavour = GFM_FLAVOUR,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
is InlineSegment.Latex -> {
LatexInline(
latex = inlineSegment.latex,
useKatex = useKatex,
)
}
}
}
}
}
is MarkdownSegment.Table -> {
if (index > 0) Spacer(modifier = Modifier.height(8.dp))
IosMarkdownTableWithFullscreen(
headers = segment.headers,
@ -402,86 +449,22 @@ actual fun MarkdownContent(
)
if (index < segments.lastIndex) Spacer(modifier = Modifier.height(8.dp))
}
is MarkdownSegment.TextBlock -> {
key(fontSizeMultiplier, index) {
Markdown(
content = segment.text,
colors = colors,
typography = typography,
flavour = GFM_FLAVOUR,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}
// ─── Table segment model ────────────────────────────────────────────
private sealed interface MdSegment {
data class Text(val content: String) : MdSegment
data class Table(
val headers: List<String>,
val alignments: List<TableCellAlignment>,
val rows: List<List<String>>,
) : MdSegment
}
private fun splitTableSegments(text: String): List<MdSegment> {
val lines = text.split('\n')
val result = mutableListOf<MdSegment>()
val buffer = mutableListOf<String>()
var i = 0
while (i < lines.size) {
if (i + 2 < lines.size && isTableRow(lines[i]) && isTableSeparator(lines[i + 1])) {
if (buffer.isNotEmpty()) {
val preceding = buffer.joinToString("\n").trim()
if (preceding.isNotEmpty()) result.add(MdSegment.Text(preceding))
buffer.clear()
}
val headerCells = parseTableRow(lines[i])
val alignments = parseAlignments(lines[i + 1], headerCells.size)
val dataRows = mutableListOf<List<String>>()
var j = i + 2
while (j < lines.size && isTableRow(lines[j])) {
val rowCells = parseTableRow(lines[j])
dataRows.add(List(headerCells.size) { col -> rowCells.getOrElse(col) { "" } })
j++
}
if (dataRows.isNotEmpty()) {
result.add(MdSegment.Table(headerCells, alignments, dataRows))
i = j
} else {
buffer.add(lines[i]); buffer.add(lines[i + 1]); i += 2
}
} else {
buffer.add(lines[i]); i++
}
}
if (buffer.isNotEmpty()) {
val remaining = buffer.joinToString("\n").trim()
if (remaining.isNotEmpty()) result.add(MdSegment.Text(remaining))
}
return result
}
private fun isTableRow(line: String): Boolean {
val trimmed = line.trim()
if (trimmed.isEmpty()) return false
return trimmed.startsWith('|') || trimmed.endsWith('|') || trimmed.count { it == '|' } >= 2
}
private fun isTableSeparator(line: String): Boolean = TABLE_SEPARATOR_REGEX.matches(line.trim())
private fun parseTableRow(line: String): List<String> {
val inner = line.trim().removePrefix("|").removeSuffix("|")
return inner.split('|').map { it.trim() }
}
private fun parseAlignments(separatorLine: String, columnCount: Int): List<TableCellAlignment> {
val cells = parseTableRow(separatorLine)
return List(columnCount) { col ->
val cell = cells.getOrElse(col) { "---" }.trim()
when {
cell.startsWith(':') && cell.endsWith(':') -> TableCellAlignment.CENTER
cell.endsWith(':') -> TableCellAlignment.RIGHT
else -> TableCellAlignment.LEFT
}
}
}
// ─── Table with fullscreen expand ───────────────────────────────────
@Composable

View file

@ -43,6 +43,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.interop.UIKitView
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@ -421,68 +422,51 @@ actual fun VideoContentPlayer(
VideoContent(url = videoUrl, modifier = modifier)
}
@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun LatexBlock(
latex: String,
modifier: Modifier,
useKatex: Boolean,
) {
val isDarkTheme = MaterialTheme.colorScheme.surface.let { color ->
(color.red * 0.299 + color.green * 0.587 + color.blue * 0.114) < 0.5
}
val textColor = if (isDarkTheme) "#e0e0e0" else "#1a1a1a"
val html = remember(latex, textColor) {
buildKatexHtml(latex, displayMode = true, textColor = textColor)
}
var contentHeight by remember { mutableStateOf(80.dp) }
UIKitView(
modifier = modifier
.fillMaxWidth()
.height(contentHeight)
.padding(vertical = 4.dp),
factory = {
val config = WKWebViewConfiguration()
val webView = WKWebView(frame = kotlinx.cinterop.cValue { }, configuration = config)
webView.setOpaque(false)
webView.scrollView.setScrollEnabled(false)
webView.navigationDelegate = object : NSObject(), WKNavigationDelegateProtocol {
override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) {
webView.evaluateJavaScript("document.body.scrollHeight") { result, _ ->
val height = (result as? Number)?.toDouble()
if (height != null && height > 0) {
contentHeight = height.dp
}
}
}
}
webView.loadHTMLString(html, baseURL = NSURL.URLWithString("https://cdn.jsdelivr.net"))
webView
},
update = { webView ->
webView.loadHTMLString(html, baseURL = NSURL.URLWithString("https://cdn.jsdelivr.net"))
},
KatexWebView(
latex = latex,
displayMode = true,
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
initialHeight = 80.dp,
)
}
@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun LatexInline(
latex: String,
modifier: Modifier,
useKatex: Boolean,
) {
val isDarkTheme = MaterialTheme.colorScheme.surface.let { color ->
(color.red * 0.299 + color.green * 0.587 + color.blue * 0.114) < 0.5
}
KatexWebView(
latex = latex,
displayMode = false,
modifier = modifier,
initialHeight = 24.dp,
)
}
@OptIn(ExperimentalForeignApi::class)
@Composable
private fun KatexWebView(
latex: String,
displayMode: Boolean,
modifier: Modifier,
initialHeight: Dp,
) {
val bgComposeColor = MaterialTheme.colorScheme.background
val isDarkTheme = (bgComposeColor.red * 0.299 + bgComposeColor.green * 0.587 + bgComposeColor.blue * 0.114) < 0.5
val textColor = if (isDarkTheme) "#e0e0e0" else "#1a1a1a"
val html = remember(latex, textColor) {
buildKatexHtml(latex, displayMode = false, textColor = textColor)
val bgColor = remember(bgComposeColor) { colorToCssHex(bgComposeColor) }
val html = remember(latex, textColor, bgColor) {
buildKatexHtml(latex, displayMode = displayMode, textColor = textColor, bgColor = bgColor)
}
var contentHeight by remember { mutableStateOf(24.dp) }
var contentHeight by remember { mutableStateOf(initialHeight) }
UIKitView(
modifier = modifier
@ -490,7 +474,14 @@ actual fun LatexInline(
factory = {
val config = WKWebViewConfiguration()
val webView = WKWebView(frame = kotlinx.cinterop.cValue { }, configuration = config)
webView.setOpaque(false)
val nativeBg = platform.UIKit.UIColor(
red = bgComposeColor.red.toDouble(),
green = bgComposeColor.green.toDouble(),
blue = bgComposeColor.blue.toDouble(),
alpha = 1.0,
)
webView.setBackgroundColor(nativeBg)
webView.scrollView.setBackgroundColor(nativeBg)
webView.scrollView.setScrollEnabled(false)
webView.navigationDelegate = object : NSObject(), WKNavigationDelegateProtocol {
override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) {
@ -511,7 +502,14 @@ actual fun LatexInline(
)
}
private fun buildKatexHtml(latex: String, displayMode: Boolean, textColor: String): String {
private fun colorToCssHex(color: androidx.compose.ui.graphics.Color): String {
val r = (color.red * 255).toInt()
val g = (color.green * 255).toInt()
val b = (color.blue * 255).toInt()
return "#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}"
}
private fun buildKatexHtml(latex: String, displayMode: Boolean, textColor: String, bgColor: String): String {
val escapedLatex = latex
.replace("\\", "\\\\")
.replace("`", "\\`")
@ -531,7 +529,7 @@ private fun buildKatexHtml(latex: String, displayMode: Boolean, textColor: Strin
display: flex;
justify-content: ${if (displayMode) "center" else "flex-start"};
align-items: center;
background: transparent;
background: $bgColor;
color: $textColor;
overflow: hidden;
min-height: 100%;