From fd672c5ce6067391c88f2cd6e6571abd7756f0e5 Mon Sep 17 00:00:00 2001 From: Nate Weaver Date: Sun, 26 Apr 2020 02:23:59 -0500 Subject: [PATCH] Add a custom HTML -> NSAttributedString initializer --- .../NSAttributedString+NetNewsWire.swift | 330 ++++++++++++++++++ submodules/RSCore | 2 +- submodules/RSDatabase | 2 +- submodules/RSParser | 2 +- submodules/RSTree | 2 +- submodules/RSWeb | 2 +- 6 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 Shared/Extensions/NSAttributedString+NetNewsWire.swift diff --git a/Shared/Extensions/NSAttributedString+NetNewsWire.swift b/Shared/Extensions/NSAttributedString+NetNewsWire.swift new file mode 100644 index 000000000..0b907fc35 --- /dev/null +++ b/Shared/Extensions/NSAttributedString+NetNewsWire.swift @@ -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) 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 `` 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