// // 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 defaultMonospaceFont = UIFont.monospacedSystemFont(ofSize: 17, weight: .regular) 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 monospaceFont: UIFont = defaultMonospaceFont var color: UIColor = defaultColor var paragraphStyle: NSParagraphStyle = defaultParagraphStyle func convert(_ html: String) -> NSAttributedString { let doc = try! SwiftSoup.parseBodyFragment(html) let body = doc.body()! if let attributedText = attributedTextForHTMLNode(body) { let mutAttrString = NSMutableAttributedString(attributedString: attributedText) mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines) mutAttrString.collapseWhitespace() // Wait until the end and then fill in the unset paragraph styles, to avoid clobbering the list style. mutAttrString.enumerateAttribute(.paragraphStyle, in: mutAttrString.fullRange, options: .longestEffectiveRangeNotRequired) { value, range, stop in if value == nil { mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range) } } return mutAttrString } else { return NSAttributedString() } } 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: if node.tagName() == "ol" || node.tagName() == "ul" { return attributedTextForList(node, usePreformattedText: usePreformattedText) } let attributed = NSMutableAttributedString(string: "", attributes: [.font: font, .foregroundColor: color]) for child in node.getChildNodes() { var appendEllipsis = false if node.tagName() == "a", let el = child as? Element { if el.hasClass("invisible") { continue } else if el.hasClass("ellipsis") { appendEllipsis = true } } if let childText = attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre") { attributed.append(childText) } if appendEllipsis { attributed.append(NSAttributedString("…")) } } 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": attributed.addAttribute(.font, value: monospaceFont, range: attributed.fullRange) case "pre": attributed.append(NSAttributedString(string: "\n\n")) attributed.addAttribute(.font, value: monospaceFont, range: attributed.fullRange) default: break } return attributed default: return nil } } private func attributedTextForList(_ element: Element, usePreformattedText: Bool) -> NSAttributedString { let list = element.tagName() == "ol" ? OrderedNumberTextList(markerFormat: .decimal, options: 0) : NSTextList(markerFormat: .disc, options: 0) let paragraphStyle = paragraphStyle.mutableCopy() as! NSMutableParagraphStyle // I don't like that I can't just use paragraphStyle.textLists, because it makes the list markers // not use the monospace digit font (it seems to just use whatever font attribute is set for the whole thing), // and it doesn't right align the list markers. // Unfortunately, doing it manually means the list markers are incldued in the selectable text. paragraphStyle.headIndent = 32 paragraphStyle.firstLineHeadIndent = 0 // Use 2 tab stops, one for the list marker, the second for the content. paragraphStyle.tabStops = [NSTextTab(textAlignment: .right, location: 28), NSTextTab(textAlignment: .natural, location: 32)] let str = NSMutableAttributedString(string: "") var item = 1 for child in element.children() where child.tagName() == "li" { if let childStr = attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText) { str.append(NSAttributedString(string: "\t\(list.marker(forItemNumber: item))\t", attributes: [ .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .monospacedDigitSystemFont(ofSize: 17, weight: .regular)), ])) str.append(childStr) str.append(NSAttributedString(string: "\n")) item += 1 } } str.addAttribute(.paragraphStyle, value: paragraphStyle, range: str.fullRange) return str } } private class OrderedNumberTextList: NSTextList { override func marker(forItemNumber itemNumber: Int) -> String { "\(super.marker(forItemNumber: itemNumber))." } }