From 7e5d8675c24123ad52e5dab2b965c7083fe5c4ea Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 3 Dec 2022 18:58:19 -0500 Subject: [PATCH] Extract HTML to attributed string converter to separate helper --- Tusker.xcodeproj/project.pbxproj | 4 + Tusker/HTMLConverter.swift | 128 +++++++++++++++++++++++++++++ Tusker/Views/ContentTextView.swift | 115 ++++---------------------- 3 files changed, 146 insertions(+), 101 deletions(-) create mode 100644 Tusker/HTMLConverter.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index ee05863d..6d07da57 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -64,6 +64,7 @@ D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B0293BD85300C0B37F /* CreateFilterService.swift */; }; D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */; }; D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */; }; + D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75BA293C183100C0B37F /* HTMLConverter.swift */; }; D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; }; D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; }; D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; }; @@ -444,6 +445,7 @@ D61F75B0293BD85300C0B37F /* CreateFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateFilterService.swift; sourceTree = ""; }; D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateFilterService.swift; sourceTree = ""; }; D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteFilterService.swift; sourceTree = ""; }; + D61F75BA293C183100C0B37F /* HTMLConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLConverter.swift; sourceTree = ""; }; D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = ""; }; D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; }; D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = ""; }; @@ -1453,6 +1455,7 @@ D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */, D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */, D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */, + D61F75BA293C183100C0B37F /* HTMLConverter.swift */, D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */, D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, D64D0AAC2128D88B005A6F37 /* LocalData.swift */, @@ -1856,6 +1859,7 @@ D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */, 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */, D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */, + D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */, D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */, D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */, D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */, diff --git a/Tusker/HTMLConverter.swift b/Tusker/HTMLConverter.swift new file mode 100644 index 00000000..4ceae78d --- /dev/null +++ b/Tusker/HTMLConverter.swift @@ -0,0 +1,128 @@ +// +// HTMLConverter.swift +// Tusker +// +// Created by Shadowfacts on 12/3/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import SwiftSoup +import WebURL +import WebURLFoundationExtras + +struct HTMLConverter { + + static let defaultFont = UIFont.systemFont(ofSize: 17) + static let defaultColor = UIColor.label + static let defaultParagraphStyle: NSParagraphStyle = { + let style = NSMutableParagraphStyle() + // 2 points is enough that it doesn't make things look weirdly spaced out, but leaves a slight gap when there are multiple lines of emojis + style.lineSpacing = 2 + return style + }() + + var font: UIFont = defaultFont + var color: UIColor = defaultColor + var paragraphStyle: NSParagraphStyle = defaultParagraphStyle + + func convert(_ html: String) -> NSAttributedString { + let doc = try! SwiftSoup.parseBodyFragment(html) + let body = doc.body()! + + let attributedText = attributedTextForHTMLNode(body) + let mutAttrString = NSMutableAttributedString(attributedString: attributedText) + mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines) + mutAttrString.collapseWhitespace() + + mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: mutAttrString.fullRange) + + return mutAttrString + } + + private func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString { + switch node { + case let node as TextNode: + let text: String + if usePreformattedText { + text = node.getWholeText() + } else { + text = node.text() + } + return NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: color]) + case let node as Element: + let attributed = NSMutableAttributedString(string: "", attributes: [.font: font, .foregroundColor: color]) + for child in node.getChildNodes() { + attributed.append(attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre")) + } + + switch node.tagName() { + case "br": + // need to specify defaultFont here b/c otherwise it uses the default 12pt Helvetica which + // screws up its determination of the line height making multiple lines of emojis squash together + attributed.append(NSAttributedString(string: "\n", attributes: [.font: font])) + case "a": + let href = try! node.attr("href") + if let webURL = WebURL(href), + let url = URL(webURL) { + attributed.addAttribute(.link, value: url, range: attributed.fullRange) + } else if let url = URL(string: href) { + attributed.addAttribute(.link, value: url, range: attributed.fullRange) + } + case "p": + attributed.append(NSAttributedString(string: "\n\n", attributes: [.font: font])) + case "em", "i": + let currentFont: UIFont + if attributed.length == 0 { + currentFont = font + } else { + currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font + } + attributed.addAttribute(.font, value: currentFont.withTraits(.traitItalic)!, range: attributed.fullRange) + case "strong", "b": + let currentFont: UIFont + if attributed.length == 0 { + currentFont = font + } else { + currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font + } + attributed.addAttribute(.font, value: currentFont.withTraits(.traitBold)!, range: attributed.fullRange) + case "del": + attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange) + case "code": + // TODO: this probably breaks with dynamic type + attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: font.pointSize, weight: .regular), range: attributed.fullRange) + case "pre": + attributed.append(NSAttributedString(string: "\n\n")) + attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: font.pointSize, weight: .regular), range: attributed.fullRange) + case "ol", "ul": + attributed.append(NSAttributedString(string: "\n\n")) + attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines) + case "li": + let parentEl = node.parent()! + let parentTag = parentEl.tagName() + let bullet: NSAttributedString + if parentTag == "ol" { + let index = (try? node.elementSiblingIndex()) ?? 0 + // we use the monospace digit font so that the periods of all the list items line up + // TODO: this probably breaks with dynamic type + bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: font.pointSize, weight: .regular)]) + } else if parentTag == "ul" { + bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: font]) + } else { + bullet = NSAttributedString() + } + attributed.insert(bullet, at: 0) + attributed.append(NSAttributedString(string: "\n", attributes: [.font: font])) + default: + break + } + + return attributed + default: + fatalError("Unexpected node type \(type(of: node))") + } + } + + +} diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index e8effca2..e8a2af3c 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -22,14 +22,19 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { weak var overrideMastodonController: MastodonController? var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController } - var defaultFont: UIFont = .systemFont(ofSize: 17) - var defaultColor: UIColor = .label - var paragraphStyle: NSParagraphStyle = { - let style = NSMutableParagraphStyle() - // 2 points is enough that it doesn't make things look weirdly spaced out, but leaves a slight gap when there are multiple lines of emojis - style.lineSpacing = 2 - return style - }() + private var htmlConverter = HTMLConverter() + var defaultFont: UIFont { + _read { yield htmlConverter.font } + _modify { yield &htmlConverter.font } + } + var defaultColor: UIColor { + _read { yield htmlConverter.color } + _modify { yield &htmlConverter.color } + } + var paragraphStyle: NSParagraphStyle { + _read { yield htmlConverter.paragraphStyle } + _modify { yield &htmlConverter.paragraphStyle } + } private(set) var hasEmojis = false @@ -85,99 +90,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { // MARK: - HTML Parsing func setTextFromHtml(_ html: String) { - let doc = try! SwiftSoup.parse(html) - let body = doc.body()! - - let attributedText = attributedTextForHTMLNode(body) - let mutAttrString = NSMutableAttributedString(attributedString: attributedText) - mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines) - mutAttrString.collapseWhitespace() - - mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: mutAttrString.fullRange) - - self.attributedText = mutAttrString - } - - private func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString { - switch node { - case let node as TextNode: - let text: String - if usePreformattedText { - text = node.getWholeText() - } else { - text = node.text() - } - return NSAttributedString(string: text, attributes: [.font: defaultFont, .foregroundColor: defaultColor]) - case let node as Element: - let attributed = NSMutableAttributedString(string: "", attributes: [.font: defaultFont, .foregroundColor: defaultColor]) - for child in node.getChildNodes() { - attributed.append(attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre")) - } - - switch node.tagName() { - case "br": - // need to specify defaultFont here b/c otherwise it uses the default 12pt Helvetica which - // screws up its determination of the line height making multiple lines of emojis squash together - attributed.append(NSAttributedString(string: "\n", attributes: [.font: defaultFont])) - case "a": - let href = try! node.attr("href") - if let webURL = WebURL(href), - let url = URL(webURL) { - attributed.addAttribute(.link, value: url, range: attributed.fullRange) - } else if let url = URL(string: href) { - attributed.addAttribute(.link, value: url, range: attributed.fullRange) - } - case "p": - attributed.append(NSAttributedString(string: "\n\n", attributes: [.font: defaultFont])) - case "em", "i": - let currentFont: UIFont - if attributed.length == 0 { - currentFont = defaultFont - } else { - currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? defaultFont - } - attributed.addAttribute(.font, value: currentFont.withTraits(.traitItalic)!, range: attributed.fullRange) - case "strong", "b": - let currentFont: UIFont - if attributed.length == 0 { - currentFont = defaultFont - } else { - currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? defaultFont - } - attributed.addAttribute(.font, value: currentFont.withTraits(.traitBold)!, range: attributed.fullRange) - case "del": - attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange) - case "code": - attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: defaultFont.pointSize, weight: .regular), range: attributed.fullRange) - case "pre": - attributed.append(NSAttributedString(string: "\n\n")) - attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: defaultFont.pointSize, weight: .regular), range: attributed.fullRange) - case "ol", "ul": - attributed.append(NSAttributedString(string: "\n\n")) - attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines) - case "li": - let parentEl = node.parent()! - let parentTag = parentEl.tagName() - let bullet: NSAttributedString - if parentTag == "ol" { - let index = (try? node.elementSiblingIndex()) ?? 0 - // we use the monospace digit font so that the periods of all the list items line up - bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: defaultFont.pointSize, weight: .regular)]) - } else if parentTag == "ul" { - bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: defaultFont]) - } else { - bullet = NSAttributedString() - } - attributed.insert(bullet, at: 0) - attributed.append(NSAttributedString(string: "\n", attributes: [.font: defaultFont])) - default: - break - } - - return attributed - default: - fatalError("Unexpected node type \(type(of: node))") - } + self.attributedText = htmlConverter.convert(html) } // MARK: - Interaction