diff --git a/Frameworks/Articles/Article.swift b/Frameworks/Articles/Article.swift index 6e13633cc..d2fbdda77 100644 --- a/Frameworks/Articles/Article.swift +++ b/Frameworks/Articles/Article.swift @@ -86,3 +86,38 @@ public extension Array where Element == Article { return map { $0.articleID } } } + +public extension Article { + static let allowedTags: Set = ["b", "bdi", "bdo", "cite", "code", "del", "dfn", "em", "i", "ins", "kbd", "mark", "q", /* "rb", "rp", "rt", "rtc", "ruby", */ "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 + } + +} diff --git a/Frameworks/Articles/xcconfig/Articles_project.xcconfig b/Frameworks/Articles/xcconfig/Articles_project.xcconfig index 573284972..d3bd6c6de 100644 --- a/Frameworks/Articles/xcconfig/Articles_project.xcconfig +++ b/Frameworks/Articles/xcconfig/Articles_project.xcconfig @@ -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 diff --git a/Frameworks/ArticlesDatabase/xcconfig/ArticlesDatabase_project.xcconfig b/Frameworks/ArticlesDatabase/xcconfig/ArticlesDatabase_project.xcconfig index 573284972..d3bd6c6de 100644 --- a/Frameworks/ArticlesDatabase/xcconfig/ArticlesDatabase_project.xcconfig +++ b/Frameworks/ArticlesDatabase/xcconfig/ArticlesDatabase_project.xcconfig @@ -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 diff --git a/Frameworks/SyncDatabase/xcconfig/SyncDatabase_project.xcconfig b/Frameworks/SyncDatabase/xcconfig/SyncDatabase_project.xcconfig index 09f243ba8..d3bd6c6de 100644 --- a/Frameworks/SyncDatabase/xcconfig/SyncDatabase_project.xcconfig +++ b/Frameworks/SyncDatabase/xcconfig/SyncDatabase_project.xcconfig @@ -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 = diff --git a/Mac/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift b/Mac/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift index d5b979d25..de75fc46e 100644 --- a/Mac/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift +++ b/Mac/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift @@ -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 diff --git a/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift b/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift index b6ca525bc..1e92a7714 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift @@ -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() } } diff --git a/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift b/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift index 07bb08c9b..df4fb42b2 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift @@ -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 diff --git a/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift b/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift index eb1e8a79e..d0b9a7802 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift @@ -221,7 +221,7 @@ private extension TimelineTableCellView { func updateTitleView() { - updateTextFieldText(titleView, cellData?.title) + updateTextFieldAttributedText(titleView, cellData?.attributedTitle) } func updateSummaryView() { @@ -247,6 +247,19 @@ private extension TimelineTableCellView { } } + func updateTextFieldAttributedText(_ textField: NSTextField, _ text: NSAttributedString?) { + var s = text ?? NSAttributedString(string: "") + + if let fieldFont = textField.font, let color = textField.textColor { + s = s.adding(font: fieldFont, color: color) + } + + if textField.attributedStringValue != s { + textField.attributedStringValue = s + needsLayout = true + } + } + func updateFeedNameView() { switch cellData.showFeedName { case .byline: diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 599217fad..96d0e8804 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -723,12 +723,15 @@ 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 */; }; + B26B9572243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B26B9571243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift */; }; + B26B9573243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B26B9571243D176B0053EEF5 /* 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 */; }; B2B8075E239C49D300F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; B2B80778239C4C7000F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; B2B80779239C4C7300F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; + B2C0FDEA2447A69100ADC150 /* NSAttributedString+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B26B9571243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift */; }; B528F81E23333C7E00E735DD /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = B528F81D23333C7E00E735DD /* page.html */; }; BDCB516724282C8A00102A80 /* AccountsNewsBlur.xib in Resources */ = {isa = PBXBuildFile; fileRef = BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */; }; BDCB516824282C8A00102A80 /* AccountsNewsBlur.xib in Resources */ = {isa = PBXBuildFile; fileRef = BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */; }; @@ -1257,6 +1260,13 @@ remoteGlobalIDString = 84F22C541B52E0D9000060CE; remoteInfo = RSDatabase; }; + B234554A2453AB0F000F1D7F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 517A754424451BD500B553B9 /* OAuthSwift.xcodeproj */; + proxyType = 1; + remoteGlobalIDString = C48B28011AFA598D00C7DEF6; + remoteInfo = OAuthSwiftMacOS; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -1782,6 +1792,7 @@ 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconURLFinder.swift; sourceTree = ""; }; B24EFD482330FF99006C6242 /* NetNewsWire-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NetNewsWire-Bridging-Header.h"; sourceTree = ""; }; B24EFD5923310109006C6242 /* WKPreferencesPrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WKPreferencesPrivate.h; sourceTree = ""; }; + B26B9571243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+NetNewsWire.swift"; sourceTree = ""; }; B27EEBDF244D15F2000932E6 /* shared.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = shared.css; sourceTree = ""; }; B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RSImage-AppIcons.swift"; sourceTree = ""; }; B528F81D23333C7E00E735DD /* page.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = page.html; sourceTree = ""; }; @@ -2576,6 +2587,7 @@ B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */, 51126DA3225FDE2F00722696 /* RSImage-Extensions.swift */, 84411E701FE5FBFA004B527F /* SmallIconProvider.swift */, + B26B9571243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift */, ); path = Extensions; sourceTree = ""; @@ -3245,6 +3257,7 @@ 84C37FB020DD8D9900CA8CF5 /* PBXTargetDependency */, 84C37FB820DD8DBB00CA8CF5 /* PBXTargetDependency */, 84C37FC820DD8E1D00CA8CF5 /* PBXTargetDependency */, + B234554B2453AB0F000F1D7F /* PBXTargetDependency */, 51C451AC226377C300C03939 /* PBXTargetDependency */, 51C451BC226377C900C03939 /* PBXTargetDependency */, 51C451C0226377D000C03939 /* PBXTargetDependency */, @@ -3291,36 +3304,36 @@ TargetAttributes = { 51314636235A7BBE00387FDC = { CreatedOnToolsVersion = 11.2; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M72QZ9W58G; LastSwiftMigration = 1120; ProvisioningStyle = Automatic; }; 513C5CE5232571C2003D4054 = { CreatedOnToolsVersion = 11.0; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M72QZ9W58G; ProvisioningStyle = Automatic; }; 518B2ED12351B3DD00400001 = { CreatedOnToolsVersion = 11.2; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M72QZ9W58G; ProvisioningStyle = Automatic; TestTargetID = 840D617B2029031C009BC708; }; 6581C73220CED60000F4AD34 = { - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M72QZ9W58G; ProvisioningStyle = Automatic; }; 65ED3FA2235DEF6C0081F399 = { - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M72QZ9W58G; ProvisioningStyle = Automatic; }; 65ED4090235DEF770081F399 = { - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M72QZ9W58G; ProvisioningStyle = Automatic; }; 840D617B2029031C009BC708 = { CreatedOnToolsVersion = 9.3; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M72QZ9W58G; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.BackgroundModes = { @@ -3330,7 +3343,7 @@ }; 849C645F1ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M72QZ9W58G; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.HardenedRuntime = { @@ -3340,7 +3353,7 @@ }; 849C64701ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M72QZ9W58G; ProvisioningStyle = Automatic; TestTargetID = 849C645F1ED37A5D003D8FC0; }; @@ -4204,6 +4217,7 @@ 65ED4023235DEF6C0081F399 /* Folder+Scriptability.swift in Sources */, 65ED4024235DEF6C0081F399 /* TimelineCellLayout.swift in Sources */, 65ED4025235DEF6C0081F399 /* DetailWebView.swift in Sources */, + B26B9573243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift in Sources */, B2B80779239C4C7300F191E0 /* RSImage-AppIcons.swift in Sources */, 65ED4026235DEF6C0081F399 /* TimelineTableRowView.swift in Sources */, 65ED4027235DEF6C0081F399 /* UnreadIndicatorView.swift in Sources */, @@ -4300,6 +4314,7 @@ 51F9F3F923DFB16300A314FD /* UITableView-Extensions.swift in Sources */, 51C452792265091600C03939 /* MasterTimelineTableViewCell.swift in Sources */, 51C4526B226508F600C03939 /* MasterFeedViewController.swift in Sources */, + B2C0FDEA2447A69100ADC150 /* NSAttributedString+NetNewsWire.swift in Sources */, 5126EE97226CB48A00C22AFC /* SceneCoordinator.swift in Sources */, 84CAFCB022BC8C35007694F0 /* FetchRequestOperation.swift in Sources */, 51EF0F77227716200050506E /* FaviconGenerator.swift in Sources */, @@ -4539,6 +4554,7 @@ 8426119E1FCB6ED40086A189 /* HTMLMetadataDownloader.swift in Sources */, 849A976E1ED9EBC8007D329B /* TimelineViewController.swift in Sources */, 3B826DCC2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */, + B26B9572243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift in Sources */, 5154368B229404D1005E1CDF /* FaviconGenerator.swift in Sources */, 5183CCE6226F4E110010922C /* RefreshInterval.swift in Sources */, 849A97771ED9EC04007D329B /* TimelineCellData.swift in Sources */, @@ -4753,6 +4769,11 @@ name = RSDatabase; targetProxy = 84C37FC720DD8E1D00CA8CF5 /* PBXContainerItemProxy */; }; + B234554B2453AB0F000F1D7F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = OAuthSwiftMacOS; + targetProxy = B234554A2453AB0F000F1D7F /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ diff --git a/Shared/Article Rendering/ArticleRenderer.swift b/Shared/Article Rendering/ArticleRenderer.swift index ef7a25a52..3588615bd 100644 --- a/Shared/Article Rendering/ArticleRenderer.swift +++ b/Shared/Article Rendering/ArticleRenderer.swift @@ -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 diff --git a/Shared/Extensions/ArticleStringFormatter.swift b/Shared/Extensions/ArticleStringFormatter.swift index 4fb9249ae..dac926b34 100644 --- a/Shared/Extensions/ArticleStringFormatter.swift +++ b/Shared/Extensions/ArticleStringFormatter.swift @@ -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 "" diff --git a/Shared/Extensions/NSAttributedString+NetNewsWire.swift b/Shared/Extensions/NSAttributedString+NetNewsWire.swift new file mode 100644 index 000000000..afd4fada9 --- /dev/null +++ b/Shared/Extensions/NSAttributedString+NetNewsWire.swift @@ -0,0 +1,134 @@ +// +// 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. + /// + /// Additionally converts super-/subscript runs to super-/subscripted font variants + /// and converts bold text to heavy if the base font is semibold. + /// + /// - 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 + + if symbolicTraits.contains(italicTrait) { + newSymbolicTraits.insert(italicTrait) + } + + if symbolicTraits.contains(monoSpaceTrait) { + newSymbolicTraits.insert(monoSpaceTrait) + } + + #if canImport(AppKit) + var descriptor = baseDescriptor.withSymbolicTraits(newSymbolicTraits) + #else + var descriptor = baseDescriptor.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) + } + + // make sup/sub smaller. `Key("NSSupeScript")` is used here because `.superscript` + // isn't defined in UIKit. + let superscriptAttribute = Key("NSSuperScript") + + mutable.enumerateAttributes(in: fullRange, options: []) { (attributes: [Key : Any], range: NSRange, stop: UnsafeMutablePointer) in + guard let superscript = attributes[superscriptAttribute] as? Int else { + return + } + + if superscript != 0 { + let font = mutable.attribute(.font, at: range.location, effectiveRange: nil) as! Font + + // There's some discrepancy here: The raw value of AppKit's .typeIdentifier is UIKit's .featureIdentifier, + // and AppKit's .selectorIdentifier is UIKit's .typeIdentifier + #if canImport(AppKit) + let features: [FontDescriptor.FeatureKey: Any] = [.typeIdentifier: kVerticalPositionType, .selectorIdentifier: superscript > 0 ? kSuperiorsSelector : kInferiorsSelector] + #else + let features: [FontDescriptor.FeatureKey: Any] = [.featureIdentifier: kVerticalPositionType, .typeIdentifier: superscript > 0 ? kSuperiorsSelector : kInferiorsSelector] + #endif + let attributes: [FontDescriptor.AttributeName: Any] = [.featureSettings: [features]] + let descriptor = font.fontDescriptor.addingAttributes(attributes) + + let newFont = Font(descriptor: descriptor, size: font.pointSize) + + let newAttributes: [NSAttributedString.Key: Any] = [ + .font: newFont as Any, + superscriptAttribute: 0, + ] + + mutable.addAttributes(newAttributes, range: range) + } + } + + return mutable.copy() as! NSAttributedString + } + + /// Creates an attributed string from HTML. + convenience init(html: String) { + let data = html.data(using: .utf8)! + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [.characterEncoding: String.Encoding.utf8.rawValue, .documentType: NSAttributedString.DocumentType.html] + try! self.init(data: data, options: options, documentAttributes: nil) + } + +} diff --git a/iOS/MasterTimeline/Cell/MasterTimelineCellData.swift b/iOS/MasterTimeline/Cell/MasterTimelineCellData.swift index 8996654f8..d1d1c3782 100644 --- a/iOS/MasterTimeline/Cell/MasterTimelineCellData.swift +++ b/iOS/MasterTimeline/Cell/MasterTimelineCellData.swift @@ -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 = "" diff --git a/iOS/MasterTimeline/Cell/MasterTimelineTableViewCell.swift b/iOS/MasterTimeline/Cell/MasterTimelineTableViewCell.swift index 863ee7631..0c2d9d4a5 100644 --- a/iOS/MasterTimeline/Cell/MasterTimelineTableViewCell.swift +++ b/iOS/MasterTimeline/Cell/MasterTimelineTableViewCell.swift @@ -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, let color = label.textColor { + s = s.adding(font: fieldFont, color: color) + } + + if label.attributedText != s { + label.attributedText = s + setNeedsLayout() + } + } func updateFeedNameView() { switch cellData.showFeedName {