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
|
occurrenceOffset += segmentOccurrences
|
||||||
} else {
|
} else {
|
||||||
val hasCitations = remember(segment.text) {
|
val hasCitations = remember(segment.text) {
|
||||||
segment.text.contains(CITATION_REGEX)
|
segment.text.contains(CITATION_DETECT_REGEX)
|
||||||
}
|
}
|
||||||
if (hasCitations) {
|
if (hasCitations) {
|
||||||
CitationText(
|
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
|
* 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 const val ACTION_AUTO_HIDE_MILLIS = 30_000L
|
||||||
|
|
||||||
|
private val GFM_FLAVOUR = GFMFlavourDescriptor()
|
||||||
|
|
||||||
// ─── MessageBubble ───────────────────────────────────────────────────
|
// ─── MessageBubble ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -352,17 +354,13 @@ actual fun ContentPartRenderer(
|
||||||
|
|
||||||
// ─── MarkdownContent ─────────────────────────────────────────────────
|
// ─── MarkdownContent ─────────────────────────────────────────────────
|
||||||
|
|
||||||
private val TABLE_SEPARATOR_REGEX = Regex("^\\|?\\s*:?-{1,}:?\\s*(\\|\\s*:?-{1,}:?\\s*)*\\|?$")
|
|
||||||
|
|
||||||
private enum class TableCellAlignment { LEFT, CENTER, RIGHT }
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun MarkdownContent(
|
actual fun MarkdownContent(
|
||||||
text: String, modifier: Modifier, fontSizeMultiplier: Float, useKatex: Boolean,
|
text: String, modifier: Modifier, fontSizeMultiplier: Float, useKatex: Boolean,
|
||||||
searchQuery: String?, searchFocusedOccurrence: Int,
|
searchQuery: String?, searchFocusedOccurrence: Int,
|
||||||
onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)?,
|
onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val segments = remember(text) { splitTableSegments(text) }
|
val segments = remember(text) { parseMarkdownSegments(text) }
|
||||||
|
|
||||||
val colors = markdownColor(
|
val colors = markdownColor(
|
||||||
text = MaterialTheme.colorScheme.onSurface,
|
text = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|
@ -388,10 +386,59 @@ actual fun MarkdownContent(
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
segments.forEachIndexed { index, segment ->
|
segments.forEachIndexed { index, segment ->
|
||||||
when (segment) {
|
when (segment) {
|
||||||
is MdSegment.Text -> key(fontSizeMultiplier, index) {
|
is MarkdownSegment.CodeBlock -> {
|
||||||
Markdown(content = segment.content, colors = colors, typography = typography, flavour = GFMFlavourDescriptor(), modifier = Modifier.fillMaxWidth())
|
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))
|
if (index > 0) Spacer(modifier = Modifier.height(8.dp))
|
||||||
IosMarkdownTableWithFullscreen(
|
IosMarkdownTableWithFullscreen(
|
||||||
headers = segment.headers,
|
headers = segment.headers,
|
||||||
|
|
@ -402,86 +449,22 @@ actual fun MarkdownContent(
|
||||||
)
|
)
|
||||||
if (index < segments.lastIndex) Spacer(modifier = Modifier.height(8.dp))
|
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 ───────────────────────────────────
|
// ─── Table with fullscreen expand ───────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.interop.UIKitView
|
import androidx.compose.ui.interop.UIKitView
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
|
|
@ -421,68 +422,51 @@ actual fun VideoContentPlayer(
|
||||||
VideoContent(url = videoUrl, modifier = modifier)
|
VideoContent(url = videoUrl, modifier = modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun LatexBlock(
|
actual fun LatexBlock(
|
||||||
latex: String,
|
latex: String,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
useKatex: Boolean,
|
useKatex: Boolean,
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = MaterialTheme.colorScheme.surface.let { color ->
|
KatexWebView(
|
||||||
(color.red * 0.299 + color.green * 0.587 + color.blue * 0.114) < 0.5
|
latex = latex,
|
||||||
}
|
displayMode = true,
|
||||||
val textColor = if (isDarkTheme) "#e0e0e0" else "#1a1a1a"
|
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
val html = remember(latex, textColor) {
|
initialHeight = 80.dp,
|
||||||
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"))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun LatexInline(
|
actual fun LatexInline(
|
||||||
latex: String,
|
latex: String,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
useKatex: Boolean,
|
useKatex: Boolean,
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = MaterialTheme.colorScheme.surface.let { color ->
|
KatexWebView(
|
||||||
(color.red * 0.299 + color.green * 0.587 + color.blue * 0.114) < 0.5
|
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 textColor = if (isDarkTheme) "#e0e0e0" else "#1a1a1a"
|
||||||
val html = remember(latex, textColor) {
|
val bgColor = remember(bgComposeColor) { colorToCssHex(bgComposeColor) }
|
||||||
buildKatexHtml(latex, displayMode = false, textColor = textColor)
|
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(
|
UIKitView(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -490,7 +474,14 @@ actual fun LatexInline(
|
||||||
factory = {
|
factory = {
|
||||||
val config = WKWebViewConfiguration()
|
val config = WKWebViewConfiguration()
|
||||||
val webView = WKWebView(frame = kotlinx.cinterop.cValue { }, configuration = config)
|
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.scrollView.setScrollEnabled(false)
|
||||||
webView.navigationDelegate = object : NSObject(), WKNavigationDelegateProtocol {
|
webView.navigationDelegate = object : NSObject(), WKNavigationDelegateProtocol {
|
||||||
override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) {
|
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
|
val escapedLatex = latex
|
||||||
.replace("\\", "\\\\")
|
.replace("\\", "\\\\")
|
||||||
.replace("`", "\\`")
|
.replace("`", "\\`")
|
||||||
|
|
@ -531,7 +529,7 @@ private fun buildKatexHtml(latex: String, displayMode: Boolean, textColor: Strin
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: ${if (displayMode) "center" else "flex-start"};
|
justify-content: ${if (displayMode) "center" else "flex-start"};
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: transparent;
|
background: $bgColor;
|
||||||
color: $textColor;
|
color: $textColor;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue