add Markdown surpport
This commit is contained in:
@@ -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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user