NoteHtml.kt

11.2 KB · raw

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.sp1112/*13 * Note bodies are authored in the browser's contenteditable, so a synced note14 * arrives as HTML: the extension's sanitizer allow-list (NoteEditor.tsx) is15 * b, strong, i, em, u, s, h2, h3, blockquote, ul, ol, li, br, hr, p — attribute-less — plus16 * the div wrappers Chrome's contenteditable inserts for new lines. Android read17 * that field as plain text, which is why the phone showed raw tags.18 *19 * Parsed here rather than through HtmlCompat: the vocabulary is fifteen20 * attribute-less tags, host-JVM unit tests cannot touch android.text, and21 * Html.toHtml serialises back as `<p dir="ltr" style="…">` — outside the22 * allow-list, so it could not round-trip to the browser.23 *24 * Compose drops AnnotatedString spans on every keystroke, so there is no sound25 * WYSIWYG editing surface. Editing therefore goes through a plain-text markup26 * (`**bold**`, `## heading`, `> quote`, `- item`) that converts back to the same tag27 * vocabulary on save. The markup never leaves the device — it exists only28 * between load and save.29 */3031/** Inline marks, longest markup first so `**` wins over `*` when scanning. */32private 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+\\. ")4344/** Same test the browser's toHtml uses to decide plain text vs markup. */45internal fun isNoteHtml(raw: String): Boolean = TAG_HINT.containsMatchIn(raw)4647/** Stored note -> the text the editor shows and the user edits. */48internal fun noteEditorText(stored: String): String =49    if (isNoteHtml(stored)) htmlToMarkup(stored) else stored5051/**52 * Editor text -> stored note. A note that arrived as plain text stays plain53 * text: markup is only interpreted once the user opts into formatting, so a54 * shopping list with "- eggs" in it is not silently turned into a `<ul>`.55 */56internal fun noteStorageText(editorText: String, isHtml: Boolean): String =57    if (isHtml) markupToHtml(editorText) else editorText5859/** Editor text -> rendered form, for the read views. */60internal fun noteRendered(editorText: String, isHtml: Boolean): AnnotatedString =61    if (isHtml) markupToAnnotated(editorText) else AnnotatedString(editorText)6263/** Stored note -> rendered form. */64internal fun noteRendered(stored: String): AnnotatedString =65    noteRendered(noteEditorText(stored), isNoteHtml(stored))6667/** Stored note -> plain text, for search and the clipboard. */68internal fun notePlainText(stored: String): String = noteRendered(stored).text6970/** First non-blank rendered line — the note list's preview. */71internal fun notePreviewLine(stored: String): String =72    notePlainText(stored).lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty()7374// ── HTML -> markup ────────────────────────────────────────────────────────7576private fun htmlToMarkup(html: String): String {77    val out = StringBuilder()78    // Ordinal per open <ol>; -1 marks a <ul>.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        // Whitespace between block tags is layout, not content.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            // Anything else is dropped tag-only, keeping its text — the same127            // thing the browser's sanitizer does with a disallowed element.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("&lt;", "<")138        .replace("&gt;", ">")139        .replace("&quot;", "\"")140        .replace("&#39;", "'")141        .replace("&apos;", "'")142        .replace("&nbsp;", " ")143        .replace(' ', ' ') // contenteditable emits the raw character too, not just the entity144        .replace("&amp;", "&") // last: &amp;lt; must decode to &lt;, not <145}146147private fun escape(s: String): String =148    s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")149150// ── markup -> HTML / AnnotatedString ──────────────────────────────────────151152private data class Run(val text: String, val marks: Set<Mark>)153154/**155 * Split a line on its inline markers. The innermost open marker is matched156 * first so `**a *b***` closes the italic before the bold instead of tripping157 * over the shared `*`.158 */159private 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}