Add a custom HTML -> NSAttributedString initializer
This commit is contained in:
parent
83e7975080
commit
fd672c5ce6
330
Shared/Extensions/NSAttributedString+NetNewsWire.swift
Normal file
330
Shared/Extensions/NSAttributedString+NetNewsWire.swift
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
//
|
||||||
|
// NSAttributedString+NetNewsWire.swift
|
||||||
|
// NetNewsWire
|
||||||
|
//
|
||||||
|
// Created by Nate Weaver on 2020-04-07.
|
||||||
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
typealias Font = NSFont
|
||||||
|
typealias FontDescriptor = NSFontDescriptor
|
||||||
|
typealias Color = NSColor
|
||||||
|
|
||||||
|
private let boldTrait = NSFontDescriptor.SymbolicTraits.bold
|
||||||
|
private let italicTrait = NSFontDescriptor.SymbolicTraits.italic
|
||||||
|
private let monoSpaceTrait = NSFontDescriptor.SymbolicTraits.monoSpace
|
||||||
|
#else
|
||||||
|
import UIKit
|
||||||
|
typealias Font = UIFont
|
||||||
|
typealias FontDescriptor = UIFontDescriptor
|
||||||
|
typealias Color = UIColor
|
||||||
|
|
||||||
|
private let boldTrait = UIFontDescriptor.SymbolicTraits.traitBold
|
||||||
|
private let italicTrait = UIFontDescriptor.SymbolicTraits.traitItalic
|
||||||
|
private let monoSpaceTrait = UIFontDescriptor.SymbolicTraits.traitMonoSpace
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extension NSAttributedString {
|
||||||
|
|
||||||
|
/// Adds a font and color to an attributed string.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - baseFont: The font to add.
|
||||||
|
/// - color: The color to add.
|
||||||
|
func adding(font baseFont: Font, color: Color? = nil) -> NSAttributedString {
|
||||||
|
|
||||||
|
let mutable = self.mutableCopy() as! NSMutableAttributedString
|
||||||
|
let fullRange = NSRange(location: 0, length: mutable.length)
|
||||||
|
|
||||||
|
if let color = color {
|
||||||
|
mutable.addAttribute(.foregroundColor, value: color as Any, range: fullRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = baseFont.pointSize
|
||||||
|
let baseDescriptor = baseFont.fontDescriptor
|
||||||
|
let baseSymbolicTraits = baseDescriptor.symbolicTraits
|
||||||
|
|
||||||
|
let baseTraits = baseDescriptor.object(forKey: .traits) as! [FontDescriptor.TraitKey: Any]
|
||||||
|
let baseWeight = baseTraits[.weight] as! Font.Weight
|
||||||
|
|
||||||
|
mutable.enumerateAttribute(.font, in: fullRange, options: []) { (font: Any?, range: NSRange, stop: UnsafeMutablePointer<ObjCBool>) in
|
||||||
|
guard let font = font as? Font else { return }
|
||||||
|
|
||||||
|
var newSymbolicTraits = baseSymbolicTraits
|
||||||
|
|
||||||
|
let symbolicTraits = font.fontDescriptor.symbolicTraits
|
||||||
|
|
||||||
|
newSymbolicTraits.insert(symbolicTraits)
|
||||||
|
|
||||||
|
var descriptor = baseDescriptor.addingAttributes(font.fontDescriptor.fontAttributes)
|
||||||
|
|
||||||
|
#if canImport(AppKit)
|
||||||
|
descriptor = descriptor.withSymbolicTraits(newSymbolicTraits)
|
||||||
|
#else
|
||||||
|
var descriptor = descriptor.withSymbolicTraits(newSymbolicTraits)!
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if symbolicTraits.contains(boldTrait) {
|
||||||
|
// If the base font is semibold (as timeline titles are), make the "bold"
|
||||||
|
// text heavy for better contrast.
|
||||||
|
|
||||||
|
if baseWeight == .semibold {
|
||||||
|
let traits: [FontDescriptor.TraitKey: Any] = [.weight: Font.Weight.heavy]
|
||||||
|
let attributes: [FontDescriptor.AttributeName: Any] = [.traits: traits]
|
||||||
|
descriptor = descriptor.addingAttributes(attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let newFont = Font(descriptor: descriptor, size: size)
|
||||||
|
|
||||||
|
mutable.addAttribute(.font, value: newFont as Any, range: range)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mutable.copy() as! NSAttributedString
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an attributed string from HTML.
|
||||||
|
|
||||||
|
private enum InTag {
|
||||||
|
case none
|
||||||
|
case opening
|
||||||
|
case closing
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Style {
|
||||||
|
case bold
|
||||||
|
case italic
|
||||||
|
case superscript
|
||||||
|
case `subscript`
|
||||||
|
case underline
|
||||||
|
case strikethrough
|
||||||
|
case monospace
|
||||||
|
|
||||||
|
init?(tag: String) {
|
||||||
|
switch tag {
|
||||||
|
case "b", "strong":
|
||||||
|
self = .bold
|
||||||
|
case "i", "em", "cite":
|
||||||
|
self = .italic
|
||||||
|
case "sup":
|
||||||
|
self = .superscript
|
||||||
|
case "sub":
|
||||||
|
self = .subscript
|
||||||
|
case "u", "ins":
|
||||||
|
self = .underline
|
||||||
|
case "s", "del":
|
||||||
|
self = .strikethrough
|
||||||
|
case "code", "samp", "tt", "kbd":
|
||||||
|
self = .monospace
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decode(entity: String) -> String {
|
||||||
|
// TODO: Support all named entities
|
||||||
|
|
||||||
|
guard entity.hasPrefix("&"), entity.hasSuffix(";") else { return entity }
|
||||||
|
|
||||||
|
let name = entity.dropFirst().dropLast()
|
||||||
|
|
||||||
|
if name.hasPrefix("#") {
|
||||||
|
let value = name.dropFirst()
|
||||||
|
var number: Int? = nil
|
||||||
|
|
||||||
|
if value.hasPrefix("x") {
|
||||||
|
number = Int(value.dropFirst(), radix: 16)
|
||||||
|
} else {
|
||||||
|
number = Int(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let number = number, let c = UnicodeScalar(number) {
|
||||||
|
return String(c)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch name {
|
||||||
|
case "lt":
|
||||||
|
return "<"
|
||||||
|
case "gt":
|
||||||
|
return ">"
|
||||||
|
case "amp":
|
||||||
|
return "&"
|
||||||
|
case "quot":
|
||||||
|
return "\""
|
||||||
|
case "apos":
|
||||||
|
return "'"
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an attributed string initialized from HTML text containing basic inline stylistic tags.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - html: The HTML text.
|
||||||
|
/// - font: The font to use. Defaults to the system font.
|
||||||
|
/// - locale: The locale used for quotation marks when parsing `<q>` tags.
|
||||||
|
convenience init(html: String, font: Font? = nil, locale: Locale = Locale.current) {
|
||||||
|
let baseFont = font ?? Font.systemFont(ofSize: Font.systemFontSize)
|
||||||
|
|
||||||
|
var inTag: InTag = .none
|
||||||
|
var tagBuf = ""
|
||||||
|
var tagStack = [String]()
|
||||||
|
var currentStyles: Set<Style> = []
|
||||||
|
|
||||||
|
var iterator = html.makeIterator()
|
||||||
|
|
||||||
|
let result = NSMutableAttributedString()
|
||||||
|
|
||||||
|
var attributeRanges = [(range: NSRange, styles: Set<Style>)]()
|
||||||
|
|
||||||
|
while let char = iterator.next() {
|
||||||
|
if char == "<" && inTag == .none {
|
||||||
|
tagBuf.removeAll()
|
||||||
|
|
||||||
|
guard let first = iterator.next() else { break }
|
||||||
|
|
||||||
|
if first == "/" {
|
||||||
|
inTag = .closing
|
||||||
|
} else {
|
||||||
|
inTag = .opening
|
||||||
|
tagBuf.append(first)
|
||||||
|
}
|
||||||
|
} else if char == ">" && inTag != .none {
|
||||||
|
if inTag == .opening {
|
||||||
|
tagStack.append(tagBuf)
|
||||||
|
|
||||||
|
if tagBuf == "q" {
|
||||||
|
result.mutableString.append(locale.quotationBeginDelimiter ?? "\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastRange = attributeRanges.last?.range
|
||||||
|
let location = lastRange != nil ? lastRange!.location + lastRange!.length : 0
|
||||||
|
let range = NSRange(location: location, length: result.mutableString.length - location)
|
||||||
|
|
||||||
|
let style = Style(tag: tagBuf)
|
||||||
|
|
||||||
|
attributeRanges.append( (range: range, styles: currentStyles) )
|
||||||
|
|
||||||
|
if style != nil {
|
||||||
|
currentStyles.insert(style!)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} else {
|
||||||
|
if tagBuf == "q" {
|
||||||
|
result.mutableString.append(locale.quotationEndDelimiter ?? "\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastRange = attributeRanges.last?.range
|
||||||
|
let location = lastRange != nil ? lastRange!.location + lastRange!.length : 0
|
||||||
|
let range = NSRange(location: location, length: result.mutableString.length - location)
|
||||||
|
|
||||||
|
attributeRanges.append( ( range: range, styles: currentStyles ))
|
||||||
|
|
||||||
|
if let style = Style(tag: tagBuf) {
|
||||||
|
currentStyles.remove(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = tagStack.popLast() // TODO: Handle improperly-nested tags
|
||||||
|
}
|
||||||
|
|
||||||
|
inTag = .none
|
||||||
|
} else if inTag != .none {
|
||||||
|
tagBuf.append(char)
|
||||||
|
} else {
|
||||||
|
if char == "&" {
|
||||||
|
var entity = "&"
|
||||||
|
var lastchar: Character? = nil
|
||||||
|
|
||||||
|
while let entitychar = iterator.next() {
|
||||||
|
if entitychar.isWhitespace {
|
||||||
|
lastchar = entitychar
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.append(entitychar)
|
||||||
|
|
||||||
|
if (entitychar == ";") { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
result.mutableString.append(Self.decode(entity: entity))
|
||||||
|
|
||||||
|
if let lastchar = lastchar { result.mutableString.append(String(lastchar)) }
|
||||||
|
} else {
|
||||||
|
result.mutableString.append(String(char))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.addAttribute(.font, value: baseFont, range: NSRange(location: 0, length: result.length))
|
||||||
|
|
||||||
|
for (range, styles) in attributeRanges {
|
||||||
|
let currentFont = result.attribute(.font, at: range.location, effectiveRange: nil) as! Font
|
||||||
|
let currentDescriptor = currentFont.fontDescriptor
|
||||||
|
var descriptor = currentDescriptor.copy() as! FontDescriptor
|
||||||
|
|
||||||
|
var attributes: [NSAttributedString.Key: Any] = [:]
|
||||||
|
|
||||||
|
if styles.contains(.bold) {
|
||||||
|
let traits: [FontDescriptor.TraitKey: Any] = [.weight: Font.Weight.bold]
|
||||||
|
let descriptorAttributes: [FontDescriptor.AttributeName: Any] = [.traits: traits]
|
||||||
|
descriptor = descriptor.addingAttributes(descriptorAttributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if styles.contains(.italic) {
|
||||||
|
var symbolicTraits = currentDescriptor.symbolicTraits
|
||||||
|
symbolicTraits.insert(.italic)
|
||||||
|
descriptor = descriptor.withSymbolicTraits(symbolicTraits)
|
||||||
|
}
|
||||||
|
|
||||||
|
if styles.contains(.monospace) {
|
||||||
|
var symbolicTraits = currentDescriptor.symbolicTraits
|
||||||
|
symbolicTraits.insert(.monoSpace)
|
||||||
|
descriptor = descriptor.withSymbolicTraits(symbolicTraits)
|
||||||
|
}
|
||||||
|
|
||||||
|
if styles.contains(.superscript) {
|
||||||
|
#if canImport(AppKit)
|
||||||
|
let features: [FontDescriptor.FeatureKey: Any] = [.typeIdentifier: kVerticalPositionType, .selectorIdentifier: kSuperiorsSelector]
|
||||||
|
#else
|
||||||
|
let features: [FontDescriptor.FeatureKey: Any] = [.featureIdentifier: kVerticalPositionType, .typeIdentifier: kSuperiorsSelector]
|
||||||
|
#endif
|
||||||
|
let descriptorAttributes: [FontDescriptor.AttributeName: Any] = [.featureSettings: [features]]
|
||||||
|
descriptor = descriptor.addingAttributes(descriptorAttributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if styles.contains(.subscript) {
|
||||||
|
#if canImport(AppKit)
|
||||||
|
let features: [FontDescriptor.FeatureKey: Any] = [.typeIdentifier: kVerticalPositionType, .selectorIdentifier: kInferiorsSelector]
|
||||||
|
#else
|
||||||
|
let features: [FontDescriptor.FeatureKey: Any] = [.featureIdentifier: kVerticalPositionType, .typeIdentifier: kInferiorsSelector]
|
||||||
|
#endif
|
||||||
|
let descriptorAttributes: [FontDescriptor.AttributeName: Any] = [.featureSettings: [features]]
|
||||||
|
descriptor = currentDescriptor.addingAttributes(descriptorAttributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
attributes[.font] = Font(descriptor: descriptor, size: baseFont.pointSize)
|
||||||
|
|
||||||
|
if styles.contains(.strikethrough) {
|
||||||
|
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if styles.contains(.underline) {
|
||||||
|
attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.addAttributes(attributes, range: range)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(attributedString: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1 +1 @@
|
|||||||
Subproject commit 0ba28167babdd4710adc1764ed49979c9eb42ead
|
Subproject commit 3dfa570a4600690290cd946b8e122b0b99da0a13
|
@ -1 +1 @@
|
|||||||
Subproject commit 0a8718c5c412585141d8374ae01b1e9e75dd1019
|
Subproject commit de7753914c4f47aa97adfbe7a121acf0d2f8cd40
|
@ -1 +1 @@
|
|||||||
Subproject commit 8c035b26767e66f5639c2fc0f3398216a46cb3d1
|
Subproject commit fcbd9a34ecd8c080c6f26798a4b22ea0c98d8e74
|
@ -1 +1 @@
|
|||||||
Subproject commit 81011808b6c4242cbf95cb55120f250c157d594c
|
Subproject commit 2fc9b9cff60032a272303ff6d6df5b39ec297179
|
@ -1 +1 @@
|
|||||||
Subproject commit 7af4393f575479d249b83e7761a59d14b6c170d0
|
Subproject commit 05388e4f7073b014f786cfce18782c3d61f8e378
|
Loading…
x
Reference in New Issue
Block a user