refactor(markdown): extract MarkdownModels and MarkdownParser

Split AST block models and zero-Compose Markdown block parser into
ui/chat/markdown/ package. Wire into MarkdownText.
This commit is contained in:
Hy4ri committed 2026-09-11 18:19:47 +03:00
1 parent a83f4725a2
commit 70f331b931
5 files changed
+649 -644

No files matched your search

@@ -61,15 +61,16 @@ import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.SearchHighlightColors
import com.m57.hermescontrol.theme.searchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors
import com.m57.hermescontrol.ui.chat.markdown.FnNote
import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment
import com.m57.hermescontrol.ui.chat.markdown.MdBlock
import com.m57.hermescontrol.ui.chat.markdown.TableAlign
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import com.m57.hermescontrol.ui.chat.markdown.splitInlineMath
import com.m57.hermescontrol.util.BidiUtils import com.m57.hermescontrol.util.BidiUtils
private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""") private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""")
private val TABLE_COL_WIDTH = 160.dp private val TABLE_COL_WIDTH = 160.dp
private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""")
private val TASK_LINE_RE = Regex("""^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$""")
private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""")
private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""")
private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""")
private fun bidiTextDirection(isRtl: Boolean): TextDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr private fun bidiTextDirection(isRtl: Boolean): TextDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr
@@ -508,32 +509,6 @@ fun MarkdownText(
} }
} }
/**
* Matches a Markdown image: `![alt](uri)`. The `uri` may be an http(s) URL
* (gateway-served media the phone can reach), a `data:image/...;base64,...`
* data URL (e.g. agent-delivered inline media), or any other resolvable model
* string (Coil handles all three). Bare `![]()` with empty uri is skipped.
*/
private val IMAGE_RE = Regex("""^!\[([^\]]*)\]\(([^)\s]+)\s*\)""")
private fun tryParseImage(line: String): MdBlock.Image? {
val m = IMAGE_RE.matchAt(line, 0) ?: return null
val uri = m.groupValues[2].trim()
if (uri.isEmpty()) return null
return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim())
}
private fun tryParseVideo(line: String): MdBlock.Video? {
val m = IMAGE_RE.matchAt(line, 0) ?: return null
val uri = m.groupValues[2].trim()
if (uri.isEmpty()) return null
val ext = uri.substringAfterLast('.', "").lowercase()
if (ext in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || uri.contains("video/", ignoreCase = true)) {
return MdBlock.Video(uri = uri, alt = m.groupValues[1].trim())
}
return null
}
@Composable @Composable
private fun MarkdownInlineText( private fun MarkdownInlineText(
text: String, text: String,
@@ -702,531 +677,6 @@ private fun tableTextAlign(align: TableAlign?): TextAlign? =
else -> null else -> null
} }
internal sealed interface InlineMathSegment {
data class Text(
val value: String,
) : InlineMathSegment
data class Math(
val latex: String,
) : InlineMathSegment
}
/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */
internal fun splitInlineMath(text: String): List<InlineMathSegment> {
val segments = mutableListOf<InlineMathSegment>()
var plainStart = 0
var i = 0
while (i < text.length) {
if (text[i] == '`' && !text.isEscaped(i)) {
val codeEnd = text.indexOf('`', i + 1)
i = if (codeEnd == -1) text.length else codeEnd + 1
continue
}
if (text.startsWith("\\(", i)) {
val end = text.indexOf("\\)", i + 2)
if (end != -1 && end > i + 2) {
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
segments.add(InlineMathSegment.Math(text.substring(i + 2, end)))
i = end + 2
plainStart = i
continue
}
}
if (text[i] != '$' || text.isEscaped(i) || text.getOrNull(i + 1) == '$') {
i++
continue
}
var end = i + 1
while (end < text.length && (text[end] != '$' || text.isEscaped(end))) end++
if (end >= text.length || end == i + 1 || text[end - 1].isWhitespace()) {
i++
continue
}
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
segments.add(InlineMathSegment.Math(text.substring(i + 1, end)))
i = end + 1
plainStart = i
}
if (plainStart < text.length) segments.add(InlineMathSegment.Text(text.substring(plainStart)))
return segments.ifEmpty { listOf(InlineMathSegment.Text(text)) }
}
private fun String.isEscaped(index: Int): Boolean {
var slashes = 0
var i = index - 1
while (i >= 0 && this[i] == '\\') {
slashes++
i--
}
return slashes % 2 == 1
}
private fun tryParseDisplayMath(
lines: List<String>,
start: Int,
): Pair<MdBlock.Math, Int>? {
val first = lines[start].trim()
if (first.startsWith("\\[")) {
if (first.length > 4 && first.endsWith("\\]")) {
val latex = first.substring(2, first.length - 2).trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
}
if (first != "\\[") return null
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "\\]" } ?: return null
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
}
if (!first.startsWith("$$")) return null
if (first.length > 4 && first.endsWith("$$")) {
val latex = first.substring(2, first.length - 2).trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
}
if (first != "$$") return null
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "$$" } ?: return null
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
}
/**
* Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first;
* everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs.
*/
internal fun parseBlocks(src: String): List<MdBlock> {
val lines = src.lines()
val blocks = mutableListOf<MdBlock>()
val footnotes = mutableListOf<Footnote>()
var i = 0
while (i < lines.size) {
val line = lines[i]
// Footnote definition: [^id]: text (collected, not rendered inline)
val fnMatch = FN_DEF_RE.matchAt(line, 0)
if (fnMatch != null) {
footnotes.add(Footnote(fnMatch.groupValues[1], fnMatch.groupValues[2]))
i++
continue
}
val displayMath = tryParseDisplayMath(lines, i)
if (displayMath != null) {
blocks.add(displayMath.first)
i = displayMath.second
continue
}
val codeFence = parseCodeFenceStart(line)
when {
codeFence != null -> {
val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) }
if (end != null) {
blocks.add(
MdBlock.Code(
code = lines.subList(i + 1, end).joinToString("\n"),
language = codeFence.language,
),
)
i = end + 1
} else {
blocks.add(
MdBlock.Code(
code = lines.subList(i + 1, lines.size).joinToString("\n"),
language = codeFence.language,
),
)
i = lines.size
}
}
line.isBlank() -> {
i++
}
isHorizontalRule(line) -> {
blocks.add(MdBlock.Hr)
i++
}
// Heading: requires a space after the '#' run so "#572" stays a paragraph
isValidHeading(line) -> {
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
blocks.add(MdBlock.Heading(level, line.substring(level).trim()))
i++
}
isTableStart(lines, i) -> {
val (header, alignments, body) = parseTable(lines, i)
blocks.add(MdBlock.Table(header, alignments, body))
i += body.size + 2
}
line.startsWith(">") -> {
val quote = mutableListOf<String>()
while (i < lines.size && lines[i].startsWith(">")) {
quote.add(lines[i].removePrefix(">").trim())
i++
}
blocks.add(MdBlock.Quote(quote.joinToString("\n")))
}
// Definition list: term line followed by one+ ": definition" lines
isDefListStart(lines, i) -> {
val term = line.trim()
val defs = mutableListOf<String>()
i++
while (i < lines.size && lines[i].trim().startsWith(":")) {
defs.add(lines[i].trim().removePrefix(":").trim())
i++
}
blocks.add(MdBlock.DefList(listOf(DefItem(term, defs))))
}
LIST_ITEM_LINE_RE.matches(line) -> {
val (listBlocks, nextIndex) = parseList(lines, i)
blocks.addAll(listBlocks)
i = nextIndex
}
// Standalone Markdown image: ![alt](uri) on its own line.
// Inline images inside a paragraph are left as-is (rendered as text)
// to keep scope tight; standalone is the common agent-media case.
line.isNotBlank() && tryParseVideo(line) != null -> {
blocks.add(tryParseVideo(line)!!)
i++
}
line.isNotBlank() && tryParseImage(line) != null -> {
blocks.add(tryParseImage(line)!!)
i++
}
else -> {
i = fallthroughToParagraph(lines, i, blocks)
}
}
}
if (footnotes.isNotEmpty()) {
blocks.add(MdBlock.Footnotes(footnotes.map { FnNote(it.id, it.text) }))
}
return blocks
}
private sealed interface ParsedItem {
val indent: Int
val continuationLines: MutableList<String>
data class Bullet(
override val indent: Int,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
data class Task(
override val indent: Int,
val checked: Boolean,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
data class Ordered(
override val indent: Int,
val rawNumber: Int,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
}
private fun computeIndent(prefix: String): Int {
var count = 0
for (ch in prefix) {
if (ch == '\t') {
count += 4 - (count % 4)
} else {
count++
}
}
return count
}
private fun parseList(
lines: List<String>,
startIndex: Int,
): Pair<List<MdBlock>, Int> {
val items = mutableListOf<ParsedItem>()
var j = startIndex
while (j < lines.size) {
val line = lines[j]
val taskMatch = TASK_LINE_RE.matchEntire(line)
val bulletMatch = if (taskMatch == null) BULLET_LINE_RE.matchEntire(line) else null
val orderedMatch = if (taskMatch == null && bulletMatch == null) ORDERED_LINE_RE.matchEntire(line) else null
if (taskMatch != null) {
val indent = computeIndent(taskMatch.groupValues[1])
val checked = taskMatch.groupValues[2].equals("x", ignoreCase = true)
val text = taskMatch.groupValues[3]
items.add(ParsedItem.Task(indent, checked, text))
j++
} else if (bulletMatch != null) {
val indent = computeIndent(bulletMatch.groupValues[1])
val text = bulletMatch.groupValues[2]
items.add(ParsedItem.Bullet(indent, text))
j++
} else if (orderedMatch != null) {
val indent = computeIndent(orderedMatch.groupValues[1])
val num = orderedMatch.groupValues[2].toIntOrNull() ?: 1
val text = orderedMatch.groupValues[3]
items.add(ParsedItem.Ordered(indent, num, text))
j++
} else if (line.isBlank()) {
// Check if there is a following list item
var lookahead = j + 1
while (lookahead < lines.size && lines[lookahead].isBlank()) {
lookahead++
}
if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) {
j = lookahead
} else {
break
}
} else {
// Check if this is a continuation line for the previous list item
val indent = computeIndent(line.takeWhile { it == ' ' || it == '\t' })
if (items.isNotEmpty() && (indent >= 2 || line.startsWith(" "))) {
items.last().continuationLines.add(line.trim())
j++
} else {
break
}
}
}
if (items.isEmpty()) {
return emptyList<MdBlock>() to j
}
val blocks = mutableListOf<MdBlock>()
// Track state of list items and their relative nesting depth using an indent stack
val indentStack = mutableListOf<Int>() // maps level (index) -> base indent
var lastItemWasOrdered = false
var currentSequenceNumber = 1
var lastLevel = -1
for (item in items) {
// Resolve level using the indent stack
val level: Int
if (indentStack.isEmpty() || item.indent == 0) {
indentStack.clear()
indentStack.add(item.indent)
level = 0
} else if (item.indent > indentStack.last()) {
indentStack.add(item.indent)
level = indentStack.size - 1
} else if (item.indent == indentStack.last()) {
level = indentStack.size - 1
} else {
// Unwind stack to the matching or nearest smaller indent
while (indentStack.size > 1 && indentStack.last() > item.indent) {
indentStack.removeAt(indentStack.size - 1)
}
level = indentStack.size - 1
}
val fullText =
if (item.continuationLines.isEmpty()) {
when (item) {
is ParsedItem.Bullet -> item.text
is ParsedItem.Task -> item.text
is ParsedItem.Ordered -> item.text
}
} else {
val base =
when (item) {
is ParsedItem.Bullet -> item.text
is ParsedItem.Task -> item.text
is ParsedItem.Ordered -> item.text
}
base + " " + item.continuationLines.joinToString(" ")
}
when (item) {
is ParsedItem.Bullet -> {
blocks.add(MdBlock.Bullet(fullText, level = level))
lastItemWasOrdered = false
lastLevel = level
}
is ParsedItem.Task -> {
blocks.add(MdBlock.Task(item.checked, fullText, level = level))
lastItemWasOrdered = false
lastLevel = level
}
is ParsedItem.Ordered -> {
val indexToUse =
if (level == lastLevel && lastItemWasOrdered) {
if (item.rawNumber > currentSequenceNumber) {
currentSequenceNumber = item.rawNumber
} else {
currentSequenceNumber++
}
currentSequenceNumber
} else {
currentSequenceNumber = item.rawNumber
currentSequenceNumber
}
blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level))
lastItemWasOrdered = true
lastLevel = level
}
}
}
return blocks to j
}
private fun isHorizontalRule(line: String): Boolean {
val trimmed = line.trim()
if (trimmed.length < 3) return false
val c = trimmed[0]
return (c == '-' || c == '*' || c == '_') && trimmed.all { it == c }
}
private fun isTableStart(
lines: List<String>,
i: Int,
): Boolean {
val l = lines[i]
if (!l.contains('|')) return false
if (i + 1 >= lines.size) return false
return isTableSeparator(lines[i + 1])
}
private fun isTableSeparator(line: String): Boolean {
val t = line.trim().trim('|')
if (t.isEmpty()) return false
// every cell must be only dashes/colons/spaces, with at least one dash
return t.split('|').all { cell ->
val c = cell.trim()
c.isNotEmpty() && c.all { it == '-' || it == ':' || it == ' ' } && c.any { it == '-' }
}
}
private fun parseTable(
lines: List<String>,
i: Int,
): Triple<List<String>, List<TableAlign>, List<List<String>>> {
val header = splitRow(lines[i])
val alignments =
splitRow(lines[i + 1]).map { cell ->
val c = cell.trim()
when {
c.startsWith(":") && c.endsWith(":") -> TableAlign.CENTER
c.endsWith(":") -> TableAlign.RIGHT
c.startsWith(":") -> TableAlign.LEFT
else -> TableAlign.LEFT
}
}
val body = mutableListOf<List<String>>()
var j = i + 2
while (j < lines.size && lines[j].contains('|') && lines[j].trim().isNotEmpty()) {
body.add(splitRow(lines[j]))
j++
}
return Triple(header, alignments, body)
}
private fun splitRow(line: String): List<String> {
val trimmed = line.trim().trim('|')
return trimmed.split('|').map { it.trim() }
}
private data class CodeFenceInfo(
val fenceChar: Char,
val fenceLength: Int,
val language: String?,
)
private fun parseCodeFenceStart(line: String): CodeFenceInfo? {
val trimmed = line.trimStart()
if (trimmed.length < 3) return null
val firstChar = trimmed[0]
if (firstChar != '`' && firstChar != '~') return null
val count = trimmed.takeWhile { it == firstChar }.length
if (count < 3) return null
val rest = trimmed.substring(count).trim()
if (firstChar == '`' && rest.contains('`')) return null
return CodeFenceInfo(
fenceChar = firstChar,
fenceLength = count,
language = rest.ifBlank { null },
)
}
private fun isCodeFenceEnd(
line: String,
fence: CodeFenceInfo,
): Boolean {
val trimmed = line.trimStart()
val count = trimmed.takeWhile { it == fence.fenceChar }.length
if (count < fence.fenceLength) return false
val rest = trimmed.substring(count).trim()
return rest.isEmpty()
}
private fun isDefListStart(
lines: List<String>,
i: Int,
): Boolean {
val l = lines[i].trim()
if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || parseCodeFenceStart(l) != null) return false
if (LIST_ITEM_LINE_RE.matches(lines[i])) return false
if (i + 1 >= lines.size) return false
return lines[i + 1].trim().startsWith(":")
}
private fun isValidHeading(line: String): Boolean {
if (!line.startsWith("#")) return false
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
return level < line.length && line[level] == ' '
}
private fun fallthroughToParagraph(
lines: List<String>,
start: Int,
blocks: MutableList<MdBlock>,
): Int {
var i = start
val para = mutableListOf<String>()
while (
i < lines.size &&
lines[i].isNotBlank() &&
parseCodeFenceStart(lines[i]) == null &&
!isValidHeading(lines[i]) &&
!lines[i].startsWith(">") &&
!LIST_ITEM_LINE_RE.matches(lines[i]) &&
!isHorizontalRule(lines[i]) &&
!isTableStart(lines, i) &&
!isDefListStart(lines, i) &&
tryParseDisplayMath(lines, i) == null &&
FN_DEF_RE.matchAt(lines[i], 0) == null
) {
para.add(lines[i])
i++
}
if (para.isNotEmpty()) blocks.add(MdBlock.Paragraph(para.joinToString("\n")))
return i
}
/** /**
* Inline Markdown -> AnnotatedString. Handles `code`, **bold**, *italic*, ***bold italic***, * Inline Markdown -> AnnotatedString. Handles `code`, **bold**, *italic*, ***bold italic***,
* ~~strike~~, ==highlight==, ^sup^, ~sub~, <kbd>keys</kbd>, [^ref] footnotes, [text](url) and * ~~strike~~, ==highlight==, ^sup^, ~sub~, <kbd>keys</kbd>, [^ref] footnotes, [text](url) and
@@ -1549,94 +999,6 @@ private fun parseInlineSource(
} }
} }
internal sealed interface MdBlock {
data class Code(
val code: String,
val language: String? = null,
) : MdBlock
data class Math(
val latex: String,
) : MdBlock
data class Heading(
val level: Int,
val text: String,
) : MdBlock
data class Bullet(
val text: String,
val level: Int = 0,
) : MdBlock
data class Task(
val checked: Boolean,
val text: String,
val level: Int = 0,
) : MdBlock
data class Ordered(
val index: Int,
val text: String,
val level: Int = 0,
) : MdBlock
data class Image(
val uri: String,
val alt: String = "",
) : MdBlock
data class Video(
val uri: String,
val alt: String = "",
) : MdBlock
data class Quote(
val text: String,
) : MdBlock
data class Paragraph(
val text: String,
) : MdBlock
data class Table(
val header: List<String>,
val alignments: List<TableAlign>,
val rows: List<List<String>>,
) : MdBlock
object Hr : MdBlock
data class DefList(
val items: List<DefItem>,
) : MdBlock
data class Footnotes(
val notes: List<FnNote>,
) : MdBlock
}
internal data class DefItem(
val term: String,
val definitions: List<String>,
)
internal data class Footnote(
val id: String,
val text: String,
)
internal data class FnNote(
val id: String,
val text: String,
)
internal enum class TableAlign {
LEFT,
CENTER,
RIGHT,
}
private fun resolveImageUrl(uri: String): String { private fun resolveImageUrl(uri: String): String {
val trimmed = uri.trim() val trimmed = uri.trim()
if (trimmed.isBlank()) return uri if (trimmed.isBlank()) return uri
@@ -0,0 +1,99 @@
package com.m57.hermescontrol.ui.chat.markdown
sealed interface MdBlock {
data class Code(
val code: String,
val language: String? = null,
) : MdBlock
data class Math(
val latex: String,
) : MdBlock
data class Heading(
val level: Int,
val text: String,
) : MdBlock
data class Bullet(
val text: String,
val level: Int = 0,
) : MdBlock
data class Task(
val checked: Boolean,
val text: String,
val level: Int = 0,
) : MdBlock
data class Ordered(
val index: Int,
val text: String,
val level: Int = 0,
) : MdBlock
data class Image(
val uri: String,
val alt: String = "",
) : MdBlock
data class Video(
val uri: String,
val alt: String = "",
) : MdBlock
data class Quote(
val text: String,
) : MdBlock
data class Paragraph(
val text: String,
) : MdBlock
data class Table(
val header: List<String>,
val alignments: List<TableAlign>,
val rows: List<List<String>>,
) : MdBlock
object Hr : MdBlock
data class DefList(
val items: List<DefItem>,
) : MdBlock
data class Footnotes(
val notes: List<FnNote>,
) : MdBlock
}
data class DefItem(
val term: String,
val definitions: List<String>,
)
data class Footnote(
val id: String,
val text: String,
)
data class FnNote(
val id: String,
val text: String,
)
enum class TableAlign {
LEFT,
CENTER,
RIGHT,
}
sealed interface InlineMathSegment {
data class Text(
val value: String,
) : InlineMathSegment
data class Math(
val latex: String,
) : InlineMathSegment
}
@@ -0,0 +1,539 @@
package com.m57.hermescontrol.ui.chat.markdown
private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""")
private val TASK_LINE_RE = Regex("""^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$""")
private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""")
private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""")
private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""")
private val IMAGE_RE = Regex("""^!\[([^\]]*)\]\(([^)\s]+)\s*\)""")
/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */
fun splitInlineMath(text: String): List<InlineMathSegment> {
val segments = mutableListOf<InlineMathSegment>()
var plainStart = 0
var i = 0
while (i < text.length) {
if (text[i] == '`' && !text.isEscaped(i)) {
val codeEnd = text.indexOf('`', i + 1)
i = if (codeEnd == -1) text.length else codeEnd + 1
continue
}
if (text.startsWith("\\(", i)) {
val end = text.indexOf("\\)", i + 2)
if (end != -1 && end > i + 2) {
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
segments.add(InlineMathSegment.Math(text.substring(i + 2, end)))
i = end + 2
plainStart = i
continue
}
}
if (text[i] != '$' || text.isEscaped(i) || text.getOrNull(i + 1) == '$') {
i++
continue
}
var end = i + 1
while (end < text.length && (text[end] != '$' || text.isEscaped(end))) end++
if (end >= text.length || end == i + 1 || text[end - 1].isWhitespace()) {
i++
continue
}
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
segments.add(InlineMathSegment.Math(text.substring(i + 1, end)))
i = end + 1
plainStart = i
}
if (plainStart < text.length) segments.add(InlineMathSegment.Text(text.substring(plainStart)))
return segments.ifEmpty { listOf(InlineMathSegment.Text(text)) }
}
private fun String.isEscaped(index: Int): Boolean {
var slashes = 0
var i = index - 1
while (i >= 0 && this[i] == '\\') {
slashes++
i--
}
return slashes % 2 == 1
}
private fun tryParseDisplayMath(
lines: List<String>,
start: Int,
): Pair<MdBlock.Math, Int>? {
val first = lines[start].trim()
if (first.startsWith("\\[")) {
if (first.length > 4 && first.endsWith("\\]")) {
val latex = first.substring(2, first.length - 2).trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
}
if (first != "\\[") return null
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "\\]" } ?: return null
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
}
if (!first.startsWith("$$")) return null
if (first.length > 4 && first.endsWith("$$")) {
val latex = first.substring(2, first.length - 2).trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
}
if (first != "$$") return null
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "$$" } ?: return null
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
}
/**
* Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first;
* everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs.
*/
fun parseBlocks(src: String): List<MdBlock> {
val lines = src.lines()
val blocks = mutableListOf<MdBlock>()
val footnotes = mutableListOf<Footnote>()
var i = 0
while (i < lines.size) {
val line = lines[i]
// Footnote definition: [^id]: text (collected, not rendered inline)
val fnMatch = FN_DEF_RE.matchAt(line, 0)
if (fnMatch != null) {
footnotes.add(Footnote(fnMatch.groupValues[1], fnMatch.groupValues[2]))
i++
continue
}
val displayMath = tryParseDisplayMath(lines, i)
if (displayMath != null) {
blocks.add(displayMath.first)
i = displayMath.second
continue
}
val codeFence = parseCodeFenceStart(line)
when {
codeFence != null -> {
val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) }
if (end != null) {
blocks.add(
MdBlock.Code(
code = lines.subList(i + 1, end).joinToString("\n"),
language = codeFence.language,
),
)
i = end + 1
} else {
blocks.add(
MdBlock.Code(
code = lines.subList(i + 1, lines.size).joinToString("\n"),
language = codeFence.language,
),
)
i = lines.size
}
}
line.isBlank() -> {
i++
}
isHorizontalRule(line) -> {
blocks.add(MdBlock.Hr)
i++
}
// Heading: requires a space after the '#' run so "#572" stays a paragraph
isValidHeading(line) -> {
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
blocks.add(MdBlock.Heading(level, line.substring(level).trim()))
i++
}
isTableStart(lines, i) -> {
val (header, alignments, body) = parseTable(lines, i)
blocks.add(MdBlock.Table(header, alignments, body))
i += body.size + 2
}
line.startsWith(">") -> {
val quote = mutableListOf<String>()
while (i < lines.size && lines[i].startsWith(">")) {
quote.add(lines[i].removePrefix(">").trim())
i++
}
blocks.add(MdBlock.Quote(quote.joinToString("\n")))
}
// Definition list: term line followed by one+ ": definition" lines
isDefListStart(lines, i) -> {
val term = line.trim()
val defs = mutableListOf<String>()
i++
while (i < lines.size && lines[i].trim().startsWith(":")) {
defs.add(lines[i].trim().removePrefix(":").trim())
i++
}
blocks.add(MdBlock.DefList(listOf(DefItem(term, defs))))
}
LIST_ITEM_LINE_RE.matches(line) -> {
val (listBlocks, nextIndex) = parseList(lines, i)
blocks.addAll(listBlocks)
i = nextIndex
}
// Standalone Markdown image: ![alt](uri) on its own line.
line.isNotBlank() && tryParseVideo(line) != null -> {
blocks.add(tryParseVideo(line)!!)
i++
}
line.isNotBlank() && tryParseImage(line) != null -> {
blocks.add(tryParseImage(line)!!)
i++
}
else -> {
i = fallthroughToParagraph(lines, i, blocks)
}
}
}
if (footnotes.isNotEmpty()) {
blocks.add(MdBlock.Footnotes(footnotes.map { FnNote(it.id, it.text) }))
}
return blocks
}
private sealed interface ParsedItem {
val indent: Int
val continuationLines: MutableList<String>
data class Bullet(
override val indent: Int,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
data class Task(
override val indent: Int,
val checked: Boolean,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
data class Ordered(
override val indent: Int,
val rawNumber: Int,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
}
private fun computeIndent(prefix: String): Int {
var count = 0
for (ch in prefix) {
if (ch == '\t') {
count += 4 - (count % 4)
} else {
count++
}
}
return count
}
private fun parseList(
lines: List<String>,
startIndex: Int,
): Pair<List<MdBlock>, Int> {
val items = mutableListOf<ParsedItem>()
var j = startIndex
while (j < lines.size) {
val line = lines[j]
val taskMatch = TASK_LINE_RE.matchEntire(line)
val bulletMatch = if (taskMatch == null) BULLET_LINE_RE.matchEntire(line) else null
val orderedMatch = if (taskMatch == null && bulletMatch == null) ORDERED_LINE_RE.matchEntire(line) else null
if (taskMatch != null) {
val indent = computeIndent(taskMatch.groupValues[1])
val checked = taskMatch.groupValues[2].equals("x", ignoreCase = true)
val text = taskMatch.groupValues[3]
items.add(ParsedItem.Task(indent, checked, text))
j++
} else if (bulletMatch != null) {
val indent = computeIndent(bulletMatch.groupValues[1])
val text = bulletMatch.groupValues[2]
items.add(ParsedItem.Bullet(indent, text))
j++
} else if (orderedMatch != null) {
val indent = computeIndent(orderedMatch.groupValues[1])
val num = orderedMatch.groupValues[2].toIntOrNull() ?: 1
val text = orderedMatch.groupValues[3]
items.add(ParsedItem.Ordered(indent, num, text))
j++
} else if (line.isBlank()) {
// Check if there is a following list item
var lookahead = j + 1
while (lookahead < lines.size && lines[lookahead].isBlank()) {
lookahead++
}
if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) {
j = lookahead
} else {
break
}
} else {
// Check if this is a continuation line for the previous list item
val indent = computeIndent(line.takeWhile { it == ' ' || it == '\t' })
if (items.isNotEmpty() && (indent >= 2 || line.startsWith(" "))) {
items.last().continuationLines.add(line.trim())
j++
} else {
break
}
}
}
if (items.isEmpty()) {
return emptyList<MdBlock>() to j
}
val blocks = mutableListOf<MdBlock>()
// Track state of list items and their relative nesting depth using an indent stack
val indentStack = mutableListOf<Int>() // maps level (index) -> base indent
var lastItemWasOrdered = false
var currentSequenceNumber = 1
var lastLevel = -1
for (item in items) {
// Resolve level using the indent stack
val level: Int
if (indentStack.isEmpty() || item.indent == 0) {
indentStack.clear()
indentStack.add(item.indent)
level = 0
} else if (item.indent > indentStack.last()) {
indentStack.add(item.indent)
level = indentStack.size - 1
} else if (item.indent == indentStack.last()) {
level = indentStack.size - 1
} else {
// Unwind stack to the matching or nearest smaller indent
while (indentStack.size > 1 && indentStack.last() > item.indent) {
indentStack.removeAt(indentStack.size - 1)
}
level = indentStack.size - 1
}
val fullText =
if (item.continuationLines.isEmpty()) {
when (item) {
is ParsedItem.Bullet -> item.text
is ParsedItem.Task -> item.text
is ParsedItem.Ordered -> item.text
}
} else {
val base =
when (item) {
is ParsedItem.Bullet -> item.text
is ParsedItem.Task -> item.text
is ParsedItem.Ordered -> item.text
}
base + " " + item.continuationLines.joinToString(" ")
}
when (item) {
is ParsedItem.Bullet -> {
blocks.add(MdBlock.Bullet(fullText, level = level))
lastItemWasOrdered = false
lastLevel = level
}
is ParsedItem.Task -> {
blocks.add(MdBlock.Task(item.checked, fullText, level = level))
lastItemWasOrdered = false
lastLevel = level
}
is ParsedItem.Ordered -> {
val indexToUse =
if (level == lastLevel && lastItemWasOrdered) {
if (item.rawNumber > currentSequenceNumber) {
currentSequenceNumber = item.rawNumber
} else {
currentSequenceNumber++
}
currentSequenceNumber
} else {
currentSequenceNumber = item.rawNumber
currentSequenceNumber
}
blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level))
lastItemWasOrdered = true
lastLevel = level
}
}
}
return blocks to j
}
private fun isHorizontalRule(line: String): Boolean {
val trimmed = line.trim()
if (trimmed.length < 3) return false
val c = trimmed[0]
return (c == '-' || c == '*' || c == '_') && trimmed.all { it == c }
}
private fun isTableStart(
lines: List<String>,
i: Int,
): Boolean {
val l = lines[i]
if (!l.contains('|')) return false
if (i + 1 >= lines.size) return false
return isTableSeparator(lines[i + 1])
}
private fun isTableSeparator(line: String): Boolean {
val t = line.trim().trim('|')
if (t.isEmpty()) return false
// every cell must be only dashes/colons/spaces, with at least one dash
return t.split('|').all { cell ->
val c = cell.trim()
c.isNotEmpty() && c.all { it == '-' || it == ':' || it == ' ' } && c.any { it == '-' }
}
}
private fun parseTable(
lines: List<String>,
i: Int,
): Triple<List<String>, List<TableAlign>, List<List<String>>> {
val header = splitRow(lines[i])
val alignments =
splitRow(lines[i + 1]).map { cell ->
val c = cell.trim()
when {
c.startsWith(":") && c.endsWith(":") -> TableAlign.CENTER
c.endsWith(":") -> TableAlign.RIGHT
c.startsWith(":") -> TableAlign.LEFT
else -> TableAlign.LEFT
}
}
val body = mutableListOf<List<String>>()
var j = i + 2
while (j < lines.size && lines[j].contains('|') && lines[j].trim().isNotEmpty()) {
body.add(splitRow(lines[j]))
j++
}
return Triple(header, alignments, body)
}
private fun splitRow(line: String): List<String> {
val trimmed = line.trim().trim('|')
return trimmed.split('|').map { it.trim() }
}
private data class CodeFenceInfo(
val fenceChar: Char,
val fenceLength: Int,
val language: String?,
)
private fun parseCodeFenceStart(line: String): CodeFenceInfo? {
val trimmed = line.trimStart()
if (trimmed.length < 3) return null
val firstChar = trimmed[0]
if (firstChar != '`' && firstChar != '~') return null
val count = trimmed.takeWhile { it == firstChar }.length
if (count < 3) return null
val rest = trimmed.substring(count).trim()
if (firstChar == '`' && rest.contains('`')) return null
return CodeFenceInfo(
fenceChar = firstChar,
fenceLength = count,
language = rest.ifBlank { null },
)
}
private fun isCodeFenceEnd(
line: String,
fence: CodeFenceInfo,
): Boolean {
val trimmed = line.trimStart()
val count = trimmed.takeWhile { it == fence.fenceChar }.length
if (count < fence.fenceLength) return false
val rest = trimmed.substring(count).trim()
return rest.isEmpty()
}
private fun isDefListStart(
lines: List<String>,
i: Int,
): Boolean {
val l = lines[i].trim()
if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || parseCodeFenceStart(l) != null) return false
if (LIST_ITEM_LINE_RE.matches(lines[i])) return false
if (i + 1 >= lines.size) return false
return lines[i + 1].trim().startsWith(":")
}
private fun isValidHeading(line: String): Boolean {
if (!line.startsWith("#")) return false
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
return level < line.length && line[level] == ' '
}
private fun tryParseImage(line: String): MdBlock.Image? {
val m = IMAGE_RE.matchAt(line, 0) ?: return null
val uri = m.groupValues[2].trim()
if (uri.isEmpty()) return null
return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim())
}
private fun tryParseVideo(line: String): MdBlock.Video? {
val m = IMAGE_RE.matchAt(line, 0) ?: return null
val uri = m.groupValues[2].trim()
if (uri.isEmpty()) return null
val ext = uri.substringAfterLast('.', "").lowercase()
if (ext in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || uri.contains("video/", ignoreCase = true)) {
return MdBlock.Video(uri = uri, alt = m.groupValues[1].trim())
}
return null
}
private fun fallthroughToParagraph(
lines: List<String>,
start: Int,
blocks: MutableList<MdBlock>,
): Int {
var i = start
val para = mutableListOf<String>()
while (
i < lines.size &&
lines[i].isNotBlank() &&
parseCodeFenceStart(lines[i]) == null &&
!isValidHeading(lines[i]) &&
!lines[i].startsWith(">") &&
!LIST_ITEM_LINE_RE.matches(lines[i]) &&
!isHorizontalRule(lines[i]) &&
!isTableStart(lines, i) &&
!isDefListStart(lines, i) &&
tryParseDisplayMath(lines, i) == null &&
FN_DEF_RE.matchAt(lines[i], 0) == null
) {
para.add(lines[i])
i++
}
if (para.isNotEmpty()) blocks.add(MdBlock.Paragraph(para.joinToString("\n")))
return i
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import org.junit.Test import org.junit.Test
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@@ -11,6 +11,10 @@ import com.m57.hermescontrol.theme.StatusRedContainer
import com.m57.hermescontrol.theme.StatusYellow import com.m57.hermescontrol.theme.StatusYellow
import com.m57.hermescontrol.theme.StatusYellowContainer import com.m57.hermescontrol.theme.StatusYellowContainer
import com.m57.hermescontrol.theme.searchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors
import com.m57.hermescontrol.ui.chat.markdown.FnNote
import com.m57.hermescontrol.ui.chat.markdown.MdBlock
import com.m57.hermescontrol.ui.chat.markdown.TableAlign
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import com.m57.hermescontrol.util.BidiUtils import com.m57.hermescontrol.util.BidiUtils
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse