// // NSAttributedString+NetNewsWire.swift // NetNewsWire // // Created by Nate Weaver on 2020-04-07. // Copyright © 2020 Ranchero Software. All rights reserved. // import RSParser #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. func adding(font baseFont: Font) -> NSAttributedString { 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 } let currentDescriptor = font.fontDescriptor let symbolicTraits = baseSymbolicTraits.union(currentDescriptor.symbolicTraits) var descriptor = currentDescriptor.addingAttributes(baseDescriptor.fontAttributes) #if canImport(AppKit) descriptor = descriptor.withSymbolicTraits(symbolicTraits) #else descriptor = descriptor.withSymbolicTraits(symbolicTraits)! #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 init?(forTag: String) { switch forTag { case "b", "strong": self = .bold case "i", "em", "cite", "var", "dfn": 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. convenience init(html: String, locale: Locale = Locale.current) { let baseFont = Font.systemFont(ofSize: Font.systemFontSize) var inTag: InTag = .none var tag = "" var currentStyles = CountedSet<Style>() var iterator = html.makeIterator() let result = NSMutableAttributedString() var attributeRanges = [ (range: NSRange, styles: CountedSet<Style>) ]() var quoteDepth = 0 while let char = iterator.next() { if char == "<" && inTag == .none { tag.removeAll() guard let first = iterator.next() else { break } if first == "/" { inTag = .closing } else { inTag = .opening tag.append(first) } } else if char == ">" && inTag != .none { 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 inTag == .opening { if tag == "q" { quoteDepth += 1 let delimiter = quoteDepth % 2 == 1 ? locale.quotationBeginDelimiter : locale.alternateQuotationBeginDelimiter result.mutableString.append(delimiter ?? "\"") } if let style = Style(forTag: tag) { currentStyles.insert(style) } } else { if tag == "q" { let delimiter = quoteDepth % 2 == 1 ? locale.quotationEndDelimiter : locale.alternateQuotationEndDelimiter result.mutableString.append(delimiter ?? "\"") quoteDepth -= 1 } if let style = Style(forTag: tag) { currentStyles.remove(style) } } inTag = .none } else if inTag != .none { tag.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(entity.decodedEntity) 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 { if range.location >= result.length { continue } let currentFont = result.attribute(.font, at: range.location, effectiveRange: nil) as! Font let currentDescriptor = currentFont.fontDescriptor var descriptor = currentDescriptor.copy() as! FontDescriptor var symbolicTraits = currentDescriptor.symbolicTraits if styles.contains(.bold) { let traits: [FontDescriptor.TraitKey: Any] = [.weight: Font.Weight.bold] let attributes: [FontDescriptor.AttributeName: Any] = [.traits: traits] descriptor = descriptor.addingAttributes(attributes) symbolicTraits.insert(boldTrait) } if styles.contains(.italic) { symbolicTraits.insert(italicTrait) } if styles.contains(.monospace) { symbolicTraits.insert(monoSpaceTrait) } #if canImport(AppKit) descriptor = descriptor.withSymbolicTraits(symbolicTraits) #else descriptor = descriptor.withSymbolicTraits(symbolicTraits)! #endif func verticalPositionFeature(forSuperscript: Bool) -> [FontDescriptor.FeatureKey: Any] { #if canImport(AppKit) let features: [FontDescriptor.FeatureKey: Any] = [.typeIdentifier: kVerticalPositionType, .selectorIdentifier: forSuperscript ? kSuperiorsSelector : kInferiorsSelector] #else let features: [FontDescriptor.FeatureKey: Any] = [.type: kVerticalPositionType, .selector: forSuperscript ? kSuperiorsSelector : kInferiorsSelector] #endif return features } if styles.contains(.superscript) || styles.contains(.subscript) { let features = verticalPositionFeature(forSuperscript: styles.contains(.superscript)) let descriptorAttributes: [FontDescriptor.AttributeName: Any] = [.featureSettings: [features]] descriptor = descriptor.addingAttributes(descriptorAttributes) } var attributes = [NSAttributedString.Key: Any]() 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) } } /// This is a very, very basic implementation that only covers our needs. private struct CountedSet<Element> where Element: Hashable { 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 } subscript(key: Element) -> Int { get { return _storage[key, default: 0] } } } private extension String { var decodedEntity: String { // It's possible the implementation will change, but for now it just calls this. (self as NSString).rsparser_stringByDecodingHTMLEntities() as String } }