diff --git a/app/src/main/kotlin/org/libremail/richtext/RichText.kt b/app/src/main/kotlin/org/libremail/richtext/RichText.kt index 652ca8d..4da265e 100644 --- a/app/src/main/kotlin/org/libremail/richtext/RichText.kt +++ b/app/src/main/kotlin/org/libremail/richtext/RichText.kt @@ -102,3 +102,20 @@ internal fun lineMarker(line: String): String? = when { line.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX else -> ORDERED_PREFIX.find(line)?.value } + +/** + * Merges spans with the exact same style value that touch or overlap, yielding the canonical + * maximal-run form. Shared by the HTML parser and the editing ops so the two can never drift. + */ +internal fun mergeSameValueSpans(spans: List): List { + val merged = ArrayList() + for (span in spans.sortedWith(compareBy({ it.start }, { it.end }))) { + val i = merged.indexOfLast { it.style == span.style && span.start <= it.end } + if (i >= 0) { + merged[i] = merged[i].copy(end = maxOf(merged[i].end, span.end)) + } else { + merged.add(span) + } + } + return merged +} diff --git a/app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt b/app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt index c2d8078..2434ca1 100644 --- a/app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt +++ b/app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt @@ -28,7 +28,7 @@ object RichTextEditing { val updated = if (isFullyStyled(sameKind.filter { it.style == style }, start, end)) { cleared } else { - mergeSameValue(cleared + RichSpan(start, end, style)) + mergeSameValueSpans(cleared + RichSpan(start, end, style)) } return content.copy(spans = (otherKinds + updated).sortedBy { it.start }) } @@ -160,20 +160,6 @@ private fun subtractRange(spans: List, start: Int, end: Int): List): List { - val merged = ArrayList() - for (span in spans.sortedWith(compareBy({ it.start }, { it.end }))) { - val i = merged.indexOfLast { it.style == span.style && span.start <= it.end } - if (i >= 0) { - merged[i] = merged[i].copy(end = maxOf(merged[i].end, span.end)) - } else { - merged.add(span) - } - } - return merged -} - // --- block marker helpers --- private val ORDERED = Regex("^\\d+\\. ") diff --git a/app/src/main/kotlin/org/libremail/richtext/RichTextHtml.kt b/app/src/main/kotlin/org/libremail/richtext/RichTextHtml.kt index bfe20ff..7553f0e 100644 --- a/app/src/main/kotlin/org/libremail/richtext/RichTextHtml.kt +++ b/app/src/main/kotlin/org/libremail/richtext/RichTextHtml.kt @@ -122,12 +122,17 @@ private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List, from: Int): Int { val align = alignFor(content, lines[from]) sb.append(openBlockTag("p", align)) + val inner = StringBuilder() var i = from while (i < lines.size && lines[i].kind == Kind.PARAGRAPH && alignFor(content, lines[i]) == align) { - if (i > from) sb.append("
") - sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)) + if (i > from) inner.append("
") + inner.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)) i++ } + // A group of only empty lines would otherwise vanish on parse (the closing tag's newline is a + // no-op at a line start), so it emits one
per line to keep the blank lines round-trippable. + val allEmpty = (from until i).all { lines[it].contentStart >= lines[it].contentEnd } + sb.append(if (allEmpty) "
".repeat(i - from) else inner) sb.append("

") return i } diff --git a/app/src/main/kotlin/org/libremail/richtext/RichTextHtmlParser.kt b/app/src/main/kotlin/org/libremail/richtext/RichTextHtmlParser.kt index 16892f5..ffb42a2 100644 --- a/app/src/main/kotlin/org/libremail/richtext/RichTextHtmlParser.kt +++ b/app/src/main/kotlin/org/libremail/richtext/RichTextHtmlParser.kt @@ -218,7 +218,7 @@ internal class RichTextHtmlParser(private val html: String) { val len = out.length return RichTextContent( text = out, - spans = mergedSpans(spans.mapNotNull { it.clampedTo(len) }), + spans = mergeSameValueSpans(spans.mapNotNull { it.clampedTo(len) }), links = links.mapNotNull { it.clampedTo(len) }, alignments = mergedAlignments(alignments.mapNotNull { it.clampedTo(len) }), images = images.filter { it.end <= len }, @@ -242,20 +242,6 @@ private fun RichAlignment.clampedTo(len: Int): RichAlignment? { return if (start < e) copy(end = e) else null } -/** Merges same-style spans that touch or overlap, yielding the canonical maximal-run form. */ -private fun mergedSpans(spans: List): List { - val merged = ArrayList() - for (span in spans.sortedWith(compareBy({ it.start }, { it.end }))) { - val i = merged.indexOfLast { it.style == span.style && span.start <= it.end } - if (i >= 0) { - merged[i] = merged[i].copy(end = maxOf(merged[i].end, span.end)) - } else { - merged.add(span) - } - } - return merged -} - /** Merges same-alignment runs on adjacent lines (ranges separated by exactly the newline). */ private fun mergedAlignments(alignments: List): List { val merged = ArrayList() diff --git a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt index 734db36..18f4804 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt @@ -300,7 +300,7 @@ private fun applyBlock( value: TextFieldValue, marker: BlockMarker, linkColor: Color, - resolveFont: (String) -> FontFamily? = { null }, + resolveFont: (String) -> FontFamily?, ): TextFieldValue { val result = RichTextEditing.toggleBlock( value.annotatedString.toRichContent(), @@ -318,7 +318,7 @@ private fun applyLink( value: TextFieldValue, url: String, linkColor: Color, - resolveFont: (String) -> FontFamily? = { null }, + resolveFont: (String) -> FontFamily?, ): TextFieldValue { val updated = RichTextEditing.applyLink( value.annotatedString.toRichContent(), diff --git a/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt b/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt index e4f3506..79bbecf 100644 --- a/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt +++ b/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt @@ -187,6 +187,20 @@ class RichTextHtmlTest { ) } + @Test + fun `blank line between differently aligned paragraphs survives round-trip`() { + val content = RichTextContent( + text = "a\n\nb", + alignments = listOf(RichAlignment(0, 1, RichAlign.CENTER), RichAlignment(3, 4, RichAlign.CENTER)), + ) + // The empty middle group must emit an explicit
or the blank line vanishes on parse. + assertEquals( + "

a


b

", + RichTextHtml.toHtml(content), + ) + assertRoundTrips(content) + } + @Test fun `parser accepts start and end alignment synonyms`() { val restored = RichTextHtml.fromHtml("

a

b

")