From e9db3fa0acfc03fbf7a1c74700ea1afcabb1dddd Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 21 Jan 2020 21:21:23 -0500 Subject: [PATCH] Collapse whitespace according to CSS spec after converting HTML to attributed string Fixes #27 --- Tusker.xcodeproj/project.pbxproj | 4 + .../Extensions/AttributedString+Helpers.swift | 79 +++++++++++++++++-- Tusker/Views/ContentTextView.swift | 3 +- TuskerTests/AttributedStringHelperTests.swift | 46 +++++++++++ 4 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 TuskerTests/AttributedStringHelperTests.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 59fd6bd7f4..60b99cc37d 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -104,6 +104,7 @@ D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2421217AA7E1005076CC /* UserActivityManager.swift */; }; D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */; }; D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2425217ABF63005076CC /* UserActivityType.swift */; }; + D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */; }; D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; }; D6333B772138D94E00CE884A /* ComposeMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B762138D94E00CE884A /* ComposeMediaView.swift */; }; D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; }; @@ -376,6 +377,7 @@ D62D2421217AA7E1005076CC /* UserActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityManager.swift; sourceTree = ""; }; D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSUserActivity+Extensions.swift"; sourceTree = ""; }; D62D2425217ABF63005076CC /* UserActivityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityType.swift; sourceTree = ""; }; + D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringHelperTests.swift; sourceTree = ""; }; D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = ""; }; D6333B762138D94E00CE884A /* ComposeMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeMediaView.swift; sourceTree = ""; }; D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = ""; }; @@ -1206,6 +1208,7 @@ isa = PBXGroup; children = ( D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */, + D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */, D6D4DDE6212518A200E1C4BB /* Info.plist */, ); path = TuskerTests; @@ -1743,6 +1746,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */, D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Tusker/Extensions/AttributedString+Helpers.swift b/Tusker/Extensions/AttributedString+Helpers.swift index 6ad67cdc35..9c24469571 100644 --- a/Tusker/Extensions/AttributedString+Helpers.swift +++ b/Tusker/Extensions/AttributedString+Helpers.swift @@ -8,28 +8,95 @@ import Foundation -extension NSMutableAttributedString { - +private let ASCII_NEWLINE: unichar = 10 +private let ASCII_SPACE: unichar = 32 + +extension NSAttributedString { + var fullRange: NSRange { return NSRange(location: 0, length: self.length) } - + + /// Creates a new string with the whitespace collapsed according to the CSS Text Module Level 3 rules. + /// See https://www.w3.org/TR/css-text-3/#white-space-phase-1 + func collapsingWhitespace() -> NSAttributedString { + let mut = NSMutableAttributedString(attributedString: self) + mut.collapseWhitespace() + return mut + } + +} + +extension NSMutableAttributedString { + func trimLeadingCharactersInSet(_ charSet: CharacterSet) { var range = (string as NSString).rangeOfCharacter(from: charSet) - + while range.length != 0 && range.location == 0 { replaceCharacters(in: range, with: "") range = (string as NSString).rangeOfCharacter(from: charSet) } } - + func trimTrailingCharactersInSet(_ charSet: CharacterSet) { var range = (string as NSString).rangeOfCharacter(from: charSet, options: .backwards) - + while range.length != 0 && range.length + range.location == length { replaceCharacters(in: range, with: "") range = (string as NSString).rangeOfCharacter(from: charSet, options: .backwards) } } + + /// Collapses whitespace in this string according to the CSS Text Module Level 3 rules. + /// See https://www.w3.org/TR/css-text-3/#white-space-phase-1 + func collapseWhitespace() { + let str = self.mutableString + + var i = 0 + while i < str.length { + if str.character(at: i) == ASCII_NEWLINE { + var j: Int + if i > 0 { + // scan backwards to find beginning of space characters preceeding newline + j = i - 1 + while j >= 0 { + if str.character(at: j) != ASCII_SPACE { + break + } + j -= 1 + } + // add one after loop completes because start of range is _inclusive_ + j += 1 + } else { + j = 0 + } + + var k: Int + if i < str.length - 1 { + // scan forwards to find end of space characters following newline + k = i + 1 + while k < str.length { + if str.character(at: k) != ASCII_SPACE { + break + } + k += 1 + } + // don't need to subtract one before breaking out of loop, because end of range is _exclusive_ + } else { + // range end is _exclusive_, so use whole string length that way last character is included + k = str.length + } + + // if there's only one character to be replaced, that means we'd be replacing the newline with a newline, so don't bother + if k - j > 1 { + str.replaceCharacters(in: NSRange(location: j, length: k - j), with: "\n") + + // continue scanning through the string starting after the newline we just inserted + i = j + } + } + i += 1 + } + } } diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index 2a923fae83..5d63f721fd 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -109,11 +109,12 @@ class ContentTextView: LinkTextView { let attributedText = attributedTextForHTMLNode(body) let mutAttrString = NSMutableAttributedString(attributedString: attributedText) mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines) + mutAttrString.collapseWhitespace() self.attributedText = mutAttrString } - func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString { + private func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString { switch node { case let node as TextNode: let text: String diff --git a/TuskerTests/AttributedStringHelperTests.swift b/TuskerTests/AttributedStringHelperTests.swift new file mode 100644 index 0000000000..5def79467e --- /dev/null +++ b/TuskerTests/AttributedStringHelperTests.swift @@ -0,0 +1,46 @@ +// +// AttributedStringHelperTests.swift +// TuskerTests +// +// Created by Shadowfacts on 1/21/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import XCTest +@testable import Tusker + +class AttributedStringHelperTests: XCTestCase { + + override func setUp() { + } + + override func tearDown() { + } + + func testCollapsingWhitespace() { + var str = NSAttributedString(string: "test 1\n") + XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "test 1\n")) + + str = NSAttributedString(string: "test 2 \n") + XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "test 2\n")) + + str = NSAttributedString(string: "test 3\n ") + XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "test 3\n")) + + str = NSAttributedString(string: "test 4 \n ") + XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "test 4\n")) + + str = NSAttributedString(string: "test 5 \n blah") + XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "test 5\nblah")) + + str = NSAttributedString(string: "\ntest 6") + XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "\ntest 6")) + + str = NSAttributedString(string: " \ntest 7") + XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "\ntest 7")) + + str = NSAttributedString(string: " \n test 8") + XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "\ntest 8")) + } + +}