Add an initial version of the V2 AttributedStringBuilder

This commit is contained in:
Stefan Ceriu
2025-08-22 12:20:43 +03:00
committed by Doug
parent 23a5d50067
commit 4ac805be19
10 changed files with 181 additions and 145 deletions

View File

@@ -6,17 +6,16 @@
//
import Compound
import DTCoreText
import LRUCache
import MatrixRustSDK
import SwiftSoup
import UIKit
struct AttributedStringBuilderV2: AttributedStringBuilderProtocol {
private let cacheKey: String
private let temporaryBlockquoteMarkingColor = UIColor.magenta
private let temporaryCodeBlockMarkingColor = UIColor.cyan
private let mentionBuilder: MentionBuilderProtocol
private static let attributeMSC4286 = "data-msc4286-external-payment-details"
private static let cacheDispatchQueue = DispatchQueue(label: "io.element.elementx.attributed_string_builder_v2_cache")
private static var caches: [String: LRUCache<String, AttributedString>] = [:]
@@ -39,7 +38,6 @@ struct AttributedStringBuilderV2: AttributedStringBuilderProtocol {
}
let mutableAttributedString = NSMutableAttributedString(string: string)
removeDefaultForegroundColors(mutableAttributedString)
addLinksAndMentions(mutableAttributedString)
addMatrixEntityPermalinkAttributesTo(mutableAttributedString)
@@ -67,41 +65,16 @@ struct AttributedStringBuilderV2: AttributedStringBuilderProtocol {
let htmlString = originalHTMLString.replacingHtmlBreaksOccurrences()
guard let data = htmlString.data(using: .utf8) else {
let doc = try? SwiftSoup.parseBodyFragment(htmlString)
guard let body = doc?.body() else {
return nil
}
let defaultFont = UIFont.preferredFont(forTextStyle: .body)
let parsingOptions: [String: Any] = [
DTUseiOS6Attributes: true,
DTDefaultFontFamily: defaultFont.familyName,
DTDefaultFontName: defaultFont.fontName,
DTDefaultFontSize: defaultFont.pointSize,
DTDefaultStyleSheet: DTCSSStylesheet(styleBlock: defaultCSS) as Any,
DTDefaultLinkDecoration: false
]
guard let builder = DTHTMLAttributedStringBuilder(html: data, options: parsingOptions, documentAttributes: nil) else {
return nil
}
builder.willFlushCallback = { element in
element?.sanitize(font: defaultFont)
}
guard let attributedString = builder.generatedAttributedString() else {
return nil
}
let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
removeDefaultForegroundColors(mutableAttributedString)
let mutableAttributedString = attributedString(from: body, preserveFormatting: false)
detectPhishingAttempts(mutableAttributedString)
addLinksAndMentions(mutableAttributedString)
replaceMarkedBlockquotes(mutableAttributedString)
replaceMarkedCodeBlocks(mutableAttributedString)
addMatrixEntityPermalinkAttributesTo(mutableAttributedString)
removeDTCoreTextArtifacts(mutableAttributedString)
let result = try? AttributedString(mutableAttributedString, including: \.elementX)
Self.cacheValue(result, forKey: htmlString, cacheKey: cacheKey)
@@ -111,6 +84,105 @@ struct AttributedStringBuilderV2: AttributedStringBuilderProtocol {
// MARK: - Private
func attributedString(from element: Element, preserveFormatting: Bool) -> NSMutableAttributedString {
let result = NSMutableAttributedString()
for node in element.getChildNodes() {
if let textNode = node as? TextNode {
var text = preserveFormatting ? textNode.getWholeText() : textNode.text()
// There seem to be sibling TextNodes following every </br> tag that
// contain one single space character which we don't want as it
// breaks line head indents.
if (node.previousSibling() as? Element)?.tagName() == "br" {
text.trimPrefix(" ")
}
result.append(NSAttributedString(string: text))
continue
}
guard let childElement = node as? Element else {
continue
}
let tag = childElement.tagName().lowercased()
var content = NSMutableAttributedString()
switch tag {
case "h1", "h2", "h3", "h4", "h5", "h6":
let level = Int(String(tag.dropFirst())) ?? 1
let size: CGFloat = UIFont.systemFontSize + CGFloat(6 - level) * 2
content = attributedString(from: childElement, preserveFormatting: preserveFormatting)
content.setFontPreservingSymbolicTraits(UIFont.boldSystemFont(ofSize: size))
case "p", "div":
content = attributedString(from: childElement, preserveFormatting: preserveFormatting)
content.append(NSAttributedString(string: "\n"))
case "br":
content = NSMutableAttributedString(string: "\n")
case "b", "strong":
content = attributedString(from: childElement, preserveFormatting: preserveFormatting)
content.setFontPreservingSymbolicTraits(UIFont.boldSystemFont(ofSize: UIFont.systemFontSize))
case "i", "em":
content = attributedString(from: childElement, preserveFormatting: preserveFormatting)
content.setFontPreservingSymbolicTraits(UIFont.italicSystemFont(ofSize: UIFont.systemFontSize))
case "u":
content = attributedString(from: childElement, preserveFormatting: preserveFormatting)
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: 0, length: content.length))
case "s", "del":
content = attributedString(from: childElement, preserveFormatting: preserveFormatting)
content.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: 0, length: content.length))
case "sup":
content = attributedString(from: childElement, preserveFormatting: preserveFormatting)
content.addAttribute(.baselineOffset, value: 6, range: NSRange(location: 0, length: content.length))
content.setFontPreservingSymbolicTraits(UIFont.systemFont(ofSize: UIFont.systemFontSize * 0.7))
case "sub":
content = attributedString(from: childElement, preserveFormatting: preserveFormatting)
content.addAttribute(.baselineOffset, value: -4, range: NSRange(location: 0, length: content.length))
content.setFontPreservingSymbolicTraits(UIFont.systemFont(ofSize: UIFont.systemFontSize * 0.7))
case "blockquote":
content = attributedString(from: childElement, preserveFormatting: preserveFormatting)
content.addAttribute(.MatrixBlockquote, value: true, range: NSRange(location: 0, length: content.length))
case "code", "pre":
let preserveFormatting = preserveFormatting || tag == "pre"
content = attributedString(from: childElement, preserveFormatting: preserveFormatting)
content.setFontPreservingSymbolicTraits(UIFont.monospacedSystemFont(ofSize: UIFont.systemFontSize, weight: .regular))
content.addAttribute(.backgroundColor, value: UIColor.compound._bgCodeBlock as Any, range: NSRange(location: 0, length: content.length))
case "hr":
content = NSMutableAttributedString(string: "\n")
case "a":
content = attributedString(from: childElement, preserveFormatting: preserveFormatting)
if let href = try? childElement.attr("href") {
content.addAttribute(.link, value: href, range: NSRange(location: 0, length: content.length))
}
case "span":
if (try? childElement.attr(Self.attributeMSC4286)) ?? nil != nil {
content = attributedString(from: childElement, preserveFormatting: preserveFormatting)
}
default:
content = attributedString(from: childElement, preserveFormatting: preserveFormatting)
}
result.append(content)
}
return result
}
private static func cacheValue(_ value: AttributedString?, forKey key: String, cacheKey: String) {
cacheDispatchQueue.sync {
if caches[cacheKey] == nil {
@@ -130,11 +202,6 @@ struct AttributedStringBuilderV2: AttributedStringBuilderProtocol {
return result
}
private func removeDefaultForegroundColors(_ attributedString: NSMutableAttributedString) {
attributedString.removeAttribute(.foregroundColor, range: .init(location: 0, length: attributedString.length))
}
// swiftlint:disable:next cyclomatic_complexity
private func addLinksAndMentions(_ attributedString: NSMutableAttributedString) {
let string = attributedString.string
@@ -201,12 +268,7 @@ struct AttributedStringBuilderV2: AttributedStringBuilderProtocol {
if hasLink {
return
}
// Don't add any extra attributes within codeblocks
if attributedString.attribute(.backgroundColor, at: match.range.location, effectiveRange: nil) as? UIColor == temporaryCodeBlockMarkingColor {
return
}
switch match.type {
case .atRoom:
attributedString.addAttribute(.MatrixAllUsersMention, value: true, range: match.range)
@@ -227,41 +289,6 @@ struct AttributedStringBuilderV2: AttributedStringBuilderProtocol {
}
}
private func replaceMarkedBlockquotes(_ attributedString: NSMutableAttributedString) {
// According to blockquotes in the string, DTCoreText can apply 2 policies:
// - define a `DTTextBlocksAttribute` attribute on a <blockquote> block
// - or, just define a `NSBackgroundColorAttributeName` attribute
attributedString.enumerateAttribute(.DTTextBlocks, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in
guard let value = value as? NSArray,
let dtTextBlock = value.firstObject as? DTTextBlock,
dtTextBlock.backgroundColor == temporaryBlockquoteMarkingColor else {
return
}
attributedString.addAttribute(.MatrixBlockquote, value: true, range: range)
}
attributedString.enumerateAttribute(.backgroundColor, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in
guard let value = value as? UIColor,
value == temporaryBlockquoteMarkingColor else {
return
}
attributedString.removeAttribute(.backgroundColor, range: range)
attributedString.addAttribute(.MatrixBlockquote, value: true, range: range)
}
}
private func replaceMarkedCodeBlocks(_ attributedString: NSMutableAttributedString) {
attributedString.enumerateAttribute(.backgroundColor, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in
if let value = value as? UIColor,
value == temporaryCodeBlockMarkingColor {
attributedString.addAttribute(.backgroundColor, value: UIColor.compound._bgCodeBlock as Any, range: range)
attributedString.removeAttribute(.link, range: range)
}
}
}
func addMatrixEntityPermalinkAttributesTo(_ attributedString: NSMutableAttributedString) {
attributedString.enumerateAttribute(.link, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in
if value != nil {
@@ -324,53 +351,6 @@ struct AttributedStringBuilderV2: AttributedStringBuilderProtocol {
attributedString.addAttribute(.link, value: finalURL, range: range)
}
private func removeDTCoreTextArtifacts(_ attributedString: NSMutableAttributedString) {
guard attributedString.length > 0 else {
return
}
// DTCoreText adds a newline at the end of plain text ( https://github.com/Cocoanetics/DTCoreText/issues/779 )
// or after a blockquote section.
// Trim trailing whitespace and newlines in the string content
while (attributedString.string as NSString).hasSuffixCharacter(from: .whitespacesAndNewlines) {
attributedString.deleteCharacters(in: .init(location: attributedString.length - 1, length: 1))
}
}
private var defaultCSS: String {
"""
blockquote {
background: \(temporaryBlockquoteMarkingColor.toHexString());
display: block;
}
pre,code {
background-color: \(temporaryCodeBlockMarkingColor.toHexString());
display: inline;
white-space: pre;
font-size: 0.9em;
-coretext-fontname: .AppleSystemUIFontMonospaced-Regular;
}
h1,h2,h3 {
font-size: 1.2em;
}
"""
}
}
private extension UIColor {
func toHexString() -> String {
var red: CGFloat = 0.0
var green: CGFloat = 0.0
var blue: CGFloat = 0.0
var alpha: CGFloat = 0.0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
let rgb = Int(red * 255) << 16 | Int(green * 255) << 8 | Int(blue * 255) << 0
return NSString(format: "#%06x", rgb) as String
}
}
private struct TextParsingMatch {
@@ -396,3 +376,23 @@ private struct TextParsingMatch {
}
}
}
private extension NSMutableAttributedString {
func setFontPreservingSymbolicTraits(_ newFont: UIFont) {
enumerateAttribute(.font, in: NSRange(location: 0, length: length)) { value, range, _ in
if let oldFont = value as? UIFont {
// keep the traits (bold, italic, etc.)
let traits = oldFont.fontDescriptor.symbolicTraits
if let descriptor = newFont.fontDescriptor.withSymbolicTraits(traits) {
let updatedFont = UIFont(descriptor: descriptor, size: newFont.pointSize)
addAttribute(.font, value: updatedFont, range: range)
} else {
// fallback if traits can't be applied
addAttribute(.font, value: newFont, range: range)
}
} else {
addAttribute(.font, value: newFont, range: range)
}
}
}
}

View File

@@ -134,24 +134,35 @@ struct FormattedBodyText: View {
struct FormattedBodyText_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
body
body(AttributedStringBuilderV1(cacheKey: "v1", mentionBuilder: MentionBuilder()))
.previewLayout(.sizeThatFits)
body(AttributedStringBuilderV2(cacheKey: "v2", mentionBuilder: MentionBuilder()))
.previewLayout(.sizeThatFits)
}
@ViewBuilder
static var body: some View {
static func body(_ attributedStringBuilder: AttributedStringBuilderProtocol) -> some View {
let htmlStrings = [
"""
Plain text\n
!room:matrix.org\n
https://www.matrix.org\n
www.matrix.org\n
Plain text
<br>
!room:matrix.org
<br>
https://www.matrix.org
<br>
www.matrix.org
<br>
matrix.org
<br>
what's <sup>sup</sup> dude?
<br>
thumbs if you liked it, <sub>sub</sub> if you loved it
""",
"""
Text before blockquote
<blockquote>
<b>bold</b> <i>italic</i>
</blockquote>Text after blockquote
<blockquote><b>bold</b> <i>italic</i></blockquote>
Text after blockquote
""",
"""
<blockquote>First blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote>
@@ -165,6 +176,21 @@ struct FormattedBodyText_Previews: PreviewProvider, TestablePreview {
<p>And a simple reply here.</p>
""",
"""
<pre><code>struct ContentView: View {
var body: some View {
VStack {
Text("Knock, knock!")
.padding()
.background(Color.yellow, in: RoundedRectangle(cornerRadius: 8))
Text("Who's there?")
}
.padding()
}
}
</code>
</pre>
""",
"""
<code>Hello world</code>
<p>Text</p>
<code><b>Hello</b> <i>world</i></code>
@@ -178,8 +204,6 @@ struct FormattedBodyText_Previews: PreviewProvider, TestablePreview {
"<p>test</p>\n<p>test</p>"
]
let attributedStringBuilder = AttributedStringBuilder(mentionBuilder: MentionBuilder())
ScrollView {
VStack(alignment: .leading, spacing: 24.0) {
ForEach(htmlStrings, id: \.self) { htmlString in

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a5300140d0d125b42c74b95e136ca19e809d0ca441f63dcf3e0eff2c38c0522
size 319205
oid sha256:934392ab4fdab8bd7bdb73ec3fde03c80ec86c045af86c7af0d43f2b9c973307
size 431642

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:555d7ae30575387972f64caa2d324bcb5511c27aad11b80a0d74daafe0a80651
size 403362

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a5300140d0d125b42c74b95e136ca19e809d0ca441f63dcf3e0eff2c38c0522
size 319205
oid sha256:934392ab4fdab8bd7bdb73ec3fde03c80ec86c045af86c7af0d43f2b9c973307
size 431642

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:555d7ae30575387972f64caa2d324bcb5511c27aad11b80a0d74daafe0a80651
size 403362

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:974272940990f0fd78e1f403c74ba9714fe8662d79399e91f63307bc35421fdd
size 247463
oid sha256:c32002366537dbb2498693c6404bbb7f04c8e2b6d4276c27cf8e7b6da726c41d
size 327007

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:070581019f6b97ffdad6ebb5b895211e1359c3f77f05b34c9addfafc3fe4b7aa
size 302644

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:974272940990f0fd78e1f403c74ba9714fe8662d79399e91f63307bc35421fdd
size 247463
oid sha256:c32002366537dbb2498693c6404bbb7f04c8e2b6d4276c27cf8e7b6da726c41d
size 327007

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:070581019f6b97ffdad6ebb5b895211e1359c3f77f05b34c9addfafc3fe4b7aa
size 302644