Merge pull request #2035 from Wevah/title-tags
Updated title tags implementation
This commit is contained in:
commit
7696fced21
|
@ -92,3 +92,38 @@ public extension Array where Element == Article {
|
|||
return map { $0.articleID }
|
||||
}
|
||||
}
|
||||
|
||||
public extension Article {
|
||||
private static let allowedTags: Set = ["b", "bdi", "bdo", "cite", "code", "del", "dfn", "em", "i", "ins", "kbd", "mark", "q", "s", "samp", "small", "strong", "sub", "sup", "time", "u", "var"]
|
||||
|
||||
func sanitizedTitle(forHTML: Bool = true) -> String? {
|
||||
guard let title = title else { return nil }
|
||||
|
||||
let scanner = Scanner(string: title)
|
||||
scanner.charactersToBeSkipped = nil
|
||||
var result = ""
|
||||
result.reserveCapacity(title.count)
|
||||
|
||||
while !scanner.isAtEnd {
|
||||
if let text = scanner.scanUpToString("<") {
|
||||
result.append(text)
|
||||
}
|
||||
|
||||
if let _ = scanner.scanString("<") {
|
||||
// All the allowed tags currently don't allow attributes
|
||||
if let tag = scanner.scanUpToString(">") {
|
||||
if Self.allowedTags.contains(tag.replacingOccurrences(of: "/", with: "")) {
|
||||
forHTML ? result.append("<\(tag)>") : result.append("")
|
||||
} else {
|
||||
forHTML ? result.append("<\(tag)>") : result.append("<\(tag)>")
|
||||
}
|
||||
|
||||
let _ = scanner.scanString(">")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ PROVISIONING_PROFILE_SPECIFIER =
|
|||
#include? "../../../SharedXcodeSettings/DeveloperSettings.xcconfig"
|
||||
|
||||
SDKROOT = macosx
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0
|
||||
SUPPORTED_PLATFORMS = macosx iphoneos iphonesimulator
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ PROVISIONING_PROFILE_SPECIFIER =
|
|||
#include? "../../../SharedXcodeSettings/DeveloperSettings.xcconfig"
|
||||
|
||||
SDKROOT = macosx
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0
|
||||
SUPPORTED_PLATFORMS = macosx iphoneos iphonesimulator
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ PROVISIONING_PROFILE_SPECIFIER =
|
|||
#include? "../../../SharedXcodeSettings/DeveloperSettings.xcconfig"
|
||||
|
||||
SDKROOT = macosx
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0
|
||||
SUPPORTED_PLATFORMS = macosx iphoneos iphonesimulator
|
||||
|
||||
|
@ -18,7 +18,6 @@ SWIFT_VERSION = 5.1
|
|||
COMBINE_HIDPI_IMAGES = YES
|
||||
|
||||
COPY_PHASE_STRIP = NO
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14
|
||||
ALWAYS_SEARCH_USER_PATHS = NO
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
VERSION_INFO_PREFIX =
|
||||
|
|
|
@ -34,6 +34,7 @@ final class MultilineTextFieldSizer {
|
|||
private let singleLineHeightEstimate: Int
|
||||
private let doubleLineHeightEstimate: Int
|
||||
private var cache = [String: WidthHeightCache]() // Each string has a cache.
|
||||
private var attributedCache = [NSAttributedString: WidthHeightCache]()
|
||||
private static var sizers = [TextFieldSizerSpecifier: MultilineTextFieldSizer]()
|
||||
|
||||
private init(numberOfLines: Int, font: NSFont) {
|
||||
|
@ -51,6 +52,14 @@ final class MultilineTextFieldSizer {
|
|||
return sizer(numberOfLines: numberOfLines, font: font).sizeInfo(for: string, width: width)
|
||||
}
|
||||
|
||||
static func size(for attributedString: NSAttributedString, numberOfLines: Int, width: Int) -> TextFieldSizeInfo {
|
||||
|
||||
// Assumes the same font family/size for the whole string
|
||||
let font = attributedString.attribute(.font, at: 0, effectiveRange: nil) as! NSFont
|
||||
|
||||
return sizer(numberOfLines: numberOfLines, font: font).sizeInfo(for: attributedString, width: width)
|
||||
}
|
||||
|
||||
static func emptyCache() {
|
||||
|
||||
sizers = [TextFieldSizerSpecifier: MultilineTextFieldSizer]()
|
||||
|
@ -83,6 +92,16 @@ private extension MultilineTextFieldSizer {
|
|||
return sizeInfo
|
||||
}
|
||||
|
||||
func sizeInfo(for attributedString: NSAttributedString, width: Int) -> TextFieldSizeInfo {
|
||||
|
||||
let textFieldHeight = height(for: attributedString, width: width)
|
||||
let numberOfLinesUsed = numberOfLines(for: textFieldHeight)
|
||||
|
||||
let size = NSSize(width: width, height: textFieldHeight)
|
||||
let sizeInfo = TextFieldSizeInfo(size: size, numberOfLinesUsed: numberOfLinesUsed)
|
||||
return sizeInfo
|
||||
}
|
||||
|
||||
func height(for string: String, width: Int) -> Int {
|
||||
|
||||
if cache[string] == nil {
|
||||
|
@ -103,6 +122,26 @@ private extension MultilineTextFieldSizer {
|
|||
return height
|
||||
}
|
||||
|
||||
func height(for attribtuedString: NSAttributedString, width: Int) -> Int {
|
||||
|
||||
if attributedCache[attribtuedString] == nil {
|
||||
attributedCache[attribtuedString] = WidthHeightCache()
|
||||
}
|
||||
|
||||
if let height = attributedCache[attribtuedString]![width] {
|
||||
return height
|
||||
}
|
||||
|
||||
if let height = heightConsideringNeighbors(attributedCache[attribtuedString]!, width) {
|
||||
return height
|
||||
}
|
||||
|
||||
let height = calculateHeight(attribtuedString, width)
|
||||
attributedCache[attribtuedString]![width] = height
|
||||
|
||||
return height
|
||||
}
|
||||
|
||||
static func createTextField(_ numberOfLines: Int, _ font: NSFont) -> NSTextField {
|
||||
|
||||
let textField = NSTextField(wrappingLabelWithString: "")
|
||||
|
@ -120,6 +159,14 @@ private extension MultilineTextFieldSizer {
|
|||
return MultilineTextFieldSizer.calculateHeight(string, width, textField)
|
||||
}
|
||||
|
||||
func calculateHeight(_ attributedString: NSAttributedString, _ width: Int) -> Int {
|
||||
|
||||
textField.attributedStringValue = attributedString
|
||||
textField.preferredMaxLayoutWidth = CGFloat(width)
|
||||
let size = textField.fittingSize
|
||||
return Int(ceil(size.height))
|
||||
}
|
||||
|
||||
static func calculateHeight(_ string: String, _ width: Int, _ textField: NSTextField) -> Int {
|
||||
|
||||
textField.stringValue = string
|
||||
|
|
|
@ -12,6 +12,7 @@ import Articles
|
|||
struct TimelineCellData {
|
||||
|
||||
let title: String
|
||||
let attributedTitle: NSAttributedString
|
||||
let text: String
|
||||
let dateString: String
|
||||
let feedName: String
|
||||
|
@ -26,6 +27,7 @@ struct TimelineCellData {
|
|||
init(article: Article, showFeedName: TimelineShowFeedName, feedName: String?, byline: String?, iconImage: IconImage?, showIcon: Bool, featuredImage: NSImage?) {
|
||||
|
||||
self.title = ArticleStringFormatter.truncatedTitle(article)
|
||||
self.attributedTitle = ArticleStringFormatter.attributedTruncatedTitle(article)
|
||||
self.text = ArticleStringFormatter.truncatedSummary(article)
|
||||
|
||||
self.dateString = ArticleStringFormatter.dateString(article.logicalDatePublished)
|
||||
|
@ -64,5 +66,6 @@ struct TimelineCellData {
|
|||
self.featuredImage = nil
|
||||
self.read = true
|
||||
self.starred = false
|
||||
self.attributedTitle = NSAttributedString()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -115,7 +115,8 @@ private extension TimelineCellLayout {
|
|||
return (r, 0)
|
||||
}
|
||||
|
||||
let sizeInfo = MultilineTextFieldSizer.size(for: cellData.title, font: appearance.titleFont, numberOfLines: appearance.titleNumberOfLines, width: Int(textBoxRect.width))
|
||||
let attributedTitle = cellData.attributedTitle.adding(font: appearance.titleFont)
|
||||
let sizeInfo = MultilineTextFieldSizer.size(for: attributedTitle, numberOfLines: appearance.titleNumberOfLines, width: Int(textBoxRect.width))
|
||||
r.size.height = sizeInfo.size.height
|
||||
if sizeInfo.numberOfLinesUsed < 1 {
|
||||
r.size.height = 0
|
||||
|
|
|
@ -222,6 +222,7 @@ private extension TimelineTableCellView {
|
|||
func updateTitleView() {
|
||||
|
||||
updateTextFieldText(titleView, cellData?.title)
|
||||
updateTextFieldAttributedText(titleView, cellData?.attributedTitle)
|
||||
}
|
||||
|
||||
func updateSummaryView() {
|
||||
|
@ -247,6 +248,19 @@ private extension TimelineTableCellView {
|
|||
}
|
||||
}
|
||||
|
||||
func updateTextFieldAttributedText(_ textField: NSTextField, _ text: NSAttributedString?) {
|
||||
var s = text ?? NSAttributedString(string: "")
|
||||
|
||||
if let fieldFont = textField.font {
|
||||
s = s.adding(font: fieldFont)
|
||||
}
|
||||
|
||||
if textField.attributedStringValue != s {
|
||||
textField.attributedStringValue = s
|
||||
needsLayout = true
|
||||
}
|
||||
}
|
||||
|
||||
func updateFeedNameView() {
|
||||
switch cellData.showFeedName {
|
||||
case .byline:
|
||||
|
|
|
@ -723,6 +723,9 @@
|
|||
84F9EAF4213660A100CF2DE4 /* testGenericScript.applescript in Sources */ = {isa = PBXBuildFile; fileRef = 84F9EAE1213660A100CF2DE4 /* testGenericScript.applescript */; };
|
||||
84F9EAF5213660A100CF2DE4 /* establishMainWindowStartingState.applescript in Sources */ = {isa = PBXBuildFile; fileRef = 84F9EAE2213660A100CF2DE4 /* establishMainWindowStartingState.applescript */; };
|
||||
84FF69B11FC3793300DC198E /* FaviconURLFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */; };
|
||||
B24E9ADC245AB88400DA5718 /* NSAttributedString+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24E9ABA245AB88300DA5718 /* NSAttributedString+NetNewsWire.swift */; };
|
||||
B24E9ADD245AB88400DA5718 /* NSAttributedString+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24E9ABA245AB88300DA5718 /* NSAttributedString+NetNewsWire.swift */; };
|
||||
B24E9ADE245AB88400DA5718 /* NSAttributedString+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24E9ABA245AB88300DA5718 /* NSAttributedString+NetNewsWire.swift */; };
|
||||
B27EEBF9244D15F3000932E6 /* shared.css in Resources */ = {isa = PBXBuildFile; fileRef = B27EEBDF244D15F2000932E6 /* shared.css */; };
|
||||
B27EEBFA244D15F3000932E6 /* shared.css in Resources */ = {isa = PBXBuildFile; fileRef = B27EEBDF244D15F2000932E6 /* shared.css */; };
|
||||
B27EEBFB244D15F3000932E6 /* shared.css in Resources */ = {isa = PBXBuildFile; fileRef = B27EEBDF244D15F2000932E6 /* shared.css */; };
|
||||
|
@ -1780,6 +1783,7 @@
|
|||
84F9EAE2213660A100CF2DE4 /* establishMainWindowStartingState.applescript */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.applescript; path = establishMainWindowStartingState.applescript; sourceTree = "<group>"; };
|
||||
84F9EAE4213660A100CF2DE4 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconURLFinder.swift; sourceTree = "<group>"; };
|
||||
B24E9ABA245AB88300DA5718 /* NSAttributedString+NetNewsWire.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+NetNewsWire.swift"; sourceTree = "<group>"; };
|
||||
B24EFD482330FF99006C6242 /* NetNewsWire-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NetNewsWire-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
B24EFD5923310109006C6242 /* WKPreferencesPrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WKPreferencesPrivate.h; sourceTree = "<group>"; };
|
||||
B27EEBDF244D15F2000932E6 /* shared.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = shared.css; sourceTree = "<group>"; };
|
||||
|
@ -2566,6 +2570,7 @@
|
|||
849A97561ED9EB0D007D329B /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B24E9ABA245AB88300DA5718 /* NSAttributedString+NetNewsWire.swift */,
|
||||
51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */,
|
||||
849A97731ED9EC04007D329B /* ArticleStringFormatter.swift */,
|
||||
849A97581ED9EB0D007D329B /* ArticleUtilities.swift */,
|
||||
|
@ -4129,6 +4134,7 @@
|
|||
65ED3FDD235DEF6C0081F399 /* ActivityType.swift in Sources */,
|
||||
65ED3FDE235DEF6C0081F399 /* CrashReportWindowController.swift in Sources */,
|
||||
65ED3FDF235DEF6C0081F399 /* WebFeedIconDownloader.swift in Sources */,
|
||||
B24E9ADD245AB88400DA5718 /* NSAttributedString+NetNewsWire.swift in Sources */,
|
||||
65ED3FE0235DEF6C0081F399 /* PreferencesControlsBackgroundView.swift in Sources */,
|
||||
65ED3FE1235DEF6C0081F399 /* MarkCommandValidationStatus.swift in Sources */,
|
||||
65ED3FE2235DEF6C0081F399 /* ArticlePasteboardWriter.swift in Sources */,
|
||||
|
@ -4385,6 +4391,7 @@
|
|||
514219372352510100E07E2C /* ImageScrollView.swift in Sources */,
|
||||
516AE9B32371C372007DEEAA /* MasterFeedTableViewSectionHeaderLayout.swift in Sources */,
|
||||
51DC370B2405BC9A0095D371 /* PreloadedWebView.swift in Sources */,
|
||||
B24E9ADE245AB88400DA5718 /* NSAttributedString+NetNewsWire.swift in Sources */,
|
||||
C5A6ED5223C9AF4300AB6BE2 /* TitleActivityItemSource.swift in Sources */,
|
||||
51DC37092402F1470095D371 /* MasterFeedDataSourceOperation.swift in Sources */,
|
||||
51C4529B22650A1000C03939 /* FaviconDownloader.swift in Sources */,
|
||||
|
@ -4494,6 +4501,7 @@
|
|||
FF3ABF1523259DDB0074C542 /* ArticleSorter.swift in Sources */,
|
||||
84E8E0DB202EC49300562D8F /* TimelineViewController+ContextualMenus.swift in Sources */,
|
||||
849A97791ED9EC04007D329B /* ArticleStringFormatter.swift in Sources */,
|
||||
B24E9ADC245AB88400DA5718 /* NSAttributedString+NetNewsWire.swift in Sources */,
|
||||
84E185C3203BB12600F69BFA /* MultilineTextFieldSizer.swift in Sources */,
|
||||
8477ACBE22238E9500DF7F37 /* SearchFeedDelegate.swift in Sources */,
|
||||
51E3EB33229AB02C00645299 /* ErrorHandler.swift in Sources */,
|
||||
|
|
|
@ -46,7 +46,7 @@ struct ArticleRenderer {
|
|||
self.article = article
|
||||
self.extractedArticle = extractedArticle
|
||||
self.articleStyle = style
|
||||
self.title = article?.title ?? ""
|
||||
self.title = article?.sanitizedTitle() ?? ""
|
||||
if let content = extractedArticle?.content {
|
||||
self.body = content
|
||||
self.baseURL = extractedArticle?.url
|
||||
|
|
|
@ -52,8 +52,8 @@ struct ArticleStringFormatter {
|
|||
return s
|
||||
}
|
||||
|
||||
static func truncatedTitle(_ article: Article) -> String {
|
||||
guard let title = article.title else {
|
||||
static func truncatedTitle(_ article: Article, forHTML: Bool = false) -> String {
|
||||
guard let title = article.sanitizedTitle(forHTML: forHTML) else {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
@ -64,7 +64,11 @@ struct ArticleStringFormatter {
|
|||
var s = title.replacingOccurrences(of: "\n", with: "")
|
||||
s = s.replacingOccurrences(of: "\r", with: "")
|
||||
s = s.replacingOccurrences(of: "\t", with: "")
|
||||
s = s.rsparser_stringByDecodingHTMLEntities()
|
||||
|
||||
if !forHTML {
|
||||
s = s.rsparser_stringByDecodingHTMLEntities()
|
||||
}
|
||||
|
||||
s = s.trimmingWhitespace
|
||||
s = s.collapsingWhitespace
|
||||
|
||||
|
@ -79,6 +83,12 @@ struct ArticleStringFormatter {
|
|||
return s
|
||||
}
|
||||
|
||||
static func attributedTruncatedTitle(_ article: Article) -> NSAttributedString {
|
||||
let title = truncatedTitle(article, forHTML: true)
|
||||
let attributed = NSAttributedString(html: title)
|
||||
return attributed
|
||||
}
|
||||
|
||||
static func truncatedSummary(_ article: Article) -> String {
|
||||
guard let body = article.body else {
|
||||
return ""
|
||||
|
|
|
@ -0,0 +1,294 @@
|
|||
//
|
||||
// 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
|
||||
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.
|
||||
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 {
|
||||
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] = [.featureIdentifier: kVerticalPositionType, .typeIdentifier: 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
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ import Articles
|
|||
struct MasterTimelineCellData {
|
||||
|
||||
let title: String
|
||||
let attributedTitle: NSAttributedString
|
||||
let summary: String
|
||||
let dateString: String
|
||||
let feedName: String
|
||||
|
@ -28,6 +29,7 @@ struct MasterTimelineCellData {
|
|||
init(article: Article, showFeedName: ShowFeedName, feedName: String?, byline: String?, iconImage: IconImage?, showIcon: Bool, featuredImage: UIImage?, numberOfLines: Int, iconSize: IconSize) {
|
||||
|
||||
self.title = ArticleStringFormatter.truncatedTitle(article)
|
||||
self.attributedTitle = ArticleStringFormatter.attributedTruncatedTitle(article)
|
||||
self.summary = ArticleStringFormatter.truncatedSummary(article)
|
||||
|
||||
self.dateString = ArticleStringFormatter.dateString(article.logicalDatePublished)
|
||||
|
@ -60,6 +62,7 @@ struct MasterTimelineCellData {
|
|||
|
||||
init() { //Empty
|
||||
self.title = ""
|
||||
self.attributedTitle = NSAttributedString()
|
||||
self.summary = ""
|
||||
self.dateString = ""
|
||||
self.feedName = ""
|
||||
|
|
|
@ -148,7 +148,7 @@ private extension MasterTimelineTableViewCell {
|
|||
func updateTitleView() {
|
||||
titleView.font = MasterTimelineDefaultCellLayout.titleFont
|
||||
titleView.textColor = labelColor
|
||||
updateTextFieldText(titleView, cellData?.title)
|
||||
updateTextFieldAttributedText(titleView, cellData?.attributedTitle)
|
||||
}
|
||||
|
||||
func updateSummaryView() {
|
||||
|
@ -170,6 +170,19 @@ private extension MasterTimelineTableViewCell {
|
|||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
func updateTextFieldAttributedText(_ label: UILabel, _ text: NSAttributedString?) {
|
||||
var s = text ?? NSAttributedString(string: "")
|
||||
|
||||
if let fieldFont = label.font {
|
||||
s = s.adding(font: fieldFont)
|
||||
}
|
||||
|
||||
if label.attributedText != s {
|
||||
label.attributedText = s
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
func updateFeedNameView() {
|
||||
switch cellData.showFeedName {
|
||||
|
|
|
@ -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…
Reference in New Issue