feat(richtext): parameterized styles, alignment/image/base-style channels, HTML round-trip

RichStyle becomes a sealed interface (Bold/Italic/Underline/Strikethrough +
FontFamily/FontSize/FontColor/Highlight); RichTextContent gains alignments,
images, and baseStyle channels. The HTML serializer emits merged <span style>
runs, text-align on <p>/<li> (splitting merged paragraphs at alignment
boundaries), <img src="cid:…"> over the visible [image: name] token, and a
single outer <div style> for the base style. The parser is a faithful inverse
and additionally tolerates <del>/<strike>, px font sizes, #rgb colors, and
start/end alignment synonyms; unknown CSS is ignored without dropping text.

hasFormatting() covers every new channel so ComposeViewModel.normalizedHtml()
never silently drops serialized formatting. The editor carries parameterized
style identity via string annotations (libremail:style / libremail:image), maps
alignment onto ParagraphStyle ranges, holds baseStyle in separate field state,
and RichTextEditing.toggleStyle now replaces a different value of the same kind
while styleAt() answers "current value over the selection" for pickers.
ColorSwatchRow is added for the upcoming color/highlight dialogs. No UI change.

Closes #70

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 16:54:25 -05:00
co-authored by Claude Fable 5
parent f5c9d4c4d2
commit 2cefc4751b
10 changed files with 1267 additions and 363 deletions
@@ -1,8 +1,33 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.richtext
/** Inline character styles the compose editor supports. */
enum class RichStyle { BOLD, ITALIC, UNDERLINE }
/**
* Inline character styles the compose editor supports. The simple toggles are singletons; the
* parameterized styles carry their value, and a well-formed [RichTextContent] never overlaps two
* values of the same kind (editing ops replace the old value instead of stacking a second one).
*/
sealed interface RichStyle {
data object Bold : RichStyle
data object Italic : RichStyle
data object Underline : RichStyle
/** Struck-through text: serialized as `<s>`, also parsed from `<del>`/`<strike>`. */
data object Strikethrough : RichStyle
/** A CSS font-family stack (e.g. `"Liberation Serif", serif`), serialized verbatim. */
data class FontFamily(val css: String) : RichStyle
/** Font size in points; parsed from `pt` or `px` (px convert at 3/4 pt per px, rounded). */
data class FontSize(val pt: Int) : RichStyle
/** Text color as ARGB; serialized as `#rrggbb`, so only opaque colors round-trip. */
data class FontColor(val argb: Int) : RichStyle
/** Background highlight as ARGB; serialized as `#rrggbb`, so only opaque colors round-trip. */
data class Highlight(val argb: Int) : RichStyle
}
/** A run of [style] over the half-open range [[start], [end]) of the plain text. */
data class RichSpan(val start: Int, val end: Int, val style: RichStyle)
@@ -10,8 +35,32 @@ data class RichSpan(val start: Int, val end: Int, val style: RichStyle)
/** A hyperlink over the half-open range [[start], [end]) pointing at [url]. */
data class RichLink(val start: Int, val end: Int, val url: String)
/** Paragraph alignment (START is the writing-direction default). */
enum class RichAlign { START, CENTER, END }
/**
* The compose editor's internal rich-text model: plain [text] plus inline [spans] and [links].
* Paragraph alignment over the half-open range [[start], [end]) of the plain text. Ranges cover
* whole lines (including any block marker), and adjacent same-aligned lines canonically share one
* range — [RichTextHtml.fromHtml] always returns that merged form.
*/
data class RichAlignment(val start: Int, val end: Int, val align: RichAlign)
/**
* An inline image attached by Content-ID. [[start], [end]) covers a visible [imageToken] in the
* plain text (`[image: name]`), which keeps the text/plain rendering readable; the HTML form
* replaces the token with `<img src="cid:contentId" alt="name">`.
*/
data class RichImage(val start: Int, val end: Int, val contentId: String, val name: String)
/** A message-wide default font family and/or size, serialized as one outer `<div style>` wrapper. */
data class RichBaseStyle(val fontCss: String? = null, val fontSizePt: Int? = null)
/** The visible plain-text placeholder for an inline image named [name]. */
fun imageToken(name: String): String = "[image: $name]"
/**
* The compose editor's internal rich-text model: plain [text] plus inline [spans], [links],
* paragraph [alignments], inline [images], and an optional message-wide [baseStyle].
*
* Block structure (unordered/ordered lists and block quotes) is encoded as recognizable line
* prefixes inside [text] — "• " for bullets, "N. " for numbered items, and "> " for quotes — so
@@ -22,16 +71,24 @@ data class RichTextContent(
val text: String = "",
val spans: List<RichSpan> = emptyList(),
val links: List<RichLink> = emptyList(),
val alignments: List<RichAlignment> = emptyList(),
val images: List<RichImage> = emptyList(),
val baseStyle: RichBaseStyle? = null,
) {
val isBlank: Boolean get() = text.isBlank()
/**
* True when the content carries anything a plaintext field could not represent: inline styling,
* a link, or a block marker. When false, callers should send/persist plaintext only so an
* unformatted message stays byte-for-byte identical to the old plaintext-only path.
* a link, a block marker, paragraph alignment, an inline image, or a base style. When false,
* callers should send/persist plaintext only so an unformatted message stays byte-for-byte
* identical to the old plaintext-only path.
*/
fun hasFormatting(): Boolean =
spans.isNotEmpty() || links.isNotEmpty() || text.lineSequence().any { lineMarker(it) != null }
fun hasFormatting(): Boolean = spans.isNotEmpty() ||
links.isNotEmpty() ||
alignments.isNotEmpty() ||
images.isNotEmpty() ||
baseStyle != null ||
text.lineSequence().any { lineMarker(it) != null }
}
/** Recognized block markers and the tags they map to. */
@@ -39,297 +96,9 @@ internal const val BULLET_PREFIX = "• "
internal const val QUOTE_PREFIX = "> "
private val ORDERED_PREFIX = Regex("^\\d+\\. ")
private enum class Kind { PARAGRAPH, BULLET, ORDERED, QUOTE }
private data class Line(val kind: Kind, val contentStart: Int, val contentEnd: Int)
/** The block marker prefixing [line], or null for an ordinary paragraph line. */
internal fun lineMarker(line: String): String? = when {
line.startsWith(BULLET_PREFIX) -> BULLET_PREFIX
line.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX
else -> ORDERED_PREFIX.find(line)?.value
}
/**
* Serializes [RichTextContent] to a small, email-safe HTML subset and back. Pure (no Android or
* Compose types), so the whole conversion is unit-testable on the JVM.
*
* The emitted subset — `<b> <i> <u> <a> <ul>/<ol>/<li> <blockquote> <br>` — is deliberately narrow
* so [fromHtml] is a faithful inverse for anything [toHtml] produces (drafts round-trip losslessly).
*/
object RichTextHtml {
fun toHtml(content: RichTextContent): String {
if (content.text.isEmpty()) return ""
val lines = classify(content.text)
val sb = StringBuilder()
var i = 0
while (i < lines.size) {
i = when (lines[i].kind) {
Kind.BULLET -> emitList(sb, content, lines, i, Kind.BULLET, "ul")
Kind.ORDERED -> emitList(sb, content, lines, i, Kind.ORDERED, "ol")
Kind.QUOTE -> emitQuote(sb, content, lines, i)
Kind.PARAGRAPH -> emitParagraph(sb, content, lines, i)
}
}
return sb.toString()
}
/** A readable plaintext rendering — the model's [RichTextContent.text] already carries markers. */
fun toPlainText(content: RichTextContent): String = content.text
fun fromHtml(html: String): RichTextContent = HtmlToRichParser(html).parse()
}
private fun classify(text: String): List<Line> {
val lines = ArrayList<Line>()
var start = 0
while (true) {
val nl = text.indexOf('\n', start)
val end = if (nl == -1) text.length else nl
val marker = lineMarker(text.substring(start, end))
val kind = when (marker) {
BULLET_PREFIX -> Kind.BULLET
QUOTE_PREFIX -> Kind.QUOTE
null -> Kind.PARAGRAPH
else -> Kind.ORDERED
}
lines.add(Line(kind, start + (marker?.length ?: 0), end))
if (nl == -1) break
start = nl + 1
}
return lines
}
private fun emitList(
sb: StringBuilder,
content: RichTextContent,
lines: List<Line>,
from: Int,
kind: Kind,
tag: String,
): Int {
sb.append("<").append(tag).append(">")
var i = from
while (i < lines.size && lines[i].kind == kind) {
sb.append("<li>").append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)).append("</li>")
i++
}
sb.append("</").append(tag).append(">")
return i
}
private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
sb.append("<blockquote>")
var i = from
while (i < lines.size && lines[i].kind == Kind.QUOTE) {
if (i > from) sb.append("<br>")
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
i++
}
sb.append("</blockquote>")
return i
}
private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
sb.append("<p>")
var i = from
while (i < lines.size && lines[i].kind == Kind.PARAGRAPH) {
if (i > from) sb.append("<br>")
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
i++
}
sb.append("</p>")
return i
}
/** Renders the inline styling/links over [[from], [to]) as nested `<a>/<b>/<i>/<u>` tags. */
private fun renderInline(content: RichTextContent, from: Int, to: Int): String {
if (from >= to) return ""
val points = cutPoints(content, from, to)
val sb = StringBuilder()
for (idx in 0 until points.size - 1) {
val a = points[idx]
val b = points[idx + 1]
if (a < b) appendRun(sb, content, a, b)
}
return sb.toString()
}
/** The sorted set of offsets where a span or link starts/ends within [[from], [to]]. */
private fun cutPoints(content: RichTextContent, from: Int, to: Int): List<Int> {
val cuts = sortedSetOf(from, to)
fun add(start: Int, end: Int) {
if (end > from && start < to) {
cuts.add(start.coerceIn(from, to))
cuts.add(end.coerceIn(from, to))
}
}
content.spans.forEach { add(it.start, it.end) }
content.links.forEach { add(it.start, it.end) }
return cuts.toList()
}
/** Emits one constant-styling run [[a], [b]) with fully-closed tags, so nesting is always valid. */
private fun appendRun(sb: StringBuilder, content: RichTextContent, a: Int, b: Int) {
val styles = content.spans.filter { it.start <= a && b <= it.end }.map { it.style }.toSet()
val link = content.links.firstOrNull { it.start <= a && b <= it.end }
if (link != null) sb.append("<a href=\"").append(escapeAttr(link.url)).append("\">")
if (RichStyle.BOLD in styles) sb.append("<b>")
if (RichStyle.ITALIC in styles) sb.append("<i>")
if (RichStyle.UNDERLINE in styles) sb.append("<u>")
sb.append(escape(content.text.substring(a, b)))
if (RichStyle.UNDERLINE in styles) sb.append("</u>")
if (RichStyle.ITALIC in styles) sb.append("</i>")
if (RichStyle.BOLD in styles) sb.append("</b>")
if (link != null) sb.append("</a>")
}
/**
* Parses the narrow HTML subset [toHtml][RichTextHtml.toHtml] emits (plus `strong`/`em` and
* pretty-printer whitespace) back into a [RichTextContent]. A small state machine keeps the nesting
* shallow: [handleTag] dispatches to one-liner helpers and [handleText] appends decoded text.
*/
private class HtmlToRichParser(private val html: String) {
private val text = StringBuilder()
private val spans = ArrayList<RichSpan>()
private val links = ArrayList<RichLink>()
private var boldStart = -1
private var italicStart = -1
private var underlineStart = -1
private var linkStart = -1
private var linkUrl = ""
private var listType: Char? = null
private var olCount = 0
private var inQuote = false
fun parse(): RichTextContent {
var i = 0
while (i < html.length) {
if (html[i] == '<') {
val gt = html.indexOf('>', i)
if (gt == -1) break
handleTag(html.substring(i + 1, gt).trim())
i = gt + 1
} else {
val lt = html.indexOf('<', i)
val end = if (lt == -1) html.length else lt
handleText(html.substring(i, end))
i = end
}
}
return finish()
}
private fun atLineStart() = text.isEmpty() || text.last() == '\n'
private fun newlineIfNeeded() {
if (!atLineStart()) text.append('\n')
}
private fun handleTag(raw: String) {
val closing = raw.startsWith("/")
val body = raw.removePrefix("/").trim()
when (body.substringBefore(' ').substringBefore('/').lowercase()) {
"br" -> {
text.append('\n')
if (inQuote) text.append(QUOTE_PREFIX)
}
"b", "strong" -> boldStart = toggle(closing, boldStart, RichStyle.BOLD)
"i", "em" -> italicStart = toggle(closing, italicStart, RichStyle.ITALIC)
"u" -> underlineStart = toggle(closing, underlineStart, RichStyle.UNDERLINE)
"a" -> handleAnchor(closing, body)
"ul" -> handleList(closing, 'u')
"ol" -> handleList(closing, 'o')
"li" -> if (!closing) startListItem()
"blockquote" -> handleQuote(closing)
"p", "div" -> newlineIfNeeded()
else -> Unit
}
}
/** Opens a style (returns the current offset) or closes it (records the span, returns -1). */
private fun toggle(closing: Boolean, openOffset: Int, style: RichStyle): Int {
if (!closing) return text.length
if (openOffset >= 0) spans.add(RichSpan(openOffset, text.length, style))
return -1
}
private fun handleAnchor(closing: Boolean, body: String) {
if (closing) {
if (linkStart >= 0) {
links.add(RichLink(linkStart, text.length, linkUrl))
linkStart = -1
linkUrl = ""
}
} else {
linkStart = text.length
linkUrl = extractHref(body)
}
}
private fun handleList(closing: Boolean, type: Char) {
if (closing) {
listType = null
} else {
listType = type
if (type == 'o') olCount = 0
}
newlineIfNeeded()
}
private fun startListItem() {
newlineIfNeeded()
if (listType == 'o') {
olCount++
text.append("$olCount. ")
} else {
text.append(BULLET_PREFIX)
}
}
private fun handleQuote(closing: Boolean) {
newlineIfNeeded()
inQuote = !closing
if (!closing) text.append(QUOTE_PREFIX)
}
private fun handleText(chunk: String) {
// Drop the insignificant whitespace a pretty-printer leaves between block tags (blank runs at
// a line start, or any blank run with a newline), but keep a real space between inline runs.
if (!(chunk.isBlank() && (atLineStart() || chunk.contains('\n')))) text.append(unescape(chunk))
}
private fun finish(): RichTextContent {
val out = text.toString().trimEnd('\n')
val len = out.length
if (boldStart in 0 until len) spans.add(RichSpan(boldStart, len, RichStyle.BOLD))
if (italicStart in 0 until len) spans.add(RichSpan(italicStart, len, RichStyle.ITALIC))
if (underlineStart in 0 until len) spans.add(RichSpan(underlineStart, len, RichStyle.UNDERLINE))
if (linkStart in 0 until len) links.add(RichLink(linkStart, len, linkUrl))
return RichTextContent(
text = out,
spans = spans.filter { it.end <= len && it.start < it.end },
links = links.filter { it.end <= len && it.start < it.end },
)
}
}
private fun extractHref(tagBody: String): String {
val match = Regex("href\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')", RegexOption.IGNORE_CASE).find(tagBody) ?: return ""
val (doubleQuoted, singleQuoted) = match.destructured
return unescape(doubleQuoted.ifEmpty { singleQuoted })
}
private fun escape(s: String): String = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
private fun escapeAttr(s: String): String = escape(s).replace("\"", "&quot;")
private fun unescape(s: String): String = s
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&nbsp;", " ")
.replace("&amp;", "&")
@@ -15,17 +15,22 @@ data class EditResult(val content: RichTextContent, val selectionStart: Int, val
*/
object RichTextEditing {
/** Adds [style] over [[start], [end]) if it is not already fully styled, otherwise removes it. */
/**
* Toggles [style] over [[start], [end]): if the exact style already covers the whole range it is
* removed; otherwise it is applied, replacing any other value of the same kind over the range
* (e.g. picking a new [RichStyle.FontSize] replaces the old size instead of stacking a second one).
*/
fun toggleStyle(content: RichTextContent, start: Int, end: Int, style: RichStyle): RichTextContent {
if (start >= end) return content
val others = content.spans.filter { it.style != style }
val same = content.spans.filter { it.style == style }
val updated = if (isFullyStyled(same, start, end)) {
subtractRange(same, start, end)
val otherKinds = content.spans.filter { it.style::class != style::class }
val sameKind = content.spans.filter { it.style::class == style::class }
val cleared = subtractRange(sameKind, start, end)
val updated = if (isFullyStyled(sameKind.filter { it.style == style }, start, end)) {
cleared
} else {
mergeSameStyle(same + RichSpan(start, end, style))
mergeSameValue(cleared + RichSpan(start, end, style))
}
return content.copy(spans = (others + updated).sortedBy { it.start })
return content.copy(spans = (otherKinds + updated).sortedBy { it.start })
}
/** Links [[start], [end]) to [url], replacing any links that overlap the range. */
@@ -45,6 +50,27 @@ object RichTextEditing {
fun isStyled(content: RichTextContent, start: Int, end: Int, style: RichStyle): Boolean =
start < end && isFullyStyled(content.spans.filter { it.style == style }, start, end)
/**
* The single value of style kind [T] over the selection, or null when absent or mixed — one call
* gives a picker its "current value". For a caret the boundaries count as inside, so the query
* matches the run the user is typing at the end of; at a boundary between two runs the earlier
* run wins.
*/
inline fun <reified T : RichStyle> styleAt(content: RichTextContent, start: Int, end: Int): T? =
styleAt(content, start, end, T::class.java)
/** Non-reified form of [styleAt] for callers that carry the kind as a [Class]. */
fun <T : RichStyle> styleAt(content: RichTextContent, start: Int, end: Int, kind: Class<T>): T? {
val candidates = content.spans.filter { kind.isInstance(it.style) }
val value = if (start >= end) {
candidates.firstOrNull { it.start <= start && start <= it.end }?.style
} else {
candidates.map { it.style }.distinct()
.firstOrNull { v -> isFullyStyled(candidates.filter { it.style == v }, start, end) }
}
return kind.cast(value)
}
/** Whether every line the selection touches carries [marker]. */
fun hasBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): Boolean {
val lineStarts = lineStartsTouching(content.text, start, end)
@@ -54,7 +80,7 @@ object RichTextEditing {
/**
* Toggles [marker] across every line the selection touches: if all those lines already carry it,
* it is removed; otherwise it is applied (replacing any other block marker already there). Spans,
* links and the selection are shifted to track the inserted/removed prefixes.
* links, alignments, images and the selection are shifted to track the inserted/removed prefixes.
*/
fun toggleBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): EditResult {
val text = content.text
@@ -71,9 +97,14 @@ object RichTextEditing {
}
}
val (newText, remap) = applyEdits(text, edits)
val newSpans = content.spans.mapNotNull { remapSpan(it, remap) }
val newLinks = content.links.mapNotNull { remapLink(it, remap) }
return EditResult(RichTextContent(newText, newSpans, newLinks), remap(start), remap(end))
val updated = content.copy(
text = newText,
spans = content.spans.mapNotNull { remapSpan(it, remap) },
links = content.links.mapNotNull { remapLink(it, remap) },
alignments = content.alignments.mapNotNull { remapAlignment(it, remap) },
images = content.images.mapNotNull { remapImage(it, remap) },
)
return EditResult(updated, remap(start), remap(end))
}
}
@@ -95,6 +126,18 @@ private fun remapLink(link: RichLink, remap: (Int) -> Int): RichLink? {
return if (s < e) RichLink(s, e, link.url) else null
}
private fun remapAlignment(alignment: RichAlignment, remap: (Int) -> Int): RichAlignment? {
val s = remap(alignment.start)
val e = remap(alignment.end)
return if (s < e) alignment.copy(start = s, end = e) else null
}
private fun remapImage(image: RichImage, remap: (Int) -> Int): RichImage? {
val s = remap(image.start)
val e = remap(image.end)
return if (s < e) image.copy(start = s, end = e) else null
}
// --- inline style helpers ---
private fun isFullyStyled(spans: List<RichSpan>, start: Int, end: Int): Boolean {
@@ -117,12 +160,13 @@ private fun subtractRange(spans: List<RichSpan>, start: Int, end: Int): List<Ric
}
}
private fun mergeSameStyle(spans: List<RichSpan>): List<RichSpan> {
/** 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.sortedBy { it.start }) {
val last = merged.lastOrNull()
if (last != null && span.start <= last.end) {
merged[merged.size - 1] = last.copy(end = maxOf(last.end, span.end))
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)
}
@@ -0,0 +1,212 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.richtext
/**
* Serializes [RichTextContent] to a small, email-safe HTML subset and back. Pure (no Android or
* Compose types), so the whole conversion is unit-testable on the JVM.
*
* The emitted subset — `<b> <i> <u> <s> <a> <span style> <img> <ul>/<ol>/<li> <blockquote> <br>`,
* `text-align` on `<p>`/`<li>`, and a single outer `<div style>` for [RichBaseStyle] — is
* deliberately narrow so [fromHtml] is a faithful inverse for anything [toHtml] produces (drafts
* round-trip losslessly). Two canonical-form caveats: [fromHtml] returns maximally-merged spans and
* alignment runs, and an inline span crossing a line break re-parses as one span per line.
*/
object RichTextHtml {
fun toHtml(content: RichTextContent): String {
val body = bodyHtml(content)
val base = content.baseStyle ?: return body
return "<div style=\"" + escapeAttr(baseCss(base)) + "\">" + body + "</div>"
}
/** A readable plaintext rendering — the model's [RichTextContent.text] already carries markers. */
fun toPlainText(content: RichTextContent): String = content.text
fun fromHtml(html: String): RichTextContent = RichTextHtmlParser(html).parse()
}
/** The `font-family`/`font-size` declarations of the base-style wrapper (possibly empty). */
private fun baseCss(base: RichBaseStyle): String = listOfNotNull(
base.fontCss?.let { "font-family:$it" },
base.fontSizePt?.let { "font-size:${it}pt" },
).joinToString(";")
private fun bodyHtml(content: RichTextContent): String {
if (content.text.isEmpty()) return ""
val lines = classify(content.text)
val sb = StringBuilder()
var i = 0
while (i < lines.size) {
i = when (lines[i].kind) {
Kind.BULLET -> emitList(sb, content, lines, i, Kind.BULLET, "ul")
Kind.ORDERED -> emitList(sb, content, lines, i, Kind.ORDERED, "ol")
Kind.QUOTE -> emitQuote(sb, content, lines, i)
Kind.PARAGRAPH -> emitParagraph(sb, content, lines, i)
}
}
return sb.toString()
}
private enum class Kind { PARAGRAPH, BULLET, ORDERED, QUOTE }
/** One line of the plain text: [start] is the raw line start, content excludes the block marker. */
private data class Line(val kind: Kind, val start: Int, val contentStart: Int, val contentEnd: Int)
private fun classify(text: String): List<Line> {
val lines = ArrayList<Line>()
var start = 0
while (true) {
val nl = text.indexOf('\n', start)
val end = if (nl == -1) text.length else nl
val marker = lineMarker(text.substring(start, end))
val kind = when (marker) {
BULLET_PREFIX -> Kind.BULLET
QUOTE_PREFIX -> Kind.QUOTE
null -> Kind.PARAGRAPH
else -> Kind.ORDERED
}
lines.add(Line(kind, start, start + (marker?.length ?: 0), end))
if (nl == -1) break
start = nl + 1
}
return lines
}
/** The alignment applying to [line]: the first alignment run overlapping the line's range. */
private fun alignFor(content: RichTextContent, line: Line): RichAlign? =
content.alignments.firstOrNull { it.start < line.contentEnd && it.end > line.start }?.align
/** Fixed left/center/right keeps the output readable in legacy email clients. */
private fun cssAlign(align: RichAlign): String = when (align) {
RichAlign.START -> "left"
RichAlign.CENTER -> "center"
RichAlign.END -> "right"
}
private fun openBlockTag(tag: String, align: RichAlign?): String =
if (align == null) "<$tag>" else "<$tag style=\"text-align:${cssAlign(align)}\">"
private fun emitList(
sb: StringBuilder,
content: RichTextContent,
lines: List<Line>,
from: Int,
kind: Kind,
tag: String,
): Int {
sb.append("<").append(tag).append(">")
var i = from
while (i < lines.size && lines[i].kind == kind) {
sb.append(openBlockTag("li", alignFor(content, lines[i])))
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
sb.append("</li>")
i++
}
sb.append("</").append(tag).append(">")
return i
}
private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
sb.append("<blockquote>")
var i = from
while (i < lines.size && lines[i].kind == Kind.QUOTE) {
if (i > from) sb.append("<br>")
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
i++
}
sb.append("</blockquote>")
return i
}
/** Merges consecutive plain lines into one `<p>`, splitting where the alignment changes. */
private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
val align = alignFor(content, lines[from])
sb.append(openBlockTag("p", align))
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))
i++
}
sb.append("</p>")
return i
}
/** Renders the inline styling/links/images over [[from], [to]) as nested, fully-closed tags. */
private fun renderInline(content: RichTextContent, from: Int, to: Int): String {
if (from >= to) return ""
val points = cutPoints(content, from, to)
val sb = StringBuilder()
for (idx in 0 until points.size - 1) {
val a = points[idx]
val b = points[idx + 1]
if (a < b) appendRun(sb, content, a, b)
}
return sb.toString()
}
/** The sorted set of offsets where a span, link, or image starts/ends within [[from], [to]]. */
private fun cutPoints(content: RichTextContent, from: Int, to: Int): List<Int> {
val cuts = sortedSetOf(from, to)
fun add(start: Int, end: Int) {
if (end > from && start < to) {
cuts.add(start.coerceIn(from, to))
cuts.add(end.coerceIn(from, to))
}
}
content.spans.forEach { add(it.start, it.end) }
content.links.forEach { add(it.start, it.end) }
content.images.forEach { add(it.start, it.end) }
return cuts.toList()
}
/** The simple toggle styles and their HTML tags, in emission (outermost-first) order. */
private val SIMPLE_TAGS = listOf<Pair<RichStyle, String>>(
RichStyle.Bold to "b",
RichStyle.Italic to "i",
RichStyle.Underline to "u",
RichStyle.Strikethrough to "s",
)
/** Emits one constant-styling run [[a], [b]) with fully-closed tags, so nesting is always valid. */
private fun appendRun(sb: StringBuilder, content: RichTextContent, a: Int, b: Int) {
val image = content.images.firstOrNull { it.start <= a && a < it.end }
if (image != null && a != image.start) return // interior cut of an image token; emitted at its start
val styles = content.spans.filter { it.start <= a && b <= it.end }.map { it.style }
val link = content.links.firstOrNull { it.start <= a && b <= it.end }
val tags = buildList {
if (link != null) add("a href=\"${escapeAttr(link.url)}\"" to "a")
inlineCss(styles).takeIf { it.isNotEmpty() }?.let { add("span style=\"${escapeAttr(it)}\"" to "span") }
SIMPLE_TAGS.forEach { (style, tag) -> if (style in styles) add(tag to tag) }
}
tags.forEach { (open, _) -> sb.append('<').append(open).append('>') }
if (image != null) {
sb.append("<img src=\"cid:").append(escapeAttr(image.contentId))
.append("\" alt=\"").append(escapeAttr(image.name)).append("\">")
} else {
sb.append(escape(content.text.substring(a, b)))
}
tags.asReversed().forEach { (_, close) -> sb.append("</").append(close).append('>') }
}
/** Merges the parameterized styles active on a run into one CSS declaration list (maybe empty). */
private fun inlineCss(styles: List<RichStyle>): String {
val parts = ArrayList<String>()
styles.firstNotNullOfOrNull { it as? RichStyle.FontFamily }?.let { parts.add("font-family:${it.css}") }
styles.firstNotNullOfOrNull { it as? RichStyle.FontSize }?.let { parts.add("font-size:${it.pt}pt") }
styles.firstNotNullOfOrNull { it as? RichStyle.FontColor }?.let { parts.add("color:${cssColor(it.argb)}") }
styles.firstNotNullOfOrNull { it as? RichStyle.Highlight }
?.let { parts.add("background-color:${cssColor(it.argb)}") }
return parts.joinToString(";")
}
private const val RGB_MASK = 0xFFFFFF
private const val RGB_HEX_DIGITS = 6
private const val HEX_RADIX = 16
/** `#rrggbb` for the low 24 bits of [argb] (alpha is not representable in email-safe CSS). */
internal fun cssColor(argb: Int): String = "#" + (argb and RGB_MASK).toString(HEX_RADIX).padStart(RGB_HEX_DIGITS, '0')
internal fun escape(s: String): String = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
internal fun escapeAttr(s: String): String = escape(s).replace("\"", "&quot;")
@@ -0,0 +1,387 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.richtext
import kotlin.math.roundToInt
/**
* Parses the narrow HTML subset [RichTextHtml.toHtml] emits (plus tolerated variants: `strong`/`em`,
* `del`/`strike`, `px` font sizes, `#rgb` colors, `start`/`end` alignment, and pretty-printer
* whitespace) back into a [RichTextContent]. Unknown tags and unknown CSS properties are ignored
* without dropping the text they wrap. A small state machine keeps the nesting shallow:
* [handleTag] dispatches to one-liner helpers and [handleText] appends decoded text; open
* style-bearing tags live on a stack so spans of any kind can nest and interleave.
*/
internal class RichTextHtmlParser(private val html: String) {
private val text = StringBuilder()
private val spans = ArrayList<RichSpan>()
private val links = ArrayList<RichLink>()
private val alignments = ArrayList<RichAlignment>()
private val images = ArrayList<RichImage>()
private val openStyles = ArrayDeque<OpenStyles>()
private var baseStyle: RichBaseStyle? = null
private var linkStart = -1
private var linkUrl = ""
private var listType: Char? = null
private var olCount = 0
private var inQuote = false
private var blockStart = -1
private var blockAlign: RichAlign? = null
/** One still-open style-bearing tag: the [channel] whose closing tag ends it, and its styles. */
private class OpenStyles(val channel: String, val start: Int, val styles: List<RichStyle>)
fun parse(): RichTextContent {
var i = 0
while (i < html.length) {
if (html[i] == '<') {
val gt = html.indexOf('>', i)
if (gt == -1) break
handleTag(html.substring(i + 1, gt).trim())
i = gt + 1
} else {
val lt = html.indexOf('<', i)
val end = if (lt == -1) html.length else lt
handleText(html.substring(i, end))
i = end
}
}
return finish()
}
private fun atLineStart() = text.isEmpty() || text.last() == '\n'
private fun newlineIfNeeded() {
if (!atLineStart()) text.append('\n')
}
private fun handleTag(raw: String) {
val closing = raw.startsWith("/")
val body = raw.removePrefix("/").trim()
val name = body.substringBefore(' ').substringBefore('/').lowercase()
if (!handleInlineTag(name, closing, body)) handleBlockTag(name, closing, body)
}
/** Dispatches character-level tags; returns false when [name] is not an inline tag. */
private fun handleInlineTag(name: String, closing: Boolean, body: String): Boolean {
when (name) {
"b", "strong" -> toggleStyles(closing, "b", listOf(RichStyle.Bold))
"i", "em" -> toggleStyles(closing, "i", listOf(RichStyle.Italic))
"u" -> toggleStyles(closing, "u", listOf(RichStyle.Underline))
"s", "del", "strike" -> toggleStyles(closing, "s", listOf(RichStyle.Strikethrough))
"span" -> toggleStyles(closing, "span", spanStyles(closing, body))
"a" -> handleAnchor(closing, body)
"img" -> if (!closing) handleImage(body)
else -> return false
}
return true
}
private fun handleBlockTag(name: String, closing: Boolean, body: String) {
when (name) {
"br" -> {
text.append('\n')
if (inQuote) text.append(QUOTE_PREFIX)
}
"ul" -> handleList(closing, 'u')
"ol" -> handleList(closing, 'o')
"li" -> handleListItem(closing, body)
"blockquote" -> handleQuote(closing)
"p" -> handleParagraph(closing, body)
"div" -> handleDiv(closing, body)
else -> Unit
}
}
private fun spanStyles(closing: Boolean, body: String): List<RichStyle> =
if (closing) emptyList() else parseInlineStyles(extractStyleAttr(body))
/** Opens [styles] on [channel], or on a closing tag records spans for the matching open tag. */
private fun toggleStyles(closing: Boolean, channel: String, styles: List<RichStyle>) {
if (!closing) {
openStyles.addLast(OpenStyles(channel, text.length, styles))
return
}
val idx = openStyles.indexOfLast { it.channel == channel }
if (idx >= 0) recordSpans(openStyles.removeAt(idx), text.length)
}
private fun recordSpans(open: OpenStyles, end: Int) {
if (open.start >= end) return
open.styles.forEach { style -> spans.add(RichSpan(open.start, end, style)) }
}
private fun handleAnchor(closing: Boolean, body: String) {
if (closing) {
if (linkStart >= 0) {
links.add(RichLink(linkStart, text.length, linkUrl))
linkStart = -1
linkUrl = ""
}
} else {
linkStart = text.length
linkUrl = extractHref(body)
}
}
/** `<img src="cid:…" alt="name">` becomes a visible [imageToken] backed by a [RichImage]. */
private fun handleImage(body: String) {
val src = extractAttr(body, SRC_ATTR) ?: return
if (!src.startsWith(CID_PREFIX)) return
val name = extractAttr(body, ALT_ATTR).orEmpty()
val start = text.length
text.append(imageToken(name))
images.add(RichImage(start, text.length, src.removePrefix(CID_PREFIX), name))
}
private fun handleList(closing: Boolean, type: Char) {
if (closing) {
listType = null
} else {
listType = type
if (type == 'o') olCount = 0
}
newlineIfNeeded()
}
private fun handleListItem(closing: Boolean, body: String) {
if (closing) {
endAlignedBlock()
return
}
newlineIfNeeded()
// The alignment run starts at the raw line start, so it covers the block marker too.
startAlignedBlock(parseTextAlign(extractStyleAttr(body)))
if (listType == 'o') {
olCount++
text.append("$olCount. ")
} else {
text.append(BULLET_PREFIX)
}
}
private fun handleQuote(closing: Boolean) {
newlineIfNeeded()
inQuote = !closing
if (!closing) text.append(QUOTE_PREFIX)
}
private fun handleParagraph(closing: Boolean, body: String) {
if (closing) {
endAlignedBlock()
newlineIfNeeded()
} else {
newlineIfNeeded()
startAlignedBlock(parseTextAlign(extractStyleAttr(body)))
}
}
/**
* A leading `<div style>` before any content is the base-style wrapper [RichTextHtml.toHtml]
* emits — it must not add a newline (and its closing tag lands right after the last block's
* newline, where [newlineIfNeeded] is a no-op, so the wrapper never adds stray text).
*/
private fun handleDiv(closing: Boolean, body: String) {
val wrapperCss = if (!closing && text.isEmpty() && baseStyle == null) extractStyleAttr(body) else null
if (wrapperCss != null) {
baseStyle = parseBaseStyle(wrapperCss)
} else {
newlineIfNeeded()
}
}
private fun startAlignedBlock(align: RichAlign?) {
endAlignedBlock()
blockStart = text.length
blockAlign = align
}
private fun endAlignedBlock() {
val align = blockAlign
if (align != null && blockStart >= 0 && blockStart < text.length) {
alignments.add(RichAlignment(blockStart, text.length, align))
}
blockStart = -1
blockAlign = null
}
private fun handleText(chunk: String) {
// Drop the insignificant whitespace a pretty-printer leaves between block tags (blank runs at
// a line start, or any blank run with a newline), but keep a real space between inline runs.
if (!(chunk.isBlank() && (atLineStart() || chunk.contains('\n')))) text.append(unescape(chunk))
}
private fun finish(): RichTextContent {
endAlignedBlock()
while (openStyles.isNotEmpty()) recordSpans(openStyles.removeLast(), text.length)
if (linkStart >= 0) links.add(RichLink(linkStart, text.length, linkUrl))
val out = text.toString().trimEnd('\n')
val len = out.length
return RichTextContent(
text = out,
spans = mergedSpans(spans.mapNotNull { it.clampedTo(len) }),
links = links.mapNotNull { it.clampedTo(len) },
alignments = mergedAlignments(alignments.mapNotNull { it.clampedTo(len) }),
images = images.filter { it.end <= len },
baseStyle = baseStyle,
)
}
}
private fun RichSpan.clampedTo(len: Int): RichSpan? {
val e = minOf(end, len)
return if (start < e) copy(end = e) else null
}
private fun RichLink.clampedTo(len: Int): RichLink? {
val e = minOf(end, len)
return if (start < e) copy(end = e) else null
}
private fun RichAlignment.clampedTo(len: Int): RichAlignment? {
val e = minOf(end, len)
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>()
for (alignment in alignments.sortedBy { it.start }) {
val last = merged.lastOrNull()
if (last != null && last.align == alignment.align && alignment.start <= last.end + 1) {
merged[merged.size - 1] = last.copy(end = maxOf(last.end, alignment.end))
} else {
merged.add(alignment)
}
}
return merged
}
// --- tag-attribute and CSS helpers ---
private const val CID_PREFIX = "cid:"
private fun attrRegex(name: String) =
Regex("(?:^|\\s)$name\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')", RegexOption.IGNORE_CASE)
private val HREF_ATTR = attrRegex("href")
private val STYLE_ATTR = attrRegex("style")
private val SRC_ATTR = attrRegex("src")
private val ALT_ATTR = attrRegex("alt")
private fun extractAttr(tagBody: String, attr: Regex): String? {
val match = attr.find(tagBody) ?: return null
val (doubleQuoted, singleQuoted) = match.destructured
return unescape(doubleQuoted.ifEmpty { singleQuoted })
}
internal fun extractHref(tagBody: String): String = extractAttr(tagBody, HREF_ATTR) ?: ""
/** The tag's `style` attribute value, or null when absent (an empty attribute yields ""). */
internal fun extractStyleAttr(tagBody: String): String? = extractAttr(tagBody, STYLE_ATTR)
private inline fun forEachCssDeclaration(css: String, action: (prop: String, value: String) -> Unit) {
css.split(';').forEach { declaration ->
val prop = declaration.substringBefore(':').trim().lowercase()
val value = declaration.substringAfter(':', "").trim()
if (prop.isNotEmpty() && value.isNotEmpty()) action(prop, value)
}
}
/** Maps recognized inline CSS declarations to [RichStyle]s; unknown properties are skipped. */
internal fun parseInlineStyles(css: String?): List<RichStyle> {
if (css == null) return emptyList()
val styles = ArrayList<RichStyle>()
forEachCssDeclaration(css) { prop, value -> styleForCss(prop, value)?.let(styles::add) }
return styles
}
private fun styleForCss(prop: String, value: String): RichStyle? = when (prop) {
"font-family" -> RichStyle.FontFamily(value)
"font-size" -> parseFontSizePt(value)?.let { RichStyle.FontSize(it) }
"color" -> parseCssColor(value)?.let { RichStyle.FontColor(it) }
"background-color" -> parseCssColor(value)?.let { RichStyle.Highlight(it) }
else -> null
}
/** Reads the base-style wrapper's declarations; unknown properties are ignored. */
internal fun parseBaseStyle(css: String): RichBaseStyle {
var fontCss: String? = null
var fontSizePt: Int? = null
forEachCssDeclaration(css) { prop, value ->
when (prop) {
"font-family" -> fontCss = value
"font-size" -> fontSizePt = parseFontSizePt(value)
}
}
return RichBaseStyle(fontCss, fontSizePt)
}
private val FONT_SIZE = Regex("^(\\d+(?:\\.\\d+)?)\\s*(pt|px)$", RegexOption.IGNORE_CASE)
private const val PT_PER_PX = 3.0 / 4.0
/** A CSS font size in points; `px` values convert at 3/4 pt per px, rounded to the nearest int. */
internal fun parseFontSizePt(value: String): Int? {
val match = FONT_SIZE.find(value) ?: return null
val (number, unit) = match.destructured
val size = number.toDoubleOrNull() ?: return null
val pt = if (unit.equals("px", ignoreCase = true)) size * PT_PER_PX else size
return pt.roundToInt().takeIf { it > 0 }
}
private const val OPAQUE_ALPHA = 0xFF000000.toInt()
private const val SHORT_HEX_LEN = 3
private const val LONG_HEX_LEN = 6
private const val COLOR_RADIX = 16
/** `#rgb` or `#rrggbb` as an opaque ARGB int, or null for anything else. */
internal fun parseCssColor(value: String): Int? {
if (!value.startsWith("#")) return null
val hex = value.drop(1)
val expanded = when (hex.length) {
SHORT_HEX_LEN -> buildString { hex.forEach { append(it).append(it) } }
LONG_HEX_LEN -> hex
else -> return null
}
val rgb = expanded.toIntOrNull(COLOR_RADIX) ?: return null
return OPAQUE_ALPHA or rgb
}
/** The [RichAlign] from a style attribute's `text-align` declaration, or null. */
internal fun parseTextAlign(css: String?): RichAlign? {
if (css == null) return null
var align: RichAlign? = null
forEachCssDeclaration(css) { prop, value ->
if (prop == "text-align") align = richAlignFor(value.lowercase())
}
return align
}
private fun richAlignFor(value: String): RichAlign? = when (value) {
"left", "start" -> RichAlign.START
"center" -> RichAlign.CENTER
"right", "end" -> RichAlign.END
else -> null
}
internal fun unescape(s: String): String = s
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&nbsp;", " ")
.replace("&amp;", "&")
@@ -30,17 +30,26 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.libremail.R
import org.libremail.richtext.BlockMarker
import org.libremail.richtext.RichAlign
import org.libremail.richtext.RichAlignment
import org.libremail.richtext.RichBaseStyle
import org.libremail.richtext.RichImage
import org.libremail.richtext.RichLink
import org.libremail.richtext.RichSpan
import org.libremail.richtext.RichStyle
@@ -51,6 +60,16 @@ import org.libremail.richtext.RichTextHtml
/** String-annotation tag the editor uses to carry a span's link target inside the [AnnotatedString]. */
private const val URL_TAG = "libremail:url"
/**
* String-annotation tag carrying a parameterized style's identity (see [encodeStyle]), so e.g. a
* [RichStyle.FontColor] span can never be confused with the link-color paint or any other span that
* happens to share its visual [SpanStyle].
*/
internal const val STYLE_TAG = "libremail:style"
/** String-annotation tag carrying an inline image's content id and display name over its token. */
internal const val IMAGE_TAG = "libremail:image"
/**
* A rich-text body editor: a formatting toolbar (bold / italic / underline, bulleted + numbered
* lists, block quote, and link) above a rounded [OutlinedTextField]. It converts its
@@ -60,6 +79,9 @@ private const val URL_TAG = "libremail:url"
*
* The field is a normal Compose text field, so TalkBack, text selection, and large system fonts all
* work as usual; the toolbar buttons carry content descriptions and toggle state for accessibility.
*
* [resolveFont] maps a CSS font-family stack to a Compose [FontFamily] for display; the default
* resolves nothing, leaving the system font (the model still round-trips the CSS value untouched).
*/
@Composable
fun RichTextBodyField(
@@ -68,21 +90,28 @@ fun RichTextBodyField(
onBodyChange: (plain: String, html: String?) -> Unit,
label: String,
modifier: Modifier = Modifier,
resolveFont: (String) -> FontFamily? = { null },
) {
val linkColor = MaterialTheme.colorScheme.primary
var value by remember { mutableStateOf(seedValue(body, bodyHtml, linkColor)) }
val seed = remember { seedContent(body, bodyHtml) }
var value by remember { mutableStateOf(seed.toSeededValue(linkColor, resolveFont)) }
// The message-wide base style is position-independent, so it lives outside the AnnotatedString
// (which only carries positioned spans) and is recombined with the field's content on emit.
var baseStyle by remember { mutableStateOf(seed.baseStyle) }
// Tracks the (plain, html) we last pushed up, so an external change (draft load / signature swap)
// re-seeds the field but our own emissions do not fight the user's cursor.
var lastEmitted by remember { mutableStateOf(body to bodyHtml) }
if (body to bodyHtml != lastEmitted) {
value = seedValue(body, bodyHtml, linkColor)
val content = seedContent(body, bodyHtml)
value = content.toSeededValue(linkColor, resolveFont)
baseStyle = content.baseStyle
lastEmitted = body to bodyHtml
}
fun emit(newValue: TextFieldValue) {
value = newValue
val content = newValue.annotatedString.toRichContent()
val content = newValue.annotatedString.toRichContent(baseStyle)
val html = if (content.hasFormatting()) RichTextHtml.toHtml(content) else null
lastEmitted = content.text to html
onBodyChange(content.text, html)
@@ -93,8 +122,8 @@ fun RichTextBodyField(
Column(modifier) {
FormattingToolbar(
value = value,
onToggleStyle = { style -> emit(applyStyle(value, style, linkColor)) },
onToggleBlock = { marker -> emit(applyBlock(value, marker, linkColor)) },
onToggleStyle = { style -> emit(applyStyle(value, style, linkColor, resolveFont)) },
onToggleBlock = { marker -> emit(applyBlock(value, marker, linkColor, resolveFont)) },
onLink = { showLinkDialog = true },
)
OutlinedTextField(
@@ -102,6 +131,7 @@ fun RichTextBodyField(
onValueChange = ::emit,
label = { Text(label) },
shape = MaterialTheme.shapes.large,
textStyle = applyBaseStyle(LocalTextStyle.current, baseStyle, resolveFont),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = Modifier.fillMaxWidth().weight(1f),
)
@@ -113,7 +143,7 @@ fun RichTextBodyField(
enabled = hasSelection,
onDismiss = { showLinkDialog = false },
onConfirm = { url ->
emit(applyLink(value, url, linkColor))
emit(applyLink(value, url, linkColor, resolveFont))
showLinkDialog = false
},
)
@@ -141,23 +171,23 @@ private fun FormattingToolbar(
FormatButton(
label = "B",
description = stringResource(R.string.format_bold),
active = RichTextEditing.isStyled(content, start, end, RichStyle.BOLD),
active = RichTextEditing.isStyled(content, start, end, RichStyle.Bold),
fontWeight = FontWeight.Bold,
onClick = { onToggleStyle(RichStyle.BOLD) },
onClick = { onToggleStyle(RichStyle.Bold) },
)
FormatButton(
label = "I",
description = stringResource(R.string.format_italic),
active = RichTextEditing.isStyled(content, start, end, RichStyle.ITALIC),
active = RichTextEditing.isStyled(content, start, end, RichStyle.Italic),
fontStyle = FontStyle.Italic,
onClick = { onToggleStyle(RichStyle.ITALIC) },
onClick = { onToggleStyle(RichStyle.Italic) },
)
FormatButton(
label = "U",
description = stringResource(R.string.format_underline),
active = RichTextEditing.isStyled(content, start, end, RichStyle.UNDERLINE),
active = RichTextEditing.isStyled(content, start, end, RichStyle.Underline),
underline = true,
onClick = { onToggleStyle(RichStyle.UNDERLINE) },
onClick = { onToggleStyle(RichStyle.Underline) },
)
FormatButton(
label = "•",
@@ -250,17 +280,28 @@ private fun LinkDialog(enabled: Boolean, onDismiss: () -> Unit, onConfirm: (Stri
// --- editor-op plumbing (TextFieldValue <-> RichTextContent) ---
private fun applyStyle(value: TextFieldValue, style: RichStyle, linkColor: Color): TextFieldValue {
/** Toggles [style] over the selection and rebuilds the field value (one-liner for toolbar wiring). */
internal fun applyStyle(
value: TextFieldValue,
style: RichStyle,
linkColor: Color,
resolveFont: (String) -> FontFamily? = { null },
): TextFieldValue {
val updated = RichTextEditing.toggleStyle(
value.annotatedString.toRichContent(),
value.selection.min,
value.selection.max,
style,
)
return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection)
return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection)
}
private fun applyBlock(value: TextFieldValue, marker: BlockMarker, linkColor: Color): TextFieldValue {
private fun applyBlock(
value: TextFieldValue,
marker: BlockMarker,
linkColor: Color,
resolveFont: (String) -> FontFamily? = { null },
): TextFieldValue {
val result = RichTextEditing.toggleBlock(
value.annotatedString.toRichContent(),
value.selection.min,
@@ -268,55 +309,172 @@ private fun applyBlock(value: TextFieldValue, marker: BlockMarker, linkColor: Co
marker,
)
return TextFieldValue(
result.content.toAnnotatedString(linkColor),
result.content.toAnnotatedString(linkColor, resolveFont),
TextRange(result.selectionStart, result.selectionEnd),
)
}
private fun applyLink(value: TextFieldValue, url: String, linkColor: Color): TextFieldValue {
private fun applyLink(
value: TextFieldValue,
url: String,
linkColor: Color,
resolveFont: (String) -> FontFamily? = { null },
): TextFieldValue {
val updated = RichTextEditing.applyLink(
value.annotatedString.toRichContent(),
value.selection.min,
value.selection.max,
url,
)
return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection)
return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection)
}
private fun seedValue(body: String, bodyHtml: String?, linkColor: Color): TextFieldValue {
val content = if (bodyHtml != null) RichTextHtml.fromHtml(bodyHtml) else RichTextContent(body)
val annotated = content.toAnnotatedString(linkColor)
private fun seedContent(body: String, bodyHtml: String?): RichTextContent =
if (bodyHtml != null) RichTextHtml.fromHtml(bodyHtml) else RichTextContent(body)
private fun RichTextContent.toSeededValue(linkColor: Color, resolveFont: (String) -> FontFamily?): TextFieldValue {
val annotated = toAnnotatedString(linkColor, resolveFont)
return TextFieldValue(annotated, TextRange(annotated.length))
}
/** Applies the message-wide base font family/size on top of the field's ambient text style. */
internal fun applyBaseStyle(
textStyle: TextStyle,
base: RichBaseStyle?,
resolveFont: (String) -> FontFamily?,
): TextStyle {
if (base == null) return textStyle
return textStyle.copy(
fontFamily = base.fontCss?.let(resolveFont) ?: textStyle.fontFamily,
fontSize = base.fontSizePt?.let { it.sp } ?: textStyle.fontSize,
)
}
/** Maps the app rich-text model onto a Compose [AnnotatedString] for display/editing. */
internal fun RichTextContent.toAnnotatedString(linkColor: Color): AnnotatedString = buildAnnotatedString {
internal fun RichTextContent.toAnnotatedString(
linkColor: Color,
resolveFont: (String) -> FontFamily? = { null },
): AnnotatedString = buildAnnotatedString {
append(text)
spans.forEach { span -> addStyle(spanStyleFor(span.style), span.start, span.end) }
spans.forEach { span ->
addStyle(spanStyleFor(span.style, resolveFont), span.start, span.end)
encodeStyle(span.style)?.let { addStringAnnotation(STYLE_TAG, it, span.start, span.end) }
}
links.forEach { link ->
addStyle(SpanStyle(color = linkColor), link.start, link.end)
addStringAnnotation(URL_TAG, link.url, link.start, link.end)
}
images.forEach { image ->
addStringAnnotation(IMAGE_TAG, encodeImage(image), image.start, image.end)
}
disjoint(alignments).forEach { alignment ->
addStyle(ParagraphStyle(textAlign = alignment.align.toTextAlign()), alignment.start, alignment.end)
}
}
/** Maps a Compose [AnnotatedString] back to the app model, reading single-attribute span styles. */
internal fun AnnotatedString.toRichContent(): RichTextContent {
val richSpans = spanStyles.mapNotNull { range ->
styleOf(range.item)?.let { RichSpan(range.start, range.end, it) }
/**
* Maps a Compose [AnnotatedString] back to the app model. Simple styles are recovered from their
* single-attribute [SpanStyle]s; parameterized styles and images from their string annotations
* (their visual paint is deliberately ignored, so the link color can never masquerade as a
* [RichStyle.FontColor]). [baseStyle] is position-independent and rides alongside unchanged.
*/
internal fun AnnotatedString.toRichContent(baseStyle: RichBaseStyle? = null): RichTextContent {
val simple = spanStyles.mapNotNull { range ->
simpleStyleOf(range.item)?.let { RichSpan(range.start, range.end, it) }
}
val parameterized = getStringAnnotations(STYLE_TAG, 0, length).mapNotNull { range ->
decodeStyle(range.item)?.let { RichSpan(range.start, range.end, it) }
}
val links = getStringAnnotations(URL_TAG, 0, length).map { RichLink(it.start, it.end, it.item) }
return RichTextContent(text, richSpans, links)
val images = getStringAnnotations(IMAGE_TAG, 0, length).mapNotNull(::decodeImage)
val alignments = paragraphStyles.mapNotNull { range ->
range.item.textAlign.toRichAlign()?.let { RichAlignment(range.start, range.end, it) }
}
return RichTextContent(
text = text,
spans = (simple + parameterized).sortedBy { it.start },
links = links,
alignments = alignments,
images = images,
baseStyle = baseStyle,
)
}
private fun spanStyleFor(style: RichStyle): SpanStyle = when (style) {
RichStyle.BOLD -> SpanStyle(fontWeight = FontWeight.Bold)
RichStyle.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
RichStyle.UNDERLINE -> SpanStyle(textDecoration = TextDecoration.Underline)
/** The visual paint for [style]; identity is carried separately (see [STYLE_TAG]). */
private fun spanStyleFor(style: RichStyle, resolveFont: (String) -> FontFamily?): SpanStyle = when (style) {
RichStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)
RichStyle.Italic -> SpanStyle(fontStyle = FontStyle.Italic)
RichStyle.Underline -> SpanStyle(textDecoration = TextDecoration.Underline)
RichStyle.Strikethrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
is RichStyle.FontFamily -> SpanStyle(fontFamily = resolveFont(style.css))
is RichStyle.FontSize -> SpanStyle(fontSize = style.pt.sp)
is RichStyle.FontColor -> SpanStyle(color = Color(style.argb))
is RichStyle.Highlight -> SpanStyle(background = Color(style.argb))
}
private fun styleOf(span: SpanStyle): RichStyle? = when {
span.fontWeight == FontWeight.Bold -> RichStyle.BOLD
span.fontStyle == FontStyle.Italic -> RichStyle.ITALIC
span.textDecoration == TextDecoration.Underline -> RichStyle.UNDERLINE
else -> null // e.g. the link color span, which is carried by the URL annotation instead
private fun simpleStyleOf(span: SpanStyle): RichStyle? = when {
span.fontWeight == FontWeight.Bold -> RichStyle.Bold
span.fontStyle == FontStyle.Italic -> RichStyle.Italic
span.textDecoration == TextDecoration.Underline -> RichStyle.Underline
span.textDecoration == TextDecoration.LineThrough -> RichStyle.Strikethrough
else -> null // parameterized styles and the link paint are carried by string annotations instead
}
private const val STYLE_FAMILY = "family"
private const val STYLE_SIZE = "size"
private const val STYLE_COLOR = "color"
private const val STYLE_HIGHLIGHT = "highlight"
/** Compact identity payload for parameterized styles; simple styles need none. */
private fun encodeStyle(style: RichStyle): String? = when (style) {
RichStyle.Bold, RichStyle.Italic, RichStyle.Underline, RichStyle.Strikethrough -> null
is RichStyle.FontFamily -> "$STYLE_FAMILY:${style.css}"
is RichStyle.FontSize -> "$STYLE_SIZE:${style.pt}"
is RichStyle.FontColor -> "$STYLE_COLOR:${style.argb}"
is RichStyle.Highlight -> "$STYLE_HIGHLIGHT:${style.argb}"
}
private fun decodeStyle(payload: String): RichStyle? {
val value = payload.substringAfter(':', "")
return when (payload.substringBefore(':')) {
STYLE_FAMILY -> RichStyle.FontFamily(value)
STYLE_SIZE -> value.toIntOrNull()?.let { RichStyle.FontSize(it) }
STYLE_COLOR -> value.toIntOrNull()?.let { RichStyle.FontColor(it) }
STYLE_HIGHLIGHT -> value.toIntOrNull()?.let { RichStyle.Highlight(it) }
else -> null
}
}
private const val IMAGE_SEPARATOR = '\n'
private fun encodeImage(image: RichImage): String = image.contentId + IMAGE_SEPARATOR + image.name
private fun decodeImage(range: AnnotatedString.Range<String>): RichImage? {
val sep = range.item.indexOf(IMAGE_SEPARATOR)
if (sep < 0) return null
return RichImage(range.start, range.end, range.item.take(sep), range.item.substring(sep + 1))
}
private fun RichAlign.toTextAlign(): TextAlign = when (this) {
RichAlign.START -> TextAlign.Start
RichAlign.CENTER -> TextAlign.Center
RichAlign.END -> TextAlign.End
}
private fun TextAlign.toRichAlign(): RichAlign? = when (this) {
TextAlign.Start, TextAlign.Left -> RichAlign.START
TextAlign.Center -> RichAlign.CENTER
TextAlign.End, TextAlign.Right -> RichAlign.END
else -> null
}
/** Paragraph styles must not overlap inside an [AnnotatedString]; sorts and drops any that would. */
private fun disjoint(alignments: List<RichAlignment>): List<RichAlignment> {
val result = ArrayList<RichAlignment>(alignments.size)
for (alignment in alignments.sortedBy { it.start }) {
if (alignment.start < alignment.end && (result.isEmpty() || alignment.start >= result.last().end)) {
result.add(alignment)
}
}
return result
}
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose.format
/** One selectable swatch for [ColorSwatchRow]: an opaque ARGB color and its accessible label. */
data class ColorSwatch(val argb: Int, val label: String)
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose.format
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import org.libremail.R
/**
* A horizontal row of color swatches with a leading "no color" entry, shared by the font-color and
* highlight pickers. The selected swatch (or the "no color" entry when [selectedArgb] is null)
* shows a primary-colored ring; every swatch is a labeled button for TalkBack.
*/
@Composable
fun ColorSwatchRow(
swatches: List<ColorSwatch>,
selectedArgb: Int?,
onSelect: (Int?) -> Unit,
modifier: Modifier = Modifier,
noneLabel: String = stringResource(R.string.format_color_none),
) {
Row(
modifier = modifier
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Swatch(color = null, label = noneLabel, isSelected = selectedArgb == null, onClick = { onSelect(null) })
swatches.forEach { swatch ->
Swatch(
color = Color(swatch.argb),
label = swatch.label,
isSelected = selectedArgb == swatch.argb,
onClick = { onSelect(swatch.argb) },
)
}
}
}
@Composable
private fun Swatch(color: Color?, label: String, isSelected: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val ring = if (isSelected) colors.primary else colors.outlineVariant
val slash = colors.outline
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(color ?: Color.Transparent)
.border(if (isSelected) 3.dp else 1.dp, ring, CircleShape)
.clickable(onClick = onClick, role = Role.Button)
.semantics {
contentDescription = label
selected = isSelected
},
) {
if (color == null) {
Canvas(Modifier.matchParentSize()) {
drawLine(
color = slash,
start = Offset(size.width * 0.25f, size.height * 0.75f),
end = Offset(size.width * 0.75f, size.height * 0.25f),
strokeWidth = 1.dp.toPx(),
)
}
}
}
}
+1
View File
@@ -88,6 +88,7 @@
<string name="format_link_url">Link address</string>
<string name="format_link_apply">Link</string>
<string name="format_link_needs_selection">Select some text first, then add a link.</string>
<string name="format_color_none">No color</string>
<!-- Drafts -->
<string name="drafts_title">Drafts</string>
@@ -4,6 +4,7 @@ package org.libremail.richtext
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class RichTextEditingTest {
@@ -11,23 +12,76 @@ class RichTextEditingTest {
@Test
fun `toggleStyle adds then removes a style over the selection`() {
val base = RichTextContent("hello")
val bold = RichTextEditing.toggleStyle(base, 0, 5, RichStyle.BOLD)
assertEquals(listOf(RichSpan(0, 5, RichStyle.BOLD)), bold.spans)
val bold = RichTextEditing.toggleStyle(base, 0, 5, RichStyle.Bold)
assertEquals(listOf(RichSpan(0, 5, RichStyle.Bold)), bold.spans)
val plain = RichTextEditing.toggleStyle(bold, 0, 5, RichStyle.BOLD)
val plain = RichTextEditing.toggleStyle(bold, 0, 5, RichStyle.Bold)
assertTrue(plain.spans.isEmpty())
}
@Test
fun `toggleStyle over a fully styled sub-range removes just that part`() {
val bold = RichTextContent("hello", spans = listOf(RichSpan(0, 5, RichStyle.BOLD)))
val result = RichTextEditing.toggleStyle(bold, 1, 3, RichStyle.BOLD)
val bold = RichTextContent("hello", spans = listOf(RichSpan(0, 5, RichStyle.Bold)))
val result = RichTextEditing.toggleStyle(bold, 1, 3, RichStyle.Bold)
assertEquals(
listOf(RichSpan(0, 1, RichStyle.BOLD), RichSpan(3, 5, RichStyle.BOLD)),
listOf(RichSpan(0, 1, RichStyle.Bold), RichSpan(3, 5, RichStyle.Bold)),
result.spans.sortedBy { it.start },
)
}
@Test
fun `toggleStyle with a different value of the same kind replaces it`() {
val base = RichTextContent("hello", spans = listOf(RichSpan(0, 5, RichStyle.FontSize(12))))
val resized = RichTextEditing.toggleStyle(base, 0, 5, RichStyle.FontSize(18))
assertEquals(listOf(RichSpan(0, 5, RichStyle.FontSize(18))), resized.spans)
val removed = RichTextEditing.toggleStyle(resized, 0, 5, RichStyle.FontSize(18))
assertTrue(removed.spans.isEmpty())
}
@Test
fun `toggleStyle replaces only the selected part of an old value`() {
val base = RichTextContent("abcdef", spans = listOf(RichSpan(0, 6, RichStyle.FontColor(1))))
val result = RichTextEditing.toggleStyle(base, 2, 4, RichStyle.FontColor(2))
assertEquals(
listOf(
RichSpan(0, 2, RichStyle.FontColor(1)),
RichSpan(2, 4, RichStyle.FontColor(2)),
RichSpan(4, 6, RichStyle.FontColor(1)),
),
result.spans,
)
}
@Test
fun `distinct style kinds toggle independently`() {
val base = RichTextContent("hello")
val styled = RichTextEditing.toggleStyle(
RichTextEditing.toggleStyle(base, 0, 5, RichStyle.Bold),
0,
5,
RichStyle.Strikethrough,
)
assertEquals(
setOf(RichSpan(0, 5, RichStyle.Bold), RichSpan(0, 5, RichStyle.Strikethrough)),
styled.spans.toSet(),
)
assertTrue(RichTextEditing.isStyled(styled, 0, 5, RichStyle.Strikethrough))
assertTrue(RichTextEditing.isStyled(styled, 0, 5, RichStyle.Bold))
}
@Test
fun `styleAt reports the selection's single value or null when mixed`() {
val content = RichTextContent(
"abcd",
spans = listOf(RichSpan(0, 2, RichStyle.FontSize(10)), RichSpan(2, 4, RichStyle.FontSize(14))),
)
assertEquals(RichStyle.FontSize(10), RichTextEditing.styleAt<RichStyle.FontSize>(content, 0, 2))
assertNull(RichTextEditing.styleAt<RichStyle.FontSize>(content, 0, 4))
assertEquals(RichStyle.FontSize(14), RichTextEditing.styleAt<RichStyle.FontSize>(content, 3, 3))
assertNull(RichTextEditing.styleAt<RichStyle.FontColor>(content, 0, 2))
}
@Test
fun `applyLink links the selection and removes overlapping links`() {
val base = RichTextContent("see here")
@@ -40,10 +94,10 @@ class RichTextEditingTest {
@Test
fun `toggleBlock adds a bullet marker to the caret's line and shifts spans`() {
val base = RichTextContent("ab", spans = listOf(RichSpan(0, 2, RichStyle.BOLD)))
val base = RichTextContent("ab", spans = listOf(RichSpan(0, 2, RichStyle.Bold)))
val result = RichTextEditing.toggleBlock(base, 0, 0, BlockMarker.BULLET)
assertEquals("• ab", result.content.text)
assertEquals(listOf(RichSpan(2, 4, RichStyle.BOLD)), result.content.spans)
assertEquals(listOf(RichSpan(2, 4, RichStyle.Bold)), result.content.spans)
}
@Test
@@ -67,4 +121,19 @@ class RichTextEditingTest {
val result = RichTextEditing.toggleBlock(base, 0, 3, BlockMarker.QUOTE)
assertEquals("> a", result.content.text)
}
@Test
fun `toggleBlock keeps and remaps the alignment image and base-style channels`() {
val base = RichTextContent(
text = "hi [image: x]",
alignments = listOf(RichAlignment(0, 13, RichAlign.CENTER)),
images = listOf(RichImage(3, 13, "c1", "x")),
baseStyle = RichBaseStyle(fontSizePt = 12),
)
val result = RichTextEditing.toggleBlock(base, 0, 0, BlockMarker.BULLET)
assertEquals("• hi [image: x]", result.content.text)
assertEquals(listOf(RichAlignment(2, 15, RichAlign.CENTER)), result.content.alignments)
assertEquals(listOf(RichImage(5, 15, "c1", "x")), result.content.images)
assertEquals(base.baseStyle, result.content.baseStyle)
}
}
@@ -19,9 +19,9 @@ class RichTextHtmlTest {
val content = RichTextContent(
text = "bold italic under",
spans = listOf(
RichSpan(0, 4, RichStyle.BOLD),
RichSpan(5, 11, RichStyle.ITALIC),
RichSpan(12, 17, RichStyle.UNDERLINE),
RichSpan(0, 4, RichStyle.Bold),
RichSpan(5, 11, RichStyle.Italic),
RichSpan(12, 17, RichStyle.Underline),
),
)
assertEquals("<p><b>bold</b> <i>italic</i> <u>under</u></p>", RichTextHtml.toHtml(content))
@@ -31,7 +31,7 @@ class RichTextHtmlTest {
fun `overlapping styles stay valid html`() {
val content = RichTextContent(
text = "abcd",
spans = listOf(RichSpan(0, 3, RichStyle.BOLD), RichSpan(1, 4, RichStyle.ITALIC)),
spans = listOf(RichSpan(0, 3, RichStyle.Bold), RichSpan(1, 4, RichStyle.Italic)),
)
// b over [0,3), i over [1,4): every run fully closes its tags, so nesting is always valid.
assertEquals("<p><b>a</b><b><i>bc</i></b><i>d</i></p>", RichTextHtml.toHtml(content))
@@ -61,14 +61,22 @@ class RichTextHtmlTest {
fun `hasFormatting is false for unstyled markerless text`() {
assertFalse(RichTextContent("just words\nmore words").hasFormatting())
assertTrue(RichTextContent("• bullet").hasFormatting())
assertTrue(RichTextContent("x", spans = listOf(RichSpan(0, 1, RichStyle.BOLD))).hasFormatting())
assertTrue(RichTextContent("x", spans = listOf(RichSpan(0, 1, RichStyle.Bold))).hasFormatting())
}
@Test
fun `hasFormatting covers the alignment image and base style channels`() {
assertTrue(RichTextContent("x", alignments = listOf(RichAlignment(0, 1, RichAlign.CENTER))).hasFormatting())
assertTrue(RichTextContent("[image: a]", images = listOf(RichImage(0, 10, "cid1", "a"))).hasFormatting())
assertTrue(RichTextContent("x", baseStyle = RichBaseStyle()).hasFormatting())
assertFalse(RichTextContent("x").hasFormatting())
}
@Test
fun `fromHtml round-trips paragraphs styles lists quotes and links`() {
listOf(
RichTextContent("Hello\nWorld"),
RichTextContent("bold", spans = listOf(RichSpan(0, 4, RichStyle.BOLD))),
RichTextContent("bold", spans = listOf(RichSpan(0, 4, RichStyle.Bold))),
RichTextContent("• Milk\n• Eggs"),
RichTextContent("1. One\n2. Two"),
RichTextContent("> a\n> b"),
@@ -95,11 +103,173 @@ class RichTextHtmlTest {
val restored = RichTextHtml.fromHtml("<ul>\n <li>One</li>\n <li><strong>Two</strong></li>\n</ul>")
assertEquals("• One\n• Two", restored.text)
// "Two" occupies [8,11) of "• One\n• Two".
assertEquals(listOf(RichSpan(8, 11, RichStyle.BOLD)), restored.spans)
assertEquals(listOf(RichSpan(8, 11, RichStyle.Bold)), restored.spans)
}
@Test
fun `empty content produces empty html`() {
assertEquals("", RichTextHtml.toHtml(RichTextContent("")))
}
// --- parameterized styles, alignment, images, base style (foundation for the formatting epic) ---
@Test
fun `strikethrough renders as s and parses del and strike too`() {
val content = RichTextContent("gone", spans = listOf(RichSpan(0, 4, RichStyle.Strikethrough)))
assertEquals("<p><s>gone</s></p>", RichTextHtml.toHtml(content))
listOf("<p><s>gone</s></p>", "<p><del>gone</del></p>", "<p><strike>gone</strike></p>").forEach { html ->
assertEquals(content, RichTextHtml.fromHtml(html), html)
}
}
@Test
fun `parameterized styles on one run merge into a single span tag`() {
val content = RichTextContent(
text = "ab",
spans = listOf(
RichSpan(0, 2, RichStyle.FontSize(14)),
RichSpan(0, 2, RichStyle.FontColor(0xFFFF0000.toInt())),
RichSpan(0, 2, RichStyle.Highlight(0xFFFFFF00.toInt())),
RichSpan(0, 2, RichStyle.FontFamily("Georgia, serif")),
),
)
assertEquals(
"<p><span style=\"font-family:Georgia, serif;font-size:14pt;color:#ff0000;" +
"background-color:#ffff00\">ab</span></p>",
RichTextHtml.toHtml(content),
)
assertRoundTrips(content)
}
@Test
fun `every new style and channel round-trips and keeps hasFormatting true`() {
listOf(
RichTextContent("struck", spans = listOf(RichSpan(0, 6, RichStyle.Strikethrough))),
RichTextContent("serif", spans = listOf(RichSpan(0, 5, RichStyle.FontFamily("Georgia, serif")))),
RichTextContent("sized", spans = listOf(RichSpan(0, 5, RichStyle.FontSize(18)))),
RichTextContent("red", spans = listOf(RichSpan(0, 3, RichStyle.FontColor(0xFFCC0000.toInt())))),
RichTextContent("hi", spans = listOf(RichSpan(0, 2, RichStyle.Highlight(0xFFFFFF00.toInt())))),
RichTextContent(
text = "mixed run",
spans = listOf(
RichSpan(0, 5, RichStyle.Bold),
RichSpan(2, 9, RichStyle.FontSize(12)),
RichSpan(2, 5, RichStyle.FontColor(0xFF336699.toInt())),
),
),
RichTextContent("left\ncentered", alignments = listOf(RichAlignment(5, 13, RichAlign.CENTER))),
RichTextContent("a\nb\nc", alignments = listOf(RichAlignment(0, 3, RichAlign.END))),
RichTextContent("• Milk\n• Eggs", alignments = listOf(RichAlignment(0, 13, RichAlign.CENTER))),
RichTextContent(
text = "see [image: cat.png] here",
images = listOf(RichImage(4, 20, "img1@libremail", "cat.png")),
),
RichTextContent("plain", baseStyle = RichBaseStyle(fontCss = "Arial, sans-serif", fontSizePt = 12)),
RichTextContent("plain", baseStyle = RichBaseStyle(fontSizePt = 11)),
RichTextContent(
text = "• item\nnote [image: dog.png]",
spans = listOf(RichSpan(2, 6, RichStyle.Bold), RichSpan(7, 11, RichStyle.FontSize(10))),
alignments = listOf(RichAlignment(0, 6, RichAlign.CENTER)),
images = listOf(RichImage(12, 28, "dog@mail", "dog.png")),
baseStyle = RichBaseStyle("Georgia, serif", 12),
),
).forEach(::assertRoundTrips)
}
@Test
fun `alignment splits merged paragraphs and marks list items`() {
val paragraphs = RichTextContent("a\nb", alignments = listOf(RichAlignment(2, 3, RichAlign.CENTER)))
assertEquals("<p>a</p><p style=\"text-align:center\">b</p>", RichTextHtml.toHtml(paragraphs))
val list = RichTextContent("• Milk\n• Eggs", alignments = listOf(RichAlignment(0, 13, RichAlign.CENTER)))
assertEquals(
"<ul><li style=\"text-align:center\">Milk</li><li style=\"text-align:center\">Eggs</li></ul>",
RichTextHtml.toHtml(list),
)
}
@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>")
assertEquals("a\nb", restored.text)
assertEquals(
listOf(RichAlignment(0, 1, RichAlign.START), RichAlignment(2, 3, RichAlign.END)),
restored.alignments,
)
}
@Test
fun `images replace their token and keep surrounding style wrappers`() {
val content = RichTextContent(
text = "[image: pic]",
spans = listOf(RichSpan(0, 12, RichStyle.Bold)),
links = listOf(RichLink(0, 12, "http://x")),
images = listOf(RichImage(0, 12, "c1", "pic")),
)
assertEquals(
"<p><a href=\"http://x\"><b><img src=\"cid:c1\" alt=\"pic\"></b></a></p>",
RichTextHtml.toHtml(content),
)
assertRoundTrips(content)
}
@Test
fun `parser converts px sizes and short hex colors`() {
val restored = RichTextHtml.fromHtml("<p><span style=\"font-size:16px;color:#f00\">x</span></p>")
assertEquals(
setOf(
RichSpan(0, 1, RichStyle.FontSize(12)),
RichSpan(0, 1, RichStyle.FontColor(0xFFFF0000.toInt())),
),
restored.spans.toSet(),
)
}
@Test
fun `unknown css properties are ignored without dropping the run`() {
val restored = RichTextHtml.fromHtml("<p><span style=\"mso-spacerun:yes;letter-spacing:2px\">kept</span></p>")
assertEquals("kept", restored.text)
assertTrue(restored.spans.isEmpty(), restored.spans.toString())
}
@Test
fun `font families with quotes round-trip through attribute escaping`() {
val family = RichStyle.FontFamily("\"Open Sans\", sans-serif")
val content = RichTextContent("x", spans = listOf(RichSpan(0, 1, family)))
val html = RichTextHtml.toHtml(content)
assertTrue(html.contains("&quot;Open Sans&quot;"), html)
assertRoundTrips(content)
}
@Test
fun `base style wrapper adds no stray text or newlines`() {
val restored = RichTextHtml.fromHtml("<div style=\"font-size:12pt\"><p>a<br>b</p></div>")
assertEquals("a\nb", restored.text)
assertEquals(RichBaseStyle(fontSizePt = 12), restored.baseStyle)
}
@Test
fun `base style survives empty text`() {
val content = RichTextContent("", baseStyle = RichBaseStyle(fontCss = "Georgia, serif"))
val html = RichTextHtml.toHtml(content)
assertEquals("<div style=\"font-family:Georgia, serif\"></div>", html)
assertEquals(content, RichTextHtml.fromHtml(html))
}
/**
* The strict guarantee every channel needs: serialize, parse back, get the same model, and stay
* "formatted" — ComposeViewModel.normalizedHtml() silently drops any HTML that parses back as
* unformatted, so a violation here means user formatting is destroyed on From-account switches.
*/
private fun assertRoundTrips(original: RichTextContent) {
assertTrue(original.hasFormatting(), "hasFormatting must be true for: $original")
val html = RichTextHtml.toHtml(original)
val restored = RichTextHtml.fromHtml(html)
assertEquals(original.text, restored.text, "text of: $html")
assertEquals(original.spans.toSet(), restored.spans.toSet(), "spans of: $html")
assertEquals(original.links.toSet(), restored.links.toSet(), "links of: $html")
assertEquals(original.alignments, restored.alignments, "alignments of: $html")
assertEquals(original.images, restored.images, "images of: $html")
assertEquals(original.baseStyle, restored.baseStyle, "baseStyle of: $html")
assertTrue(restored.hasFormatting(), "formatting must survive reparsing: $html")
}
}