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