Tusker/Tusker/HTMLConverter.swift

149 lines
6.5 KiB
Swift

//
// 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()
mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: mutAttrString.fullRange)
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:
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)
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:
return nil
}
}
}