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:
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
private fun escapeAttr(s: String): String = escape(s).replace("\"", """)
|
||||
|
||||
private fun unescape(s: String): String = s
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
internal fun escapeAttr(s: String): String = escape(s).replace("\"", """)
|
||||
@@ -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("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(""Open Sans""), 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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user