1package com.peachpasswords.ui.vault23import androidx.compose.ui.text.AnnotatedString4import androidx.compose.ui.text.SpanStyle5import androidx.compose.ui.text.buildAnnotatedString6import androidx.compose.ui.text.font.FontStyle7import androidx.compose.ui.text.font.FontWeight8import androidx.compose.ui.text.style.TextDecoration9import androidx.compose.ui.text.withStyle10import androidx.compose.ui.unit.sp11121314151617181920212223242526272829303132private enum class Mark(val markup: String, val tag: String) {33 BOLD("**", "b"),34 UNDERLINE("__", "u"),35 STRIKE("~~", "s"),36 ITALIC("*", "i"),37}3839private val TAG_HINT = Regex("</?[a-z]", RegexOption.IGNORE_CASE)40private val TAG = Regex("<[^>]*>")41private val WHITESPACE = Regex("\\s+")42private val ORDERED_ITEM = Regex("^\\d+\\. ")434445internal fun isNoteHtml(raw: String): Boolean = TAG_HINT.containsMatchIn(raw)464748internal fun noteEditorText(stored: String): String =49 if (isNoteHtml(stored)) htmlToMarkup(stored) else stored50515253545556internal fun noteStorageText(editorText: String, isHtml: Boolean): String =57 if (isHtml) markupToHtml(editorText) else editorText585960internal fun noteRendered(editorText: String, isHtml: Boolean): AnnotatedString =61 if (isHtml) markupToAnnotated(editorText) else AnnotatedString(editorText)626364internal fun noteRendered(stored: String): AnnotatedString =65 noteRendered(noteEditorText(stored), isNoteHtml(stored))666768internal fun notePlainText(stored: String): String = noteRendered(stored).text697071internal fun notePreviewLine(stored: String): String =72 notePlainText(stored).lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty()73747576private fun htmlToMarkup(html: String): String {77 val out = StringBuilder()78 79 val lists = ArrayDeque<Int>()8081 fun endLine() {82 if (out.isNotEmpty() && !out.endsWith("\n")) out.append('\n')83 }8485 fun text(chunk: String) {86 if (chunk.isEmpty()) return87 val decoded = decodeEntities(chunk).replace(WHITESPACE, " ")88 89 if (decoded.isBlank() && (out.isEmpty() || out.endsWith("\n"))) return90 out.append(decoded)91 }9293 var cursor = 094 for (match in TAG.findAll(html)) {95 text(html.substring(cursor, match.range.first))96 cursor = match.range.last + 197 val token = match.value98 val closing = token.startsWith("</")99 val name = token.removePrefix("</").removePrefix("<").takeWhile { it.isLetterOrDigit() }.lowercase()100 when (name) {101 "b", "strong" -> out.append(Mark.BOLD.markup)102 "i", "em" -> out.append(Mark.ITALIC.markup)103 "u" -> out.append(Mark.UNDERLINE.markup)104 "s", "strike", "del" -> out.append(Mark.STRIKE.markup)105 "h2" -> { endLine(); if (!closing) out.append("## ") }106 "h3" -> { endLine(); if (!closing) out.append("### ") }107 "ul" -> { endLine(); if (closing) lists.removeLastOrNull() else lists.addLast(-1) }108 "ol" -> { endLine(); if (closing) lists.removeLastOrNull() else lists.addLast(1) }109 "li" -> {110 endLine()111 if (!closing) {112 val ordinal = lists.lastOrNull() ?: -1113 if (ordinal < 0) {114 out.append("- ")115 } else {116 out.append("$ordinal. ")117 lists.removeLast()118 lists.addLast(ordinal + 1)119 }120 }121 }122 "br" -> out.append('\n')123 "hr" -> { endLine(); out.append("---\n") }124 "p", "div" -> endLine()125 126 127 }128 }129 text(html.substring(cursor))130 return out.toString().trim('\n')131}132133private fun decodeEntities(s: String): String {134 if (!s.contains('&')) return s135 return s136 .replace("<", "<")137 .replace(">", ">")138 .replace(""", "\"")139 .replace("'", "'")140 .replace("'", "'")141 .replace(" ", " ")142 .replace(' ', ' ') 143 .replace("&", "&") 144}145146private fun escape(s: String): String =147 s.replace("&", "&").replace("<", "<").replace(">", ">")148149150151private data class Run(val text: String, val marks: Set<Mark>)152153154155156157158private fun inlineRuns(line: String): List<Run> {159 val runs = mutableListOf<Run>()160 val open = ArrayDeque<Mark>()161 val buf = StringBuilder()162163 fun flush() {164 if (buf.isNotEmpty()) {165 runs += Run(buf.toString(), LinkedHashSet(open))166 buf.clear()167 }168 }169170 var i = 0171 while (i < line.length) {172 val innermost = open.lastOrNull()173 if (innermost != null && line.startsWith(innermost.markup, i)) {174 flush()175 open.removeLast()176 i += innermost.markup.length177 continue178 }179 val opening = Mark.entries.firstOrNull { line.startsWith(it.markup, i) && it !in open }180 if (opening != null) {181 flush()182 open.addLast(opening)183 i += opening.markup.length184 continue185 }186 buf.append(line[i])187 i++188 }189 flush()190 return runs191}192193private fun inlineHtml(line: String): String = inlineRuns(line).joinToString("") { run ->194 val marks = Mark.entries.filter { it in run.marks }195 marks.joinToString("") { "<${it.tag}>" } +196 escape(run.text) +197 marks.reversed().joinToString("") { "</${it.tag}>" }198}199200private fun markupToHtml(markup: String): String {201 val out = StringBuilder()202 var openList: String? = null203 var prevWasPlain = false204205 fun closeList() {206 openList?.let { out.append("</$it>"); openList = null }207 }208209 fun startList(kind: String) {210 if (openList != kind) {211 closeList()212 out.append("<$kind>")213 openList = kind214 }215 }216217 for (line in markup.split("\n")) {218 val plain = when {219 line.trim() == "---" -> { closeList(); out.append("<hr>"); false }220 line.startsWith("## ") -> { closeList(); out.append("<h2>${inlineHtml(line.removePrefix("## "))}</h2>"); false }221 line.startsWith("### ") -> { closeList(); out.append("<h3>${inlineHtml(line.removePrefix("### "))}</h3>"); false }222 line.startsWith("- ") -> { startList("ul"); out.append("<li>${inlineHtml(line.removePrefix("- "))}</li>"); false }223 ORDERED_ITEM.containsMatchIn(line) -> { startList("ol"); out.append("<li>${inlineHtml(line.replaceFirst(ORDERED_ITEM, ""))}</li>"); false }224 else -> {225 closeList()226 if (prevWasPlain) out.append("<br>")227 out.append(inlineHtml(line))228 true229 }230 }231 prevWasPlain = plain232 }233 closeList()234 return out.toString()235}236237private val HEADING2 = SpanStyle(fontWeight = FontWeight.SemiBold, fontSize = 19.sp)238private val HEADING3 = SpanStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp)239240private fun markupToAnnotated(markup: String): AnnotatedString = buildAnnotatedString {241 markup.split("\n").forEachIndexed { index, line ->242 if (index > 0) append('\n')243 when {244 line.trim() == "---" -> append("──────────")245 line.startsWith("## ") -> appendRuns(line.removePrefix("## "), HEADING2)246 line.startsWith("### ") -> appendRuns(line.removePrefix("### "), HEADING3)247 line.startsWith("- ") -> { append("• "); appendRuns(line.removePrefix("- "), null) }248 ORDERED_ITEM.containsMatchIn(line) -> {249 append(ORDERED_ITEM.find(line)!!.value.trimEnd() + " ")250 appendRuns(line.replaceFirst(ORDERED_ITEM, ""), null)251 }252 else -> appendRuns(line, null)253 }254 }255}256257private fun AnnotatedString.Builder.appendRuns(line: String, base: SpanStyle?) {258 for (run in inlineRuns(line)) {259 val style = spanStyle(run.marks).let { if (base == null) it else base.merge(it) }260 withStyle(style) { append(run.text) }261 }262}263264private fun spanStyle(marks: Set<Mark>): SpanStyle {265 val decorations = buildList {266 if (Mark.UNDERLINE in marks) add(TextDecoration.Underline)267 if (Mark.STRIKE in marks) add(TextDecoration.LineThrough)268 }269 return SpanStyle(270 fontWeight = if (Mark.BOLD in marks) FontWeight.Bold else null,271 fontStyle = if (Mark.ITALIC in marks) FontStyle.Italic else null,272 textDecoration = if (decorations.isEmpty()) null else TextDecoration.combine(decorations),273 )274}