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 "blockquote" -> { endLine(); if (!closing) out.append("> ") }108 "ul" -> { endLine(); if (closing) lists.removeLastOrNull() else lists.addLast(-1) }109 "ol" -> { endLine(); if (closing) lists.removeLastOrNull() else lists.addLast(1) }110 "li" -> {111 endLine()112 if (!closing) {113 val ordinal = lists.lastOrNull() ?: -1114 if (ordinal < 0) {115 out.append("- ")116 } else {117 out.append("$ordinal. ")118 lists.removeLast()119 lists.addLast(ordinal + 1)120 }121 }122 }123 "br" -> out.append('\n')124 "hr" -> { endLine(); out.append("---\n") }125 "p", "div" -> endLine()126 127 128 }129 }130 text(html.substring(cursor))131 return out.toString().trim('\n')132}133134private fun decodeEntities(s: String): String {135 if (!s.contains('&')) return s136 return s137 .replace("<", "<")138 .replace(">", ">")139 .replace(""", "\"")140 .replace("'", "'")141 .replace("'", "'")142 .replace(" ", " ")143 .replace(' ', ' ') 144 .replace("&", "&") 145}146147private fun escape(s: String): String =148 s.replace("&", "&").replace("<", "<").replace(">", ">")149150151152private data class Run(val text: String, val marks: Set<Mark>)153154155156157158159private fun inlineRuns(line: String): List<Run> {160 val runs = mutableListOf<Run>()161 val open = ArrayDeque<Mark>()162 val buf = StringBuilder()163164 fun flush() {165 if (buf.isNotEmpty()) {166 runs += Run(buf.toString(), LinkedHashSet(open))167 buf.clear()168 }169 }170171 var i = 0172 while (i < line.length) {173 val innermost = open.lastOrNull()174 if (innermost != null && line.startsWith(innermost.markup, i)) {175 flush()176 open.removeLast()177 i += innermost.markup.length178 continue179 }180 val opening = Mark.entries.firstOrNull { line.startsWith(it.markup, i) && it !in open }181 if (opening != null) {182 flush()183 open.addLast(opening)184 i += opening.markup.length185 continue186 }187 buf.append(line[i])188 i++189 }190 flush()191 return runs192}193194private fun inlineHtml(line: String): String = inlineRuns(line).joinToString("") { run ->195 val marks = Mark.entries.filter { it in run.marks }196 marks.joinToString("") { "<${it.tag}>" } +197 escape(run.text) +198 marks.reversed().joinToString("") { "</${it.tag}>" }199}200201private fun markupToHtml(markup: String): String {202 val out = StringBuilder()203 var openList: String? = null204 var prevWasPlain = false205206 fun closeList() {207 openList?.let { out.append("</$it>"); openList = null }208 }209210 fun startList(kind: String) {211 if (openList != kind) {212 closeList()213 out.append("<$kind>")214 openList = kind215 }216 }217218 for (line in markup.split("\n")) {219 val plain = when {220 line.trim() == "---" -> { closeList(); out.append("<hr>"); false }221 line.startsWith("## ") -> { closeList(); out.append("<h2>${inlineHtml(line.removePrefix("## "))}</h2>"); false }222 line.startsWith("### ") -> { closeList(); out.append("<h3>${inlineHtml(line.removePrefix("### "))}</h3>"); false }223 line.startsWith("> ") -> { closeList(); out.append("<blockquote>${inlineHtml(line.removePrefix("> "))}</blockquote>"); false }224 line.startsWith("- ") -> { startList("ul"); out.append("<li>${inlineHtml(line.removePrefix("- "))}</li>"); false }225 ORDERED_ITEM.containsMatchIn(line) -> { startList("ol"); out.append("<li>${inlineHtml(line.replaceFirst(ORDERED_ITEM, ""))}</li>"); false }226 else -> {227 closeList()228 if (prevWasPlain) out.append("<br>")229 out.append(inlineHtml(line))230 true231 }232 }233 prevWasPlain = plain234 }235 closeList()236 return out.toString()237}238239private val HEADING2 = SpanStyle(fontWeight = FontWeight.SemiBold, fontSize = 19.sp)240private val HEADING3 = SpanStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp)241private val QUOTE = SpanStyle(fontStyle = FontStyle.Italic)242243private fun markupToAnnotated(markup: String): AnnotatedString = buildAnnotatedString {244 markup.split("\n").forEachIndexed { index, line ->245 if (index > 0) append('\n')246 when {247 line.trim() == "---" -> append("──────────")248 line.startsWith("## ") -> appendRuns(line.removePrefix("## "), HEADING2)249 line.startsWith("### ") -> appendRuns(line.removePrefix("### "), HEADING3)250 line.startsWith("> ") -> appendRuns(line.removePrefix("> "), QUOTE)251 line.startsWith("- ") -> { append("• "); appendRuns(line.removePrefix("- "), null) }252 ORDERED_ITEM.containsMatchIn(line) -> {253 append(ORDERED_ITEM.find(line)!!.value.trimEnd() + " ")254 appendRuns(line.replaceFirst(ORDERED_ITEM, ""), null)255 }256 else -> appendRuns(line, null)257 }258 }259}260261private fun AnnotatedString.Builder.appendRuns(line: String, base: SpanStyle?) {262 for (run in inlineRuns(line)) {263 val style = spanStyle(run.marks).let { if (base == null) it else base.merge(it) }264 withStyle(style) { append(run.text) }265 }266}267268private fun spanStyle(marks: Set<Mark>): SpanStyle {269 val decorations = buildList {270 if (Mark.UNDERLINE in marks) add(TextDecoration.Underline)271 if (Mark.STRIKE in marks) add(TextDecoration.LineThrough)272 }273 return SpanStyle(274 fontWeight = if (Mark.BOLD in marks) FontWeight.Bold else null,275 fontStyle = if (Mark.ITALIC in marks) FontStyle.Italic else null,276 textDecoration = if (decorations.isEmpty()) null else TextDecoration.combine(decorations),277 )278}