diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift
index 489cdac75..bcaa0f44b 100644
--- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift
+++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift
@@ -43,6 +43,8 @@ nonisolated struct AttributedStringBuilder: AttributedStringBuilderProtocol {
private let mentionBuilder: MentionBuilderProtocol
private static let attributeMSC4286 = "msc4286-external-payment-details"
+ /// Tags whose content already ends in a newline.
+ private static let lineTerminatingTags: Set = ["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "ul", "ol", "li"]
private static let caches = Mutex<[String: LRUCache]>([:])
static func invalidateCaches() {
@@ -119,6 +121,14 @@ nonisolated struct AttributedStringBuilder: AttributedStringBuilderProtocol {
for node in element.getChildNodes() {
if let textNode = node as? TextNode {
+ // Markdown generated HTML separates block elements and list items with newlines.
+ // SwiftSoup normalises those whitespace only nodes into stray spaces which misindent
+ // the following line, whereas HTML rendering collapses them away entirely.
+ if !preserveFormatting, textNode.isBlank(),
+ Self.isLineTerminating(node.previousSibling()) || Self.isLineTerminating(node.nextSibling()) {
+ continue
+ }
+
// If this node is plain text append the whitespace normalised version
if node.parent() == documentBody {
result.append(NSAttributedString(string: textNode.text()))
@@ -278,6 +288,14 @@ nonisolated struct AttributedStringBuilder: AttributedStringBuilderProtocol {
return result
}
+ private static func isLineTerminating(_ node: Node?) -> Bool {
+ guard let element = node as? Element else {
+ return false
+ }
+
+ return lineTerminatingTags.contains(element.tagName().lowercased())
+ }
+
private static func cacheValue(_ value: AttributedString?, forKey key: String, cacheKey: String) {
caches.withLock { caches in
if caches[cacheKey] == nil {
diff --git a/UnitTests/Sources/AttributedStringBuilderTests.swift b/UnitTests/Sources/AttributedStringBuilderTests.swift
index d606982ab..a215c8d39 100644
--- a/UnitTests/Sources/AttributedStringBuilderTests.swift
+++ b/UnitTests/Sources/AttributedStringBuilderTests.swift
@@ -649,7 +649,28 @@ struct AttributedStringBuilderTests {
let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string")
- #expect(String(attributedString.characters) == "like\n • this\ntest")
+ #expect(String(attributedString.characters) == "like\n • this\ntest")
+ }
+
+ /// Markdown generated HTML separates block elements with newlines which used to normalise
+ /// into stray spaces, indenting the first list item deeper than the ones that followed.
+ @Test
+ func interBlockWhitespace() throws {
+ let htmlString = "intro:
\n\n"
+
+ let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string")
+
+ #expect(String(attributedString.characters) == "intro:\n • first\n • second")
+ }
+
+ /// Whitespace between inline elements is meaningful, unlike the inter block variety.
+ @Test
+ func interInlineWhitespace() throws {
+ let htmlString = "one two"
+
+ let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string")
+
+ #expect(String(attributedString.characters) == "one two")
}
@Test
@@ -694,7 +715,7 @@ struct AttributedStringBuilderTests {
let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string")
- #expect(String(attributedString.characters) == " 2. this is a two")
+ #expect(String(attributedString.characters) == " 2. this is a two")
}
@Test
@@ -725,7 +746,7 @@ struct AttributedStringBuilderTests {
"""
let attributedString = try #require(attributedStringBuilder.fromHTML(html), "Could not build the attributed string")
- #expect(String(attributedString.characters) == "Stefan pushed 2 commits to main:\n • Some update \n • Some other update")
+ #expect(String(attributedString.characters) == "Stefan pushed 2 commits to main:\n • Some update \n • Some other update")
}
// MARK: - Phishing prevention