add Markdown surpport

This commit is contained in:
2026-09-24 08:42:11 +08:00
parent 2a7f24b1bd
commit 37ee850ee1
3 changed files with 189 additions and 8 deletions

View File

@@ -0,0 +1,130 @@
package cn.learningpad.oraltrainer.sample
import android.widget.TextView
import androidx.core.text.HtmlCompat
/** Renders common LLM Markdown into a TextView without extra libraries. */
internal object MarkdownText {
private val heading = Regex("^(#{1,6})\\s*(.*)$")
private val unordered = Regex("^[-*+]\\s+(.*)$")
private val ordered = Regex("^\\d+[.)]\\s+(.*)$")
private val boldItalic = Regex("\\*\\*\\*(.+?)\\*\\*\\*")
private val bold = Regex("(\\*\\*|__)(.+?)\\1")
private val italic = Regex("(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)")
private val italicUnderscore = Regex("(?<!\\w)_(?!_)(.+?)(?<!_)_(?!\\w)")
private val inlineCode = Regex("`([^`]+)`")
private val image = Regex("!\\[([^\\]]*)]\\(([^)]+)\\)")
private val link = Regex("\\[([^\\]]+)]\\(([^)]+)\\)")
fun set(view: TextView, markdown: String) {
view.text = render(markdown)
}
fun render(markdown: String): CharSequence {
return HtmlCompat.fromHtml(toHtml(markdown), HtmlCompat.FROM_HTML_MODE_COMPACT)
}
internal fun toHtml(markdown: String): String {
val normalized = markdown.replace("\r\n", "\n").replace('\r', '\n')
val html = StringBuilder()
var inFence = false
val code = StringBuilder()
var listTag: String? = null
fun closeList() {
listTag?.let { html.append("</$it>") }
listTag = null
}
fun openList(tag: String) {
if (listTag != tag) {
closeList()
listTag = tag
html.append('<').append(tag).append('>')
}
}
for (line in normalized.split('\n')) {
val trimmed = line.trim()
if (trimmed.startsWith("```")) {
if (inFence) {
html.append("<pre><code>").append(code).append("</code></pre>")
code.setLength(0)
inFence = false
} else {
closeList()
inFence = true
}
continue
}
if (inFence) {
if (code.isNotEmpty()) code.append('\n')
code.append(escape(line))
continue
}
if (trimmed.isEmpty()) {
closeList()
continue
}
val headingMatch = heading.matchEntire(trimmed)
if (headingMatch != null) {
closeList()
val level = headingMatch.groupValues[1].length
html.append("<h$level>").append(inline(headingMatch.groupValues[2])).append("</h$level>")
continue
}
val unorderedMatch = unordered.matchEntire(trimmed)
if (unorderedMatch != null) {
openList("ul")
html.append("<li>").append(inline(unorderedMatch.groupValues[1])).append("</li>")
continue
}
val orderedMatch = ordered.matchEntire(trimmed)
if (orderedMatch != null) {
openList("ol")
html.append("<li>").append(inline(orderedMatch.groupValues[1])).append("</li>")
continue
}
closeList()
if (trimmed.startsWith(">")) {
html.append("<blockquote>")
.append(inline(trimmed.removePrefix(">").trimStart()))
.append("</blockquote>")
} else {
html.append("<p>").append(inline(trimmed)).append("</p>")
}
}
if (inFence) {
html.append("<pre><code>").append(code).append("</code></pre>")
}
closeList()
return html.toString()
}
private fun inline(text: String): String {
val codes = mutableListOf<String>()
var current = escape(text)
current = inlineCode.replace(current) { match ->
codes += "<code>${match.groupValues[1]}</code>"
"%%MDCODE${codes.lastIndex}%%"
}
current = image.replace(current) { match -> escape(match.groupValues[1]) }
current = link.replace(current) { match ->
"""<a href="${match.groupValues[2]}">${match.groupValues[1]}</a>"""
}
current = boldItalic.replace(current) { match -> "<b><i>${match.groupValues[1]}</i></b>" }
current = bold.replace(current) { match -> "<b>${match.groupValues[2]}</b>" }
current = italic.replace(current) { match -> "<i>${match.groupValues[1]}</i>" }
current = italicUnderscore.replace(current) { match -> "<i>${match.groupValues[1]}</i>" }
codes.forEachIndexed { index, value ->
current = current.replace("%%MDCODE$index%%", value)
}
return current
}
private fun escape(text: String): String =
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
}

View File

@@ -123,8 +123,7 @@ internal class SentenceExplanationOverlay(
val cached = store.load(mediaId, sentence.index) val cached = store.load(mediaId, sentence.index)
if (!cached.isNullOrBlank()) { if (!cached.isNullOrBlank()) {
message.text = cached showMarkdown(message, cached)
message.setTextColor(SampleTheme.textDark)
markSaved(saveButton, saveHint) markSaved(saveButton, saveHint)
return return
} }
@@ -145,8 +144,7 @@ internal class SentenceExplanationOverlay(
override fun onSuccess(result: SentenceExplanationResult) { override fun onSuccess(result: SentenceExplanationResult) {
if (generation != requestGeneration) return if (generation != requestGeneration) return
loadedExplanation = result.explanation loadedExplanation = result.explanation
message.text = result.explanation showMarkdown(message, result.explanation)
message.setTextColor(SampleTheme.textDark)
saveButton.isEnabled = true saveButton.isEnabled = true
saveButton.alpha = 1f saveButton.alpha = 1f
} }
@@ -176,6 +174,11 @@ internal class SentenceExplanationOverlay(
api.release() api.release()
} }
private fun showMarkdown(message: TextView, explanation: String) {
message.setTextColor(SampleTheme.textDark)
MarkdownText.set(message, explanation)
}
private fun markSaved(saveButton: TextView, saveHint: TextView) { private fun markSaved(saveButton: TextView, saveHint: TextView) {
saveButton.text = "已保存" saveButton.text = "已保存"
saveButton.isEnabled = false saveButton.isEnabled = false

View File

@@ -256,10 +256,7 @@ struct SentenceExplanationSheet: View {
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundColor(Theme.textSubtle) .foregroundColor(Theme.textSubtle)
} else { } else {
Text(explanation) MarkdownExplanationText(text: explanation)
.font(.system(size: 16))
.foregroundColor(Theme.textDark)
.lineSpacing(5)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -320,3 +317,54 @@ struct SentenceExplanationSheet: View {
) )
} }
} }
private struct MarkdownExplanationText: View {
let text: String
var body: some View {
Text(Self.attributed(from: text))
.font(.system(size: 16))
.foregroundColor(Theme.textDark)
.tint(Theme.buttonBlue)
.lineSpacing(5)
.frame(maxWidth: .infinity, alignment: .leading)
}
private static func attributed(from source: String) -> AttributedString {
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible
)
if let parsed = try? AttributedString(
markdown: preservingLineBreaks(source),
options: options
) {
return parsed
}
return AttributedString(source)
}
private static func preservingLineBreaks(_ source: String) -> String {
let normalized = source
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
var result = ""
var inFence = false
let lines = normalized.split(separator: "\n", omittingEmptySubsequences: false)
for (index, rawLine) in lines.enumerated() {
let line = String(rawLine)
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("```") {
inFence.toggle()
}
result += line
guard index < lines.count - 1 else { continue }
if inFence || trimmed.isEmpty {
result += "\n"
} else {
result += "\n\n"
}
}
return result
}
}