From 2cefc4751b12cab58231503069552dcedf82b0ee Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 16:54:25 -0500 Subject: [PATCH] feat(richtext): parameterized styles, alignment/image/base-style channels, HTML round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 runs, text-align on

/

  • (splitting merged paragraphs at alignment boundaries), over the visible [image: name] token, and a single outer
    for the base style. The parser is a faithful inverse and additionally tolerates /, 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 --- .../kotlin/org/libremail/richtext/RichText.kt | 359 +++------------- .../org/libremail/richtext/RichTextEditing.kt | 76 +++- .../org/libremail/richtext/RichTextHtml.kt | 212 ++++++++++ .../libremail/richtext/RichTextHtmlParser.kt | 387 ++++++++++++++++++ .../libremail/ui/compose/RichTextEditor.kt | 232 +++++++++-- .../ui/compose/format/ColorSwatch.kt | 5 + .../ui/compose/format/ColorSwatchRow.kt | 89 ++++ app/src/main/res/values/strings.xml | 1 + .../libremail/richtext/RichTextEditingTest.kt | 85 +++- .../libremail/richtext/RichTextHtmlTest.kt | 184 ++++++++- 10 files changed, 1267 insertions(+), 363 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/richtext/RichTextHtml.kt create mode 100644 app/src/main/kotlin/org/libremail/richtext/RichTextHtmlParser.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/compose/format/ColorSwatch.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/compose/format/ColorSwatchRow.kt diff --git a/app/src/main/kotlin/org/libremail/richtext/RichText.kt b/app/src/main/kotlin/org/libremail/richtext/RichText.kt index 923ee13..652ca8d 100644 --- a/app/src/main/kotlin/org/libremail/richtext/RichText.kt +++ b/app/src/main/kotlin/org/libremail/richtext/RichText.kt @@ -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 ``, also parsed from ``/``. */ + 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 `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 `
    ` 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 = emptyList(), val links: List = emptyList(), + val alignments: List = emptyList(), + val images: List = 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 — `