Fix #4856, fix #4431 - Normalise HTML whitespaces using SwiftSoup's own text()/TextNode.normaliseWhitespace methods

This commit is contained in:
Stefan Ceriu
2026-01-20 16:44:56 +02:00
committed by Stefan Ceriu
parent 7d47a290ee
commit 40b655bde6
16 changed files with 68 additions and 43 deletions
@@ -122,9 +122,9 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
for node in element.getChildNodes() {
if let textNode = node as? TextNode {
// If this node is plain text just append its preformatted contents
// If this node is plain text append the whitespace normalised version
if node.parent() == documentBody {
result.append(NSAttributedString(string: textNode.getWholeText()))
result.append(NSAttributedString(string: textNode.text()))
continue
}
@@ -237,7 +237,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
content = attributedString(element: childElement, documentBody: documentBody, preserveFormatting: preserveFormatting, listTag: tag, listIndex: &listIndex, indentLevel: indentLevel + 1)
if indentLevel > 0 {
if indentLevel > 0 || !element.ownText().isEmpty {
content.insert(NSAttributedString("\n"), at: 0)
}
@@ -29,11 +29,11 @@ enum HTMLFixtures: String, CaseIterable {
"""
case .headers:
"""
<h1>H1 Header</h1>
<h2>H2 Header</h2>
<h3>H3 Header</h3>
<h4>H4 Header</h4>
<h5>H5 Header</h5>
<h1>H1 Header</h1>\
<h2>H2 Header</h2>\
<h3>H3 Header</h3>\
<h4>H4 Header</h4>\
<h5>H5 Header</h5>\
<h6>H6 Header</h6>
"""
case .paragraphs:
@@ -199,11 +199,10 @@ struct MessageText_Previews: PreviewProvider, TestablePreview {
private static let htmlStringWithQuote =
"""
<blockquote>A blockquote that is long and goes onto multiple lines as the first item in the message</blockquote>
<p>Then another line of text here to reply to the blockquote, which is also a multiline component.</p>
<blockquote>A blockquote that is long and goes onto multiple lines as the first item in the message</blockquote><p>Then another line of text here to reply to the blockquote, which is also a multiline component.</p>
"""
private static let htmlStringWithList = "<p>This is a list</p>\n<ul>\n<li>One</li>\n<li>Two</li>\n<li>And number 3</li>\n</ul>\n"
private static let htmlStringWithList = "<p>This is a list</p>\n<ul><li>One</li>\n<li>Two</li>\n<li>And number 3</li>\n</ul>\n"
private static let attributedStringBuilder = AttributedStringBuilder(mentionBuilder: MentionBuilder())
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:518aca90ecd814093b3ffbfcb361716bc55f0840fb1914d1d2dc9e6def6aee13
size 1112322
oid sha256:bce958a2c9eedbc8b4e63c7dc135dff80b249f64c812f47405400e1e2d314e22
size 1069392
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:518aca90ecd814093b3ffbfcb361716bc55f0840fb1914d1d2dc9e6def6aee13
size 1112322
oid sha256:bce958a2c9eedbc8b4e63c7dc135dff80b249f64c812f47405400e1e2d314e22
size 1069392
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d9858f3bc9676f311a526d2b08a86bc9e46d19a707a052db571f19abe7c9af86
size 1267614
oid sha256:c5e5b587c34f19aebcefabf0d9893dfde863390348132483ac8763387b692dc4
size 1252781
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d9858f3bc9676f311a526d2b08a86bc9e46d19a707a052db571f19abe7c9af86
size 1267614
oid sha256:c5e5b587c34f19aebcefabf0d9893dfde863390348132483ac8763387b692dc4
size 1252781
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f9991bd3bd6a88efc9f39fa075c0defc2a98f2a5f0f237df478b9926cbc6aadc
size 86111
oid sha256:5c60f428f0b84c2b499c2a3a5491ed302af3945d2dc93af2ac8702ba37f21568
size 82432
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f9991bd3bd6a88efc9f39fa075c0defc2a98f2a5f0f237df478b9926cbc6aadc
size 86111
oid sha256:5c60f428f0b84c2b499c2a3a5491ed302af3945d2dc93af2ac8702ba37f21568
size 82432
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ffac5d2e62703aefb5abec3a48eb22f84f4533fe26fc054b3250929138601718
size 49165
oid sha256:e36b3662d56586f0f759c03ac878eeebb325f29556e8192904b7146614802a98
size 48271
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ffac5d2e62703aefb5abec3a48eb22f84f4533fe26fc054b3250929138601718
size 49165
oid sha256:e36b3662d56586f0f759c03ac878eeebb325f29556e8192904b7146614802a98
size 48271
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8deba950d704d7e6b73183aa43c62f52c031fbb93bfa546d847282d12bca0b1c
size 75467
oid sha256:ff732f47d1ee83104eb640216c6849734a4d9b3ad8efa44f4b9bc7bb3284ccc1
size 74485
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8deba950d704d7e6b73183aa43c62f52c031fbb93bfa546d847282d12bca0b1c
size 75467
oid sha256:ff732f47d1ee83104eb640216c6849734a4d9b3ad8efa44f4b9bc7bb3284ccc1
size 74485
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:eabf0c072db6ae1062e14770b4267c1ccaf8d2478f20bde05f9a665594392b8e
size 35067
oid sha256:8699be861d421ee9615831cb23c28a6be20a223bcc775f7d86394709f1a4f87b
size 34936
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:eabf0c072db6ae1062e14770b4267c1ccaf8d2478f20bde05f9a665594392b8e
size 35067
oid sha256:8699be861d421ee9615831cb23c28a6be20a223bcc775f7d86394709f1a4f87b
size 34936
@@ -23,12 +23,12 @@ class AttributedStringBuilderTests: XCTestCase {
return
}
XCTAssertEqual(String(attributedString.characters), "H1 Header\n\nH2 Header\n\nH3 Header\n\nH4 Header\n\nH5 Header\n\nH6 Header")
XCTAssertEqual(String(attributedString.characters), "H1 Header\nH2 Header\nH3 Header\nH4 Header\nH5 Header\nH6 Header")
XCTAssertEqual(attributedString.runs.count, 11) // newlines hold no attributes
XCTAssertEqual(attributedString.runs.count, 4) // newlines hold no attributes
let pointSizes = attributedString.runs.compactMap(\.uiKit.font?.pointSize)
XCTAssertEqual(pointSizes, [23, 23, 23, 21, 19, 17])
XCTAssertEqual(pointSizes, [23, 21, 19, 17])
}
func testRenderHTMLStringWithPreCode() {
@@ -46,7 +46,7 @@ class AttributedStringBuilderTests: XCTestCase {
return
}
XCTAssertEqual(regex.numberOfMatches(in: string, options: [], range: .init(location: 0, length: string.count)), 18)
XCTAssertEqual(regex.numberOfMatches(in: string, options: [], range: .init(location: 0, length: string.count)), 13)
}
func testRenderHTMLStringWithLink() {
@@ -270,22 +270,24 @@ class AttributedStringBuilderTests: XCTestCase {
}
func testSingleBlockquote() {
let htmlString = "<blockquote>Blockquote</blockquote>"
let htmlString = "<blockquote>Blockquote</blockquote><p>Another paragraph</p>"
guard let attributedString = attributedStringBuilder.fromHTML(htmlString) else {
XCTFail("Could not build the attributed string")
return
}
XCTAssertEqual(attributedString.runs.count, 1)
XCTAssertEqual(attributedString.runs.count, 2)
XCTAssertEqual(attributedString.formattedComponents.count, 1)
XCTAssertEqual(attributedString.formattedComponents.count, 2)
for run in attributedString.runs where run.elementX.blockquote ?? false {
return
}
XCTFail("Couldn't find blockquote")
XCTAssertEqual(String(attributedString.characters), "Blockquote\nAnother paragraph")
}
// swiftlint:disable line_length
@@ -699,7 +701,7 @@ class AttributedStringBuilderTests: XCTestCase {
return
}
XCTAssertEqual(String(attributedString.characters), "like\n\n • this\ntest")
XCTAssertEqual(String(attributedString.characters), "like\n • this\ntest")
}
func testUnorderedList() {
@@ -768,6 +770,30 @@ class AttributedStringBuilderTests: XCTestCase {
XCTAssertEqual(String(attributedString.characters), " 1. A\n • A1\n • A2\n • A3\n 2. B\n 3. C")
}
// https://github.com/element-hq/element-x-ios/issues/4856
func testNormalisedWhitespaces() {
let html = """
<a href="https://github.com/stefan">Stefan</a> pushed
<a href="https://github.com">2 commits</a>
to
main:<ul> <li>
<a href="https://github.com"><code>Some update</code></a>
</li>
<li>
<a href="https://github.com"><code>Some other update</code></a>
</li>
</ul>
"""
guard let attributedString = attributedStringBuilder.fromHTML(html) else {
XCTFail("Could not build the attributed string")
return
}
XCTAssertEqual(String(attributedString.characters), "Stefan pushed 2 commits to main:\n • Some update \n • Some other update")
}
// MARK: - Phishing prevention
func testPhishingLink() {