diff --git a/Frameworks/Articles/Article.swift b/Frameworks/Articles/Article.swift index 855df90bd..5884dd919 100644 --- a/Frameworks/Articles/Article.swift +++ b/Frameworks/Articles/Article.swift @@ -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 + } + +} 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..4dabd647c 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift @@ -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: diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 599217fad..761a47d7b 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -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 = ""; }; 84F9EAE4213660A100CF2DE4 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconURLFinder.swift; sourceTree = ""; }; + B24E9ABA245AB88300DA5718 /* NSAttributedString+NetNewsWire.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+NetNewsWire.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 = ""; }; B27EEBDF244D15F2000932E6 /* shared.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = shared.css; sourceTree = ""; }; @@ -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 */, 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..4037b2489 --- /dev/null +++ b/Shared/Extensions/NSAttributedString+NetNewsWire.swift @@ -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) 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 `` 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