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 754c2f0fed
commit af7a3a24f6
10 changed files with 181 additions and 145 deletions
@@ -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)
}
}
}
}