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
")