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(headingMatch.groupValues[2])).append("") + continue + } + val unorderedMatch = unordered.matchEntire(trimmed) + if (unorderedMatch != null) { + openList("ul") + html.append("
  • ").append(inline(unorderedMatch.groupValues[1])).append("
  • ") + continue + } + val orderedMatch = ordered.matchEntire(trimmed) + if (orderedMatch != null) { + openList("ol") + html.append("
  • ").append(inline(orderedMatch.groupValues[1])).append("
  • ") + continue + } + closeList() + if (trimmed.startsWith(">")) { + 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() + var current = escape(text) + current = inlineCode.replace(current) { match -> + codes += "${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 + } +}