// // 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()! 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": attributed.addAttribute(.font, value: monospaceFont, range: attributed.fullRange) case "pre": attributed.append(NSAttributedString(string: "\n\n")) attributed.addAttribute(.font, value: monospaceFont, 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: monospaceFont, .foregroundColor: color]) } else if parentTag == "ul" { bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: font, .foregroundColor: color]) } 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))") } } }