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

Merged
JMR-dev merged 2 commits from feat-richtext-foundation into main 2026-07-01 22:15:58 +00:00
JMR-dev commented 2026-07-01 22:05:55 +00:00 (Migrated from github.com)

Foundation for the compose-editor rich formatting epic. Eight sibling tickets (#71–#78) stack on this branch — do not force-push it.

Contract implemented (org.libremail.richtext, pure JVM)

  • RichStyle is now a sealed interface: Bold, Italic, Underline, Strikethrough, FontFamily(css), FontSize(pt), FontColor(argb), Highlight(argb).
  • RichTextContent gains alignments: List<RichAlignment> (RichAlign START/CENTER/END, whole-line ranges), images: List<RichImage> (start/end/contentId/name, range covers the visible [image: name] token — see imageToken(name)), and baseStyle: RichBaseStyle? (fontCss, fontSizePt).
  • Serializer (RichTextHtml.toHtml, split into its own file): parameterized styles merge into one <span style="font-family:…;font-size:Npt;color:#rrggbb;background-color:#rrggbb"> per constant-styling run; alignment is text-align on <p>/<li> (consecutive-line <p> merging splits at alignment boundaries, and an all-blank group emits one <br> per line so blank lines round-trip); images replace their token with <img src="cid:…" alt="name"> keeping surrounding style/link wrappers; baseStyle is a single outermost <div style="…"> (emitted even for empty text so a pre-set base font survives).
  • Parser (RichTextHtmlParser.kt): faithful inverse of everything toHtml emits; also accepts <s>/<del>/<strike>, strong/em, px font sizes (pt = px·3/4, rounded), #rgb colors, left/start/right/end alignment synonyms. Unknown tags/CSS are ignored without dropping the text run. Open styles live on a general stack (span tags can carry several styles). Output is canonical: same-value spans and adjacent same-align line runs are maximally merged.
  • hasFormatting() covers every channel (span, link, block marker, alignment, image, non-null baseStyle). Exhaustive round-trip tests assert fromHtml(toHtml(x)) == x and hasFormatting() on both sides for every new style/channel — the guard for ComposeViewModel.normalizedHtml(), which drops any HTML that parses back unformatted.
  • Editor mapping (ui/compose/RichTextEditor.kt): parameterized spans carry identity via the STYLE_TAG = "libremail:style" string annotation (so FontColor can never be confused with link paint), images via IMAGE_TAG over the token range, alignment via ParagraphStyle(textAlign) ranges (sorted, overlap-dropped defensively). toAnnotatedString gains a font-resolver hook (css) -> FontFamily? (default resolves nothing); baseStyle lives in separate remember state seeded with the field and recombined in emit(), and is applied to the field's textStyle. RichTextBodyField's signature stays source-compatible (resolveFont defaulted; both existing call sites unchanged).
  • RichTextEditing: toggleStyle toggles the exact value off and replaces a different value of the same kind over the range; isStyled is exact-value; new styleAt<T>() answers "the single value of kind T over the selection (or caret)" for the pickers; toggleBlock now remaps and preserves alignments/images/baseStyle. Alignment/image ops are deliberately not implemented (#76/#77 own them). applyStyle(value, style, linkColor, resolveFont) is internal for one-line sibling wiring.
  • New ui/compose/format/ColorSwatchRow.kt (+ ColorSwatch): swatch row with selection ring, per-swatch content descriptions, and a leading "no color" entry for the #74/#75 dialogs.
  • Zero visible UI change: no new toolbar buttons; existing toolbar, signatures, drafts, and all pre-existing tests unchanged and green.

Deviations / decisions

  • ColorSwatch lives in its own file (format/ColorSwatch.kt) — detekt MatchingDeclarationName; package and names as contracted.
  • The parser class is RichTextHtmlParser (was HtmlToRichParser, internal — same reason).
  • text-align is emitted as left/center/right (email-client compatibility); start/end are parsed as synonyms.
  • Colors serialize as #rrggbb, so only opaque ARGB values round-trip (documented on RichStyle).

Known follow-ups for sibling tickets (found in review, out of foundation scope)

  • #78 (font memory): once baseStyle is non-null, the whole body serializes inside the <div> wrapper, so ComposeViewModel.swapHtmlSignature's endsWith(oldBlock) check fails and a From-account switch takes the existing rebuild-from-plaintext fallback (formatting lost, as already documented for edited HTML). Wiring baseStyle must make that swap wrapper-aware.
  • #76 (alignment ops): alignment on block-quote lines is not emitted (contract covers <p>/<li> only), and ops should canonicalize ranges to whole lines — toggleBlock remaps a range that started at a line start to start after the inserted marker, which re-parses expanded to the full line.
  • #77 (images): the editor's legacy TextFieldValue path drops annotations on IME edits (pre-existing — same for bold/links today); image insertion needs to keep the token and its RichImage record atomic under typing, and spans partially overlapping a token are canonicalized on serialize.

Verification

:app:assembleDebug + :app:testDebugUnitTest + :app:lintDebug + :app:ktlintCheck + :app:detekt + :app:compileDebugAndroidTestKotlin all green; emulator E2E left to CI.

Closes #70

🤖 Generated with Claude Code

Foundation for the compose-editor rich formatting epic. Eight sibling tickets (#71–#78) stack on this branch — **do not force-push it**. ## Contract implemented (`org.libremail.richtext`, pure JVM) - **`RichStyle`** is now a sealed interface: `Bold`, `Italic`, `Underline`, `Strikethrough`, `FontFamily(css)`, `FontSize(pt)`, `FontColor(argb)`, `Highlight(argb)`. - **`RichTextContent`** gains `alignments: List<RichAlignment>` (`RichAlign START/CENTER/END`, whole-line ranges), `images: List<RichImage>` (`start/end/contentId/name`, range covers the visible `[image: name]` token — see `imageToken(name)`), and `baseStyle: RichBaseStyle?` (`fontCss`, `fontSizePt`). - **Serializer** (`RichTextHtml.toHtml`, split into its own file): parameterized styles merge into one `<span style="font-family:…;font-size:Npt;color:#rrggbb;background-color:#rrggbb">` per constant-styling run; alignment is `text-align` on `<p>`/`<li>` (consecutive-line `<p>` merging splits at alignment boundaries, and an all-blank group emits one `<br>` per line so blank lines round-trip); images replace their token with `<img src="cid:…" alt="name">` keeping surrounding style/link wrappers; `baseStyle` is a single outermost `<div style="…">` (emitted even for empty text so a pre-set base font survives). - **Parser** (`RichTextHtmlParser.kt`): faithful inverse of everything `toHtml` emits; also accepts `<s>/<del>/<strike>`, `strong`/`em`, `px` font sizes (pt = px·3/4, rounded), `#rgb` colors, `left/start`/`right/end` alignment synonyms. Unknown tags/CSS are ignored **without** dropping the text run. Open styles live on a general stack (span tags can carry several styles). Output is canonical: same-value spans and adjacent same-align line runs are maximally merged. - **`hasFormatting()`** covers every channel (span, link, block marker, alignment, image, non-null baseStyle). Exhaustive round-trip tests assert `fromHtml(toHtml(x)) == x` **and** `hasFormatting()` on both sides for every new style/channel — the guard for `ComposeViewModel.normalizedHtml()`, which drops any HTML that parses back unformatted. - **Editor mapping** (`ui/compose/RichTextEditor.kt`): parameterized spans carry identity via the `STYLE_TAG = "libremail:style"` string annotation (so `FontColor` can never be confused with link paint), images via `IMAGE_TAG` over the token range, alignment via `ParagraphStyle(textAlign)` ranges (sorted, overlap-dropped defensively). `toAnnotatedString` gains a font-resolver hook `(css) -> FontFamily?` (default resolves nothing); `baseStyle` lives in separate `remember` state seeded with the field and recombined in `emit()`, and is applied to the field's `textStyle`. `RichTextBodyField`'s signature stays source-compatible (`resolveFont` defaulted; both existing call sites unchanged). - **`RichTextEditing`**: `toggleStyle` toggles the exact value off and *replaces* a different value of the same kind over the range; `isStyled` is exact-value; new `styleAt<T>()` answers "the single value of kind T over the selection (or caret)" for the pickers; `toggleBlock` now remaps and preserves alignments/images/baseStyle. Alignment/image *ops* are deliberately not implemented (#76/#77 own them). `applyStyle(value, style, linkColor, resolveFont)` is `internal` for one-line sibling wiring. - **New** `ui/compose/format/ColorSwatchRow.kt` (+ `ColorSwatch`): swatch row with selection ring, per-swatch content descriptions, and a leading "no color" entry for the #74/#75 dialogs. - **Zero visible UI change**: no new toolbar buttons; existing toolbar, signatures, drafts, and all pre-existing tests unchanged and green. ## Deviations / decisions - `ColorSwatch` lives in its own file (`format/ColorSwatch.kt`) — detekt `MatchingDeclarationName`; package and names as contracted. - The parser class is `RichTextHtmlParser` (was `HtmlToRichParser`, internal — same reason). - `text-align` is emitted as `left/center/right` (email-client compatibility); `start`/`end` are parsed as synonyms. - Colors serialize as `#rrggbb`, so only opaque ARGB values round-trip (documented on `RichStyle`). ## Known follow-ups for sibling tickets (found in review, out of foundation scope) - **#78 (font memory):** once `baseStyle` is non-null, the whole body serializes inside the `<div>` wrapper, so `ComposeViewModel.swapHtmlSignature`'s `endsWith(oldBlock)` check fails and a From-account switch takes the existing rebuild-from-plaintext fallback (formatting lost, as already documented for edited HTML). Wiring baseStyle must make that swap wrapper-aware. - **#76 (alignment ops):** alignment on block-quote lines is not emitted (contract covers `<p>`/`<li>` only), and ops should canonicalize ranges to whole lines — `toggleBlock` remaps a range that started at a line start to start after the inserted marker, which re-parses expanded to the full line. - **#77 (images):** the editor's legacy `TextFieldValue` path drops annotations on IME edits (pre-existing — same for bold/links today); image insertion needs to keep the token and its `RichImage` record atomic under typing, and spans partially overlapping a token are canonicalized on serialize. ## Verification `:app:assembleDebug` + `:app:testDebugUnitTest` + `:app:lintDebug` + `:app:ktlintCheck` + `:app:detekt` + `:app:compileDebugAndroidTestKotlin` all green; emulator E2E left to CI. Closes #70 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.