NoteHtml.kt

10.8 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, 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 fourteen20 * 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`, `- 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            "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            // Anything else is dropped tag-only, keeping its text — the same126            // thing the browser's sanitizer does with a disallowed element.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("&lt;", "<")137        .replace("&gt;", ">")138        .replace("&quot;", "\"")139        .replace("&#39;", "'")140        .replace("&apos;", "'")141        .replace("&nbsp;", " ")142        .replace(' ', ' ') // contenteditable emits the raw character too, not just the entity143        .replace("&amp;", "&") // last: &amp;lt; must decode to &lt;, not <144}145146private fun escape(s: String): String =147    s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")148149// ── markup -> HTML / AnnotatedString ──────────────────────────────────────150151private data class Run(val text: String, val marks: Set<Mark>)152153/**154 * Split a line on its inline markers. The innermost open marker is matched155 * first so `**a *b***` closes the italic before the bold instead of tripping156 * over the shared `*`.157 */158private 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}