171 lines
7.8 KiB
Swift
171 lines
7.8 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()
|
|
|
|
// 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))."
|
|
}
|
|
}
|