Add an initial version of the V2 AttributedStringBuilder
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0a5300140d0d125b42c74b95e136ca19e809d0ca441f63dcf3e0eff2c38c0522
|
||||
size 319205
|
||||
oid sha256:934392ab4fdab8bd7bdb73ec3fde03c80ec86c045af86c7af0d43f2b9c973307
|
||||
size 431642
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:555d7ae30575387972f64caa2d324bcb5511c27aad11b80a0d74daafe0a80651
|
||||
size 403362
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0a5300140d0d125b42c74b95e136ca19e809d0ca441f63dcf3e0eff2c38c0522
|
||||
size 319205
|
||||
oid sha256:934392ab4fdab8bd7bdb73ec3fde03c80ec86c045af86c7af0d43f2b9c973307
|
||||
size 431642
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:555d7ae30575387972f64caa2d324bcb5511c27aad11b80a0d74daafe0a80651
|
||||
size 403362
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:974272940990f0fd78e1f403c74ba9714fe8662d79399e91f63307bc35421fdd
|
||||
size 247463
|
||||
oid sha256:c32002366537dbb2498693c6404bbb7f04c8e2b6d4276c27cf8e7b6da726c41d
|
||||
size 327007
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:070581019f6b97ffdad6ebb5b895211e1359c3f77f05b34c9addfafc3fe4b7aa
|
||||
size 302644
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:974272940990f0fd78e1f403c74ba9714fe8662d79399e91f63307bc35421fdd
|
||||
size 247463
|
||||
oid sha256:c32002366537dbb2498693c6404bbb7f04c8e2b6d4276c27cf8e7b6da726c41d
|
||||
size 327007
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:070581019f6b97ffdad6ebb5b895211e1359c3f77f05b34c9addfafc3fe4b7aa
|
||||
size 302644
|
||||
Reference in New Issue
Block a user