fix(richtext): preserve blank lines between aligned paragraphs; share span merging

Code-review fixes: an all-empty paragraph group (a blank line isolated by an
alignment split) emitted <p></p>, which the parser collapses — it now emits one
<br> per line so blank lines round-trip. The identical span-merge helper that
existed in both the parser and RichTextEditing is now a single shared
mergeSameValueSpans() in RichText.kt, and the private applyBlock/applyLink drop
their never-used default font resolver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 17:05:14 -05:00
co-authored by Claude Fable 5
parent 2cefc4751b
commit 4782b24453
6 changed files with 42 additions and 34 deletions
@@ -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<RichSpan>): List<RichSpan> {
val merged = ArrayList<RichSpan>()
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
}
@@ -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<RichSpan>, start: Int, end: Int): List<Ric
}
}
/** Merges spans with the exact same style value that touch or overlap. */
private fun mergeSameValue(spans: List<RichSpan>): List<RichSpan> {
val merged = ArrayList<RichSpan>()
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+\\. ")
@@ -122,12 +122,17 @@ private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List<L
private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List<Line>, 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("<br>")
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
if (i > from) inner.append("<br>")
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 <br> 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) "<br>".repeat(i - from) else inner)
sb.append("</p>")
return i
}
@@ -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<RichSpan>): List<RichSpan> {
val merged = ArrayList<RichSpan>()
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<RichAlignment>): List<RichAlignment> {
val merged = ArrayList<RichAlignment>()
@@ -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(),
@@ -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 <br> or the blank line vanishes on parse.
assertEquals(
"<p style=\"text-align:center\">a</p><p><br></p><p style=\"text-align:center\">b</p>",
RichTextHtml.toHtml(content),
)
assertRoundTrips(content)
}
@Test
fun `parser accepts start and end alignment synonyms`() {
val restored = RichTextHtml.fromHtml("<p style=\"text-align:start\">a</p><p style=\"text-align:end\">b</p>")