From 0dd7e07aa09cd4496b2b527392af8e89d50a8af0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 14 Oct 2026 17:30:16 +0200 Subject: [PATCH] Show HTML event descriptions as formatted text (#271) (#398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Event descriptions from Exchange/Outlook and Google Calendar are often HTML. The detail view now renders them instead of showing the raw markup. - A new converter in `domain/HtmlText.kt` turns HTML into text plus styles and links. It only kicks in when the description has real tags, so plain text with `<3` or `` is left alone. - Kept: bold, italic, underline, strikethrough, super/subscript, monospace, line breaks, paragraphs, lists (bullets, numbers, `start`, nesting) and `` links (http(s), mailto, tel). Dropped: fonts, sizes, colours, images, and ``/` +

Body

+ """.trimIndent() + assertThat(parseHtml(html).text).isEqualTo("Body") + } + + @Test + fun `an nbsp paragraph keeps one blank line, more collapse`() { + val html = "

One

 

 

 

Two

" + assertThat(parseHtml(html).text).isEqualTo("One\n\nTwo") + } + + @Test + fun `entities are decoded`() { + assertThat(parseHtml("

Fish & chips <3 €5 – café &bogus;

").text) + .isEqualTo("Fish & chips <3 €5 – café &bogus;") + } + + @Test + fun `inline styles cover their text`() { + val rich = parseHtml("

A bold and italic word

") + assertThat(rich.text).isEqualTo("A bold and italic word") + assertThat(rich.styles).containsExactly( + StyleRange(2, 6, InlineStyle.Bold), + StyleRange(11, 17, InlineStyle.Italic), + ) + } + + @Test + fun `fonts and colours are not kept`() { + val rich = parseHtml("Text") + assertThat(rich).isEqualTo(RichText("Text")) + } + + @Test + fun `links keep their href, unsafe schemes are dropped`() { + val rich = parseHtml( + "
Join " + + "Dial Bad", + ) + assertThat(rich.text).isEqualTo("Join Dial Bad") + assertThat(rich.links).containsExactly( + LinkRange(0, 4, "https://teams.microsoft.com/l/x?a=1&b=2"), + LinkRange(5, 9, "tel:+4930123,,456#"), + ) + } + + @Test + fun `lists get bullets and numbers`() { + val html = "

Agenda

  1. A
  2. B
" + assertThat(parseHtml(html).text).isEqualTo("Agenda\n• One\n• Two\n 1. Sub\n1. A\n2. B") + } + + @Test + fun `table rows become lines with cells side by side`() { + val html = "" + + "
TimeTopic
9:00Intro
 Spacer row
" + assertThat(parseHtml(html).text).isEqualTo("Time · Topic\n9:00 · Intro\nSpacer row") + } + + @Test + fun `formatting on a dropped spacer cell doesn't leak onto the next`() { + val rich = parseHtml("
 Plain
") + assertThat(rich).isEqualTo(RichText("Plain")) + } + + @Test + fun `pre keeps its whitespace`() { + assertThat(parseHtml("
a  b\n  c
").text).isEqualTo("a b\n c") + } + + @Test + fun `spans shift with trimmed whitespace`() { + val rich = parseHtml("

 

Bold

") + assertThat(rich.text).isEqualTo("Bold") + assertThat(rich.styles).containsExactly(StyleRange(0, 4, InlineStyle.Bold)) + } + + @Test + fun `broken markup stays text`() { + assertThat(parseHtml("

x < y and z

").text).isEqualTo("x < y and z") + assertThat(parseHtml("

open tail").links) + .containsExactly(LinkRange(5, 9, "https://a.b")) + } + + @Test + fun `a paragraph inside a list item stays on the bullet's line`() { + assertThat(parseHtml("

").text) + .isEqualTo("• One\n• Two") + } + + @Test + fun `newlines are the lines when the markup sets none`() { + val rich = parseHtml("Agenda:\n1. Intro\n2. Q&A") + assertThat(rich.text).isEqualTo("Agenda:\n1. Intro\n2. Q&A") + } + + @Test + fun `newlines in markup with its own lines are just spaces`() { + assertThat(parseHtml("

wrapped\nsource line

").text).isEqualTo("wrapped source line") + } + + @Test + fun `a link scheme is lower-cased`() { + assertThat(parseHtml("Join").links.single().url) + .isEqualTo("https://Teams.example/Join") + } + + @Test + fun `a query parameter is not read as an entity`() { + assertThat(parseHtml("x").links.single().url) + .isEqualTo("https://x.example/?id=5®=eu×=3") + } + + @Test + fun `outlook cells with paragraphs still sit side by side`() { + val html = "" + + "

9:00

Intro

10:00

Talk

" + val rich = parseHtml(html) + assertThat(rich.text).isEqualTo("9:00 · Intro\n10:00 · Talk") + assertThat(rich.styles).containsExactly(StyleRange(7, 12, InlineStyle.Bold)) + } + + @Test + fun `empty cells anywhere in a row leave no separator`() { + val html = "
Time Topic
" + assertThat(parseHtml(html).text).isEqualTo("Time · Topic") + } + + @Test + fun `a trailing empty cell doesn't join the text after the table`() { + val html = "
10:00Demo

After

" + assertThat(parseHtml(html).text).isEqualTo("10:00 · Demo\nAfter") + } + + @Test + fun `an unclosed head or style doesn't swallow the description`() { + assertThat(parseHtml("

Agenda

").text).isEqualTo("Agenda") + assertThat(parseHtml("