2020-04-26 09:23:59 +02:00
|
|
|
//
|
|
|
|
// NSAttributedString+NetNewsWire.swift
|
|
|
|
// NetNewsWire
|
|
|
|
//
|
|
|
|
// Created by Nate Weaver on 2020-04-07.
|
|
|
|
// Copyright © 2020 Ranchero Software. All rights reserved.
|
|
|
|
//
|
|
|
|
|
2020-04-27 01:17:42 +02:00
|
|
|
import RSParser
|
|
|
|
|
2020-04-26 09:23:59 +02:00
|
|
|
#if canImport(AppKit)
|
|
|
|
import AppKit
|
|
|
|
typealias Font = NSFont
|
|
|
|
typealias FontDescriptor = NSFontDescriptor
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
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.
|
2020-05-01 08:44:23 +02:00
|
|
|
func adding(font baseFont: Font) -> NSAttributedString {
|
2020-04-26 09:23:59 +02:00
|
|
|
let mutable = self.mutableCopy() as! NSMutableAttributedString
|
|
|
|
let fullRange = NSRange(location: 0, length: mutable.length)
|
|
|
|
|
|
|
|
let size = baseFont.pointSize
|
|
|
|
let baseDescriptor = baseFont.fontDescriptor
|
|
|
|
let baseSymbolicTraits = baseDescriptor.symbolicTraits
|
|
|
|
|
|
|
|
mutable.enumerateAttribute(.font, in: fullRange, options: []) { (font: Any?, range: NSRange, stop: UnsafeMutablePointer<ObjCBool>) in
|
|
|
|
guard let font = font as? Font else { return }
|
|
|
|
|
2020-05-01 00:18:20 +02:00
|
|
|
let currentDescriptor = font.fontDescriptor
|
2020-05-01 08:56:19 +02:00
|
|
|
let symbolicTraits = baseSymbolicTraits.union(currentDescriptor.symbolicTraits)
|
2020-04-26 09:23:59 +02:00
|
|
|
|
2020-05-01 08:56:19 +02:00
|
|
|
var descriptor = currentDescriptor.addingAttributes(baseDescriptor.fontAttributes)
|
2020-05-01 00:18:20 +02:00
|
|
|
|
2020-04-26 09:23:59 +02:00
|
|
|
#if canImport(AppKit)
|
2020-05-01 08:56:19 +02:00
|
|
|
descriptor = descriptor.withSymbolicTraits(symbolicTraits)
|
2020-04-26 09:23:59 +02:00
|
|
|
#else
|
2020-05-01 08:56:19 +02:00
|
|
|
descriptor = descriptor.withSymbolicTraits(symbolicTraits)!
|
2020-04-26 09:23:59 +02:00
|
|
|
#endif
|
|
|
|
|
|
|
|
let newFont = Font(descriptor: descriptor, size: size)
|
|
|
|
|
|
|
|
mutable.addAttribute(.font, value: newFont as Any, range: range)
|
|
|
|
}
|
|
|
|
|
|
|
|
return mutable.copy() as! NSAttributedString
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
|
2020-04-30 08:21:51 +02:00
|
|
|
init?(forTag: String) {
|
|
|
|
switch forTag {
|
2020-04-26 09:23:59 +02:00
|
|
|
case "b", "strong":
|
|
|
|
self = .bold
|
2020-04-30 10:04:51 +02:00
|
|
|
case "i", "em", "cite", "var", "dfn":
|
2020-04-26 09:23:59 +02:00
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Returns an attributed string initialized from HTML text containing basic inline stylistic tags.
|
|
|
|
///
|
|
|
|
/// - Parameters:
|
|
|
|
/// - html: The HTML text.
|
|
|
|
/// - locale: The locale used for quotation marks when parsing `<q>` tags.
|
2020-04-28 22:54:56 +02:00
|
|
|
convenience init(html: String, locale: Locale = Locale.current) {
|
|
|
|
let baseFont = Font.systemFont(ofSize: Font.systemFontSize)
|
2020-04-26 09:23:59 +02:00
|
|
|
|
|
|
|
var inTag: InTag = .none
|
2020-04-26 18:11:50 +02:00
|
|
|
var tag = ""
|
2020-04-26 18:11:26 +02:00
|
|
|
var currentStyles = CountedSet<Style>()
|
2020-04-26 09:23:59 +02:00
|
|
|
|
|
|
|
var iterator = html.makeIterator()
|
|
|
|
|
|
|
|
let result = NSMutableAttributedString()
|
|
|
|
|
2020-04-26 18:11:26 +02:00
|
|
|
var attributeRanges = [ (range: NSRange, styles: CountedSet<Style>) ]()
|
2020-04-28 23:57:41 +02:00
|
|
|
var quoteDepth = 0
|
2020-04-26 09:23:59 +02:00
|
|
|
|
|
|
|
while let char = iterator.next() {
|
|
|
|
if char == "<" && inTag == .none {
|
2020-04-26 18:11:50 +02:00
|
|
|
tag.removeAll()
|
2020-04-26 09:23:59 +02:00
|
|
|
|
|
|
|
guard let first = iterator.next() else { break }
|
|
|
|
|
|
|
|
if first == "/" {
|
|
|
|
inTag = .closing
|
|
|
|
} else {
|
|
|
|
inTag = .opening
|
2020-04-26 18:11:50 +02:00
|
|
|
tag.append(first)
|
2020-04-26 09:23:59 +02:00
|
|
|
}
|
|
|
|
} else if char == ">" && inTag != .none {
|
2020-04-29 00:31:41 +02:00
|
|
|
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) )
|
|
|
|
|
2020-04-26 09:23:59 +02:00
|
|
|
if inTag == .opening {
|
2020-04-26 18:11:50 +02:00
|
|
|
if tag == "q" {
|
2020-04-28 23:57:41 +02:00
|
|
|
quoteDepth += 1
|
|
|
|
let delimiter = quoteDepth % 2 == 1 ? locale.quotationBeginDelimiter : locale.alternateQuotationBeginDelimiter
|
|
|
|
result.mutableString.append(delimiter ?? "\"")
|
2020-04-26 09:23:59 +02:00
|
|
|
}
|
|
|
|
|
2020-04-30 08:21:51 +02:00
|
|
|
if let style = Style(forTag: tag) {
|
2020-04-29 00:03:43 +02:00
|
|
|
currentStyles.insert(style)
|
2020-04-26 09:23:59 +02:00
|
|
|
}
|
|
|
|
} else {
|
2020-04-26 18:11:50 +02:00
|
|
|
if tag == "q" {
|
2020-04-28 23:57:41 +02:00
|
|
|
let delimiter = quoteDepth % 2 == 1 ? locale.quotationEndDelimiter : locale.alternateQuotationEndDelimiter
|
|
|
|
result.mutableString.append(delimiter ?? "\"")
|
|
|
|
quoteDepth -= 1
|
2020-04-26 09:23:59 +02:00
|
|
|
}
|
|
|
|
|
2020-04-30 08:21:51 +02:00
|
|
|
if let style = Style(forTag: tag) {
|
2020-04-26 09:23:59 +02:00
|
|
|
currentStyles.remove(style)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
inTag = .none
|
|
|
|
} else if inTag != .none {
|
2020-04-26 18:11:50 +02:00
|
|
|
tag.append(char)
|
2020-04-26 09:23:59 +02:00
|
|
|
} 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 }
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2020-04-27 01:17:42 +02:00
|
|
|
result.mutableString.append(entity.decodedEntity)
|
2020-04-26 09:23:59 +02:00
|
|
|
|
|
|
|
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
|
|
|
|
|
2020-04-30 09:34:45 +02:00
|
|
|
var symbolicTraits = currentDescriptor.symbolicTraits
|
|
|
|
|
2020-04-26 09:23:59 +02:00
|
|
|
if styles.contains(.bold) {
|
2020-05-01 00:08:14 +02:00
|
|
|
let traits: [FontDescriptor.TraitKey: Any] = [.weight: Font.Weight.bold]
|
|
|
|
let attributes: [FontDescriptor.AttributeName: Any] = [.traits: traits]
|
|
|
|
descriptor = descriptor.addingAttributes(attributes)
|
2020-04-30 18:50:53 +02:00
|
|
|
symbolicTraits.insert(boldTrait)
|
2020-04-26 09:23:59 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if styles.contains(.italic) {
|
2020-04-30 18:50:53 +02:00
|
|
|
symbolicTraits.insert(italicTrait)
|
2020-04-26 09:23:59 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if styles.contains(.monospace) {
|
2020-04-30 18:50:53 +02:00
|
|
|
symbolicTraits.insert(monoSpaceTrait)
|
2020-04-26 09:23:59 +02:00
|
|
|
}
|
|
|
|
|
2020-04-30 18:50:53 +02:00
|
|
|
#if canImport(AppKit)
|
2020-04-30 09:34:45 +02:00
|
|
|
descriptor = descriptor.withSymbolicTraits(symbolicTraits)
|
2020-04-30 18:50:53 +02:00
|
|
|
#else
|
|
|
|
descriptor = descriptor.withSymbolicTraits(symbolicTraits)!
|
|
|
|
#endif
|
2020-04-30 09:34:45 +02:00
|
|
|
|
2020-04-26 18:10:59 +02:00
|
|
|
func verticalPositionFeature(forSuperscript: Bool) -> [FontDescriptor.FeatureKey: Any] {
|
2020-04-26 09:23:59 +02:00
|
|
|
#if canImport(AppKit)
|
2020-04-26 18:10:59 +02:00
|
|
|
let features: [FontDescriptor.FeatureKey: Any] = [.typeIdentifier: kVerticalPositionType, .selectorIdentifier: forSuperscript ? kSuperiorsSelector : kInferiorsSelector]
|
2020-04-26 09:23:59 +02:00
|
|
|
#else
|
2020-04-26 18:10:59 +02:00
|
|
|
let features: [FontDescriptor.FeatureKey: Any] = [.featureIdentifier: kVerticalPositionType, .typeIdentifier: forSuperscript ? kSuperiorsSelector : kInferiorsSelector]
|
2020-04-26 09:23:59 +02:00
|
|
|
#endif
|
2020-04-26 18:10:59 +02:00
|
|
|
return features
|
|
|
|
}
|
|
|
|
|
2020-04-30 09:34:45 +02:00
|
|
|
if styles.contains(.superscript) || styles.contains(.subscript) {
|
|
|
|
let features = verticalPositionFeature(forSuperscript: styles.contains(.superscript))
|
2020-04-26 09:23:59 +02:00
|
|
|
let descriptorAttributes: [FontDescriptor.AttributeName: Any] = [.featureSettings: [features]]
|
|
|
|
descriptor = descriptor.addingAttributes(descriptorAttributes)
|
|
|
|
}
|
|
|
|
|
2020-04-30 19:11:52 +02:00
|
|
|
var attributes = [NSAttributedString.Key: Any]()
|
|
|
|
|
2020-04-26 09:23:59 +02:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
2020-04-26 18:11:26 +02:00
|
|
|
|
|
|
|
/// This is a very, very basic implementation that only covers our needs.
|
2020-04-27 01:18:16 +02:00
|
|
|
private struct CountedSet<Element> where Element: Hashable {
|
2020-04-26 18:11:26 +02:00
|
|
|
private var _storage = [Element: Int]()
|
|
|
|
|
|
|
|
mutating func insert(_ element: Element) {
|
|
|
|
_storage[element, default: 0] += 1
|
|
|
|
}
|
|
|
|
|
|
|
|
mutating func remove(_ element: Element) {
|
|
|
|
guard var count = _storage[element] else { return }
|
|
|
|
|
|
|
|
count -= 1
|
|
|
|
|
|
|
|
if count == 0 {
|
|
|
|
_storage.removeValue(forKey: element)
|
|
|
|
} else {
|
|
|
|
_storage[element] = count
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func contains(_ element: Element) -> Bool {
|
|
|
|
return _storage[element] != nil
|
|
|
|
}
|
|
|
|
|
2020-04-30 11:33:15 +02:00
|
|
|
subscript(key: Element) -> Int {
|
2020-04-26 18:11:26 +02:00
|
|
|
get {
|
2020-04-30 11:33:15 +02:00
|
|
|
return _storage[key, default: 0]
|
2020-04-26 18:11:26 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2020-04-27 01:17:42 +02:00
|
|
|
|
2020-04-27 01:18:16 +02:00
|
|
|
private extension String {
|
2020-04-27 01:17:42 +02:00
|
|
|
var decodedEntity: String {
|
2020-05-01 08:42:17 +02:00
|
|
|
// It's possible the implementation will change, but for now it just calls this.
|
2020-04-27 01:17:42 +02:00
|
|
|
(self as NSString).rsparser_stringByDecodingHTMLEntities() as String
|
|
|
|
}
|
|
|
|
}
|