fix: iOS LaTeX rendering by extracting shared markdown parser to commonMain
This commit is contained in:
parent
4d2a074f56
commit
fccc0ba0bb
4 changed files with 441 additions and 574 deletions
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
Loading…
Reference in a new issue