diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MarkdownText.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MarkdownText.kt new file mode 100644 index 0000000..a804ecb --- /dev/null +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MarkdownText.kt @@ -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("(?") } + 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("
").append(code).append("")
+ 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("") + .append(inline(trimmed.removePrefix(">").trimStart())) + .append("") + } else { + html.append("
").append(inline(trimmed)).append("
") + } + } + if (inFence) { + html.append("").append(code).append("")
+ }
+ closeList()
+ return html.toString()
+ }
+
+ private fun inline(text: String): String {
+ val codes = mutableListOf${match.groupValues[1]}"
+ "%%MDCODE${codes.lastIndex}%%"
+ }
+ current = image.replace(current) { match -> escape(match.groupValues[1]) }
+ current = link.replace(current) { match ->
+ """${match.groupValues[1]}"""
+ }
+ current = boldItalic.replace(current) { match -> "${match.groupValues[1]}" }
+ current = bold.replace(current) { match -> "${match.groupValues[2]}" }
+ current = italic.replace(current) { match -> "${match.groupValues[1]}" }
+ current = italicUnderscore.replace(current) { match -> "${match.groupValues[1]}" }
+ codes.forEachIndexed { index, value ->
+ current = current.replace("%%MDCODE$index%%", value)
+ }
+ return current
+ }
+
+ private fun escape(text: String): String =
+ text.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+}
diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt
index 8475beb..c718614 100644
--- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt
+++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt
@@ -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
diff --git a/ios/OralTrainer/Views/PlayerSectionView.swift b/ios/OralTrainer/Views/PlayerSectionView.swift
index 3e702eb..ccf4af3 100644
--- a/ios/OralTrainer/Views/PlayerSectionView.swift
+++ b/ios/OralTrainer/Views/PlayerSectionView.swift
@@ -256,10 +256,7 @@ struct SentenceExplanationSheet: View {
.font(.system(size: 15))
.foregroundColor(Theme.textSubtle)
} else {
- Text(explanation)
- .font(.system(size: 16))
- .foregroundColor(Theme.textDark)
- .lineSpacing(5)
+ MarkdownExplanationText(text: explanation)
}
}
.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
+ }
+}