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)
|
||||
if (!cached.isNullOrBlank()) {
|
||||
message.text = cached
|
||||
message.setTextColor(SampleTheme.textDark)
|
||||
showMarkdown(message, cached)
|
||||
markSaved(saveButton, saveHint)
|
||||
return
|
||||
}
|
||||
@@ -145,8 +144,7 @@ internal class SentenceExplanationOverlay(
|
||||
override fun onSuccess(result: SentenceExplanationResult) {
|
||||
if (generation != requestGeneration) return
|
||||
loadedExplanation = result.explanation
|
||||
message.text = result.explanation
|
||||
message.setTextColor(SampleTheme.textDark)
|
||||
showMarkdown(message, result.explanation)
|
||||
saveButton.isEnabled = true
|
||||
saveButton.alpha = 1f
|
||||
}
|
||||
@@ -176,6 +174,11 @@ internal class SentenceExplanationOverlay(
|
||||
api.release()
|
||||
}
|
||||
|
||||
private fun showMarkdown(message: TextView, explanation: String) {
|
||||
message.setTextColor(SampleTheme.textDark)
|
||||
MarkdownText.set(message, explanation)
|
||||
}
|
||||
|
||||
private fun markSaved(saveButton: TextView, saveHint: TextView) {
|
||||
saveButton.text = "已保存"
|
||||
saveButton.isEnabled = false
|
||||
|
||||
Reference in New Issue
Block a user