diff --git a/.swiftlint.yml b/.swiftlint.yml index 8d9ef1eb50c7..ec1bd3e0d268 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -119,3 +119,9 @@ custom_rules: message: "Using `LocalizedStringKey` is incompatible with our tooling and doesn't allow you to provide a hint/context comment for translators either. Please use `NSLocalizedString` instead, even with SwiftUI code." severity: error excluded: '.*Widgets/.*' + + full_range_attributed_string_attribute: + name: "Full-Range Attributed String Attribute" + regex: '\.addAttributes?\([^\n]*range:\s*NS(MakeRange\(0,|Range\(location:\s*0,\s*length:)' + message: "Use `applyAttribute(_:value:)` or `applyAttributes(_:)` instead of manually constructing a full-range NSRange. Manual ranges are error-prone with emoji and other multi-code-unit characters." + severity: warning diff --git a/Modules/Sources/FormattableContentKit/Ranges/FormattableNoticonRange.swift b/Modules/Sources/FormattableContentKit/Ranges/FormattableNoticonRange.swift index f36bf68e87d3..fed0fafcd7f8 100644 --- a/Modules/Sources/FormattableContentKit/Ranges/FormattableNoticonRange.swift +++ b/Modules/Sources/FormattableContentKit/Ranges/FormattableNoticonRange.swift @@ -21,10 +21,10 @@ public class FormattableNoticonRange: FormattableContentRange { let shiftedRange = rangeShifted(by: shift) insertIcon(to: string, at: shiftedRange) - let longerRange = NSMakeRange(shiftedRange.location, shiftedRange.length + noticon.count) + let longerRange = NSMakeRange(shiftedRange.location, shiftedRange.length + noticon.utf16.count) apply(styles, to: string, at: longerRange) - return noticon.count + return noticon.utf16.count } func insertIcon(to string: NSMutableAttributedString, at shiftedRange: NSRange) { diff --git a/Modules/Sources/WordPressKit/NonceRetrieval.swift b/Modules/Sources/WordPressKit/NonceRetrieval.swift index 14e13b15d338..33f33c41143f 100644 --- a/Modules/Sources/WordPressKit/NonceRetrieval.swift +++ b/Modules/Sources/WordPressKit/NonceRetrieval.swift @@ -48,7 +48,7 @@ enum NonceRetrievalMethod { private func scrapNonceFromNewPost(html: String) -> String? { guard let regex = try? NSRegularExpression(pattern: "apiFetch.createNonceMiddleware\\(\\s*['\"](?\\w+)['\"]\\s*\\)", options: []), - let match = regex.firstMatch(in: html, options: [], range: NSRange(location: 0, length: html.count)) else { + let match = regex.firstMatch(in: html, options: [], range: NSRange(location: 0, length: html.utf16.count)) else { return nil } let nsrange = match.range(withName: "nonce") diff --git a/Modules/Sources/WordPressKit/WordPressOrgXMLRPCValidator.swift b/Modules/Sources/WordPressKit/WordPressOrgXMLRPCValidator.swift index 84e9b65dd5e6..e0f1b276d2e4 100644 --- a/Modules/Sources/WordPressKit/WordPressOrgXMLRPCValidator.swift +++ b/Modules/Sources/WordPressKit/WordPressOrgXMLRPCValidator.swift @@ -317,7 +317,7 @@ open class WordPressOrgXMLRPCValidator: NSObject { let matches = rsdURLRegExp.matches(in: html, options: NSRegularExpression.MatchingOptions(), - range: NSRange(location: 0, length: html.count)) + range: NSRange(location: 0, length: html.utf16.count)) if matches.count <= 0 { return nil } diff --git a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift index f23cf53b4ca1..7ca62af5ef6c 100644 --- a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift +++ b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift @@ -77,17 +77,17 @@ import WordPressSharedObjC content = RegEx.styleTags.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") content = RegEx.scriptTags.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") content = RegEx.gutenbergComments.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") return content @@ -111,23 +111,23 @@ import WordPressSharedObjC // Convert div tags to p tags content = RegEx.divTagsStart.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: openPTag) content = RegEx.divTagsEnd.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: closePTag) // Remove duplicate/redundant p tags. content = RegEx.pTagsStart.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: openPTag) content = RegEx.pTagsEnd.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: closePTag) content = filterNewLines(content) @@ -141,11 +141,11 @@ import WordPressSharedObjC var ranges = [NSRange]() // We don't want to remove new lines from preformatted tag blocks, // so get the ranges of such blocks. - let matches = RegEx.preTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.count)) + let matches = RegEx.preTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.utf16.count)) if matches.count == 0 { // No blocks found, so we'll parse the whole string. - ranges.append(NSRange(location: 0, length: content.count)) + ranges.append(NSRange(location: 0, length: content.utf16.count)) } else { @@ -161,7 +161,7 @@ import WordPressSharedObjC location = match.range.location + match.range.length } - length = content.count - location + length = content.utf16.count - location ranges.append(NSRange(location: location, length: length)) } @@ -191,7 +191,7 @@ import WordPressSharedObjC content = RegEx.styleAttr.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") return content @@ -242,7 +242,7 @@ import WordPressSharedObjC mImageStr.replaceOccurrences(of: srcImgURLStr, with: modifiedURL.absoluteString, options: .literal, - range: NSRange(location: 0, length: imgElementStr.count)) + range: NSRange(location: 0, length: imgElementStr.utf16.count)) mContent.replaceCharacters(in: match.range, with: mImageStr as String) } @@ -287,10 +287,9 @@ import WordPressSharedObjC } var content = string.trim() - let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.count)) - if let match = matches.first { - let index = content.index(content.startIndex, offsetBy: match.range.location) - content = String(content.prefix(upTo: index)) + let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.utf16.count)) + if let match = matches.first, let range = Range(match.range, in: content) { + content = String(content[content.startIndex..` /// func range(from nsRange: NSRange) -> Range { - let lowerBound = index(startIndex, offsetBy: nsRange.location) - let upperBound = index(lowerBound, offsetBy: nsRange.length) - - return lowerBound ..< upperBound + guard let range = Range(nsRange, in: self) else { + preconditionFailure("Invalid NSRange \(nsRange) for string of UTF-16 length \(utf16.count)") + } + return range } func range(fromUTF16NSRange utf16NSRange: NSRange) -> Range { @@ -160,11 +160,7 @@ extension String { /// - Returns: the requested `NSRange`. /// func nsRange(from range: Range) -> NSRange { - - let location = distance(from: startIndex, to: range.lowerBound) - let length = distance(from: range.lowerBound, to: range.upperBound) - - return NSRange(location: location, length: length) + NSRange(range, in: self) } /// Converts a `Range` into an UTF16 NSRange. @@ -190,7 +186,7 @@ extension String { /// Returns a NSRange with a starting location at the very end of the string /// func endOfStringNSRange() -> NSRange { - return NSRange(location: count, length: 0) + return NSRange(location: utf16.count, length: 0) } func indexFromLocation(_ location: Int) -> String.Index? { diff --git a/Modules/Sources/WordPressShared/Utility/String+RegEx.swift b/Modules/Sources/WordPressShared/Utility/String+RegEx.swift index 3a9e3270aeae..00d960edf737 100644 --- a/Modules/Sources/WordPressShared/Utility/String+RegEx.swift +++ b/Modules/Sources/WordPressShared/Utility/String+RegEx.swift @@ -16,7 +16,7 @@ extension String { public func replacingMatches(of regex: String, options: NSRegularExpression.Options = [], using block: (String, [String]) -> String) -> String { let regex = try! NSRegularExpression(pattern: regex, options: options) - let fullRange = NSRange(location: 0, length: count) + let fullRange = NSRange(location: 0, length: utf16.count) let matches = regex.matches(in: self, options: [], range: fullRange) var newString = self @@ -49,7 +49,7 @@ extension String { /// public func matches(regex: String, options: NSRegularExpression.Options = []) -> [NSTextCheckingResult] { let regex = try! NSRegularExpression(pattern: regex, options: options) - let fullRange = NSRange(location: 0, length: count) + let fullRange = NSRange(location: 0, length: utf16.count) return regex.matches(in: self, options: [], range: fullRange) } @@ -66,7 +66,7 @@ extension String { public func replacingMatches(of regex: String, with template: String, options: NSRegularExpression.Options = []) -> String { let regex = try! NSRegularExpression(pattern: regex, options: options) - let fullRange = NSRange(location: 0, length: count) + let fullRange = NSRange(location: 0, length: utf16.count) return regex.stringByReplacingMatches(in: self, options: [], diff --git a/Modules/Sources/WordPressUI/Extensions/NSMutableAttributedString+Helpers.swift b/Modules/Sources/WordPressUI/Extensions/NSMutableAttributedString+Helpers.swift index be4a7a9e3bd8..2289f8ccfe5f 100644 --- a/Modules/Sources/WordPressUI/Extensions/NSMutableAttributedString+Helpers.swift +++ b/Modules/Sources/WordPressUI/Extensions/NSMutableAttributedString+Helpers.swift @@ -5,10 +5,31 @@ import UIKit // extension NSMutableAttributedString { + /// Applies a single attribute to the entire string. + /// + /// Prefer this over manually constructing an `NSRange` with `NSMakeRange(0, โ€ฆ)`, + /// which is error-prone when mixing `String.count` (grapheme clusters) with + /// `NSRange` (UTF-16 code units). + /// + public func applyAttribute(_ key: NSAttributedString.Key, value: Any) { + // swiftlint:disable:next full_range_attributed_string_attribute + addAttribute(key, value: value, range: NSRange(location: 0, length: length)) + } + + /// Applies a collection of attributes to the entire string. + /// + /// Prefer this over manually constructing an `NSRange` with `NSMakeRange(0, โ€ฆ)`, + /// which is error-prone when mixing `String.count` (grapheme clusters) with + /// `NSRange` (UTF-16 code units). + /// + public func applyAttributes(_ attrs: [NSAttributedString.Key: Any]) { + // swiftlint:disable:next full_range_attributed_string_attribute + addAttributes(attrs, range: NSRange(location: 0, length: length)) + } + /// Applies the specified foreground color to the full length of the receiver. /// public func applyForegroundColor(_ color: UIColor) { - let range = NSRange(location: 0, length: length) - addAttribute(.foregroundColor, value: color, range: range) + applyAttribute(.foregroundColor, value: color) } } diff --git a/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift b/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift index e7d5c67030fe..87b365c85463 100644 --- a/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift +++ b/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift @@ -56,6 +56,48 @@ class RichContentFormatterTests: XCTestCase { XCTAssertTrue(range.location != NSNotFound) } + // MARK: - Emoji / Multi-byte Character Tests + // + // These tests verify that string operations work correctly with emoji + // and other characters where String.count (grapheme clusters) differs + // from String.utf16.count (UTF-16 code units used by NSRange). + // The family emoji ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units. + + func testRemoveForbiddenTagsWithEmoji() { + let input = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ World

" + let expected = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ World

" + let result = RichContentFormatter.removeForbiddenTags(input) + XCTAssertEqual(result, expected, "Script tags after emoji should be removed") + } + + func testRemoveInlineStylesWithEmoji() { + let input = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ

test

" + let expected = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ

test

" + let result = RichContentFormatter.removeInlineStyles(input) + XCTAssertEqual(result, expected, "Inline styles after emoji should be removed") + } + + func testNormalizeParagraphsWithEmoji() { + let input = "
Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ
test
" + let expected = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ

test

" + let result = RichContentFormatter.normalizeParagraphs(input) + XCTAssertEqual(result, expected, "Div-to-p conversion after emoji should work") + } + + func testFilterNewLinesWithEmoji() { + let input = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ

\n

World

\n" + let expected = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ

World

" + let result = RichContentFormatter.filterNewLines(input) + XCTAssertEqual(result, expected, "Newlines after emoji should be filtered") + } + + func testRemoveTrailingBreakTagsWithEmoji() { + let input = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ World



" + let expected = "

Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ World

" + let result = RichContentFormatter.removeTrailingBreakTags(input) + XCTAssertEqual(result, expected, "Trailing BR tags after emoji should be removed") + } + func testFormatVideoTags() { let str1 = "

Some text.

Some text.

" let sanitizedStr1 = RichContentFormatter.formatVideoTags(str1) as NSString diff --git a/Modules/Tests/WordPressSharedTests/StringRangeConversionTests.swift b/Modules/Tests/WordPressSharedTests/StringRangeConversionTests.swift new file mode 100644 index 000000000000..7bb6cc798846 --- /dev/null +++ b/Modules/Tests/WordPressSharedTests/StringRangeConversionTests.swift @@ -0,0 +1,123 @@ +import Testing +import Foundation +import UIKit + +@testable import WordPressShared + +struct StringRangeConversionTests { + + // MARK: - range(from nsRange: NSRange) -> Range + + @Test("range(from:) converts NSRange correctly for ASCII strings") + func rangeFromNSRangeASCII() { + let string = "Hello, world!" + let nsRange = NSRange(location: 7, length: 5) + let range = string.range(from: nsRange) + #expect(String(string[range]) == "world") + } + + @Test("range(from:) converts NSRange correctly for strings with emoji") + func rangeFromNSRangeWithEmoji() { + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units + let string = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Hello" + let nsRange = NSRange(location: 12, length: 5) // UTF-16 offset past emoji + space + let range = string.range(from: nsRange) + #expect(String(string[range]) == "Hello") + } + + @Test("range(from:) handles multi-byte characters in the middle") + func rangeFromNSRangeWithEmojiInMiddle() { + // "AB๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆCD" โ€” 'A'=1, 'B'=1, emoji=11, 'C'=1, 'D'=1 UTF-16 units + let string = "AB๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆCD" + let nsRange = NSRange(location: 13, length: 2) // "CD" + let range = string.range(from: nsRange) + #expect(String(string[range]) == "CD") + } + + // MARK: - nsRange(from range: Range) -> NSRange + + @Test("nsRange(from:) produces correct UTF-16 NSRange for ASCII strings") + func nsRangeFromSwiftRangeASCII() { + let string = "Hello, world!" + let swiftRange = string.range(of: "world")! + let nsRange = string.nsRange(from: swiftRange) + #expect(nsRange.location == 7) + #expect(nsRange.length == 5) + } + + @Test("nsRange(from:) produces correct UTF-16 NSRange with emoji") + func nsRangeFromSwiftRangeWithEmoji() { + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units + let string = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Hello" + let swiftRange = string.range(of: "Hello")! + let nsRange = string.nsRange(from: swiftRange) + // "Hello" starts at UTF-16 offset 12 (11 for emoji + 1 for space) + #expect(nsRange.location == 12) + #expect(nsRange.length == 5) + } + + @Test("nsRange(from:) result works correctly with NSAttributedString") + func nsRangeFromSwiftRangeWorksWithAttributedString() { + let string = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Bold text" + let swiftRange = string.range(of: "Bold")! + let nsRange = string.nsRange(from: swiftRange) + let attributed = NSMutableAttributedString(string: string) + // This should not crash โ€” if nsRange used grapheme offsets, this would + // produce an out-of-bounds range for NSAttributedString + attributed.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: 14), range: nsRange) + let attrs = attributed.attributes(at: nsRange.location, effectiveRange: nil) + #expect(attrs[.font] != nil) + } + + // MARK: - nsRange(of:) -> NSRange? + + @Test("nsRange(of:) produces correct UTF-16 NSRange for substring after emoji") + func nsRangeOfSubstringAfterEmoji() { + let string = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ world" + let nsRange = string.nsRange(of: "world") + #expect(nsRange != nil) + #expect(nsRange!.location == 12) // 11 for emoji + 1 for space + #expect(nsRange!.length == 5) + } + + @Test("nsRange(of:) result can be used with NSRegularExpression range") + func nsRangeOfWorksWithRegex() { + let string = "๐ŸŽ‰ test@example.com" + // Find "test@example.com" via nsRange(of:) and verify it matches UTF-16 offsets + let nsRange = string.nsRange(of: "test@example.com")! + let nsString = string as NSString + let extracted = nsString.substring(with: nsRange) + #expect(extracted == "test@example.com") + } + + // MARK: - endOfStringNSRange() + + @Test("endOfStringNSRange() uses UTF-16 count for emoji strings") + func endOfStringNSRangeWithEmoji() { + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units + let string = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ" + let nsRange = string.endOfStringNSRange() + #expect(nsRange.location == 11) // UTF-16 count, not grapheme count (1) + #expect(nsRange.length == 0) + } + + @Test("endOfStringNSRange() is consistent with NSString length") + func endOfStringNSRangeConsistentWithNSString() { + let string = "Hello ๐ŸŒ World ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ!" + let nsRange = string.endOfStringNSRange() + let nsString = string as NSString + #expect(nsRange.location == nsString.length) + } + + // MARK: - Round-trip consistency + + @Test("NSRange โ†’ Range โ†’ NSRange round-trip preserves values with emoji") + func roundTripWithEmoji() { + let string = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Hello ๐ŸŒ World" + let original = NSRange(location: 12, length: 5) // "Hello" + let swiftRange = string.range(from: original) + let roundTripped = string.nsRange(from: swiftRange) + #expect(roundTripped.location == original.location) + #expect(roundTripped.length == original.length) + } +} diff --git a/Modules/Tests/WordPressUIUnitTests/Extensions/NSMutableAttributedStringHelpersTests.swift b/Modules/Tests/WordPressUIUnitTests/Extensions/NSMutableAttributedStringHelpersTests.swift new file mode 100644 index 000000000000..7b26428b1e63 --- /dev/null +++ b/Modules/Tests/WordPressUIUnitTests/Extensions/NSMutableAttributedStringHelpersTests.swift @@ -0,0 +1,60 @@ +import Testing +import UIKit + +@testable import WordPressUI + +struct NSMutableAttributedStringHelpersTests { + + // MARK: - applyAttribute(_:value:) + + @Test("applyAttribute covers the full string including emoji") + func applyAttributeWithEmoji() { + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units + let string = NSMutableAttributedString(string: "Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ World") + string.applyAttribute(.foregroundColor, value: UIColor.red) + + var effectiveRange = NSRange() + let value = string.attribute(.foregroundColor, at: 0, effectiveRange: &effectiveRange) + + #expect(value != nil) + #expect(effectiveRange.location == 0) + #expect(effectiveRange.length == string.length) + } + + // MARK: - applyAttributes(_:) + + @Test("applyAttributes covers the full string including emoji") + func applyAttributesWithEmoji() { + let string = NSMutableAttributedString(string: "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ ใƒ†ใ‚นใƒˆ") + let attrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.blue, + .font: UIFont.systemFont(ofSize: 14) + ] + string.applyAttributes(attrs) + + var colorRange = NSRange() + let color = string.attribute(.foregroundColor, at: 0, effectiveRange: &colorRange) + + var fontRange = NSRange() + let font = string.attribute(.font, at: 0, effectiveRange: &fontRange) + + #expect(color != nil) + #expect(colorRange.length == string.length) + #expect(font != nil) + #expect(fontRange.length == string.length) + } + + // MARK: - applyForegroundColor(_:) + + @Test("applyForegroundColor covers the full string including emoji") + func applyForegroundColorWithEmoji() { + let string = NSMutableAttributedString(string: "๐ŸŒ Hello ๐ŸŒ") + string.applyForegroundColor(.green) + + var effectiveRange = NSRange() + let value = string.attribute(.foregroundColor, at: 0, effectiveRange: &effectiveRange) + + #expect(value as? UIColor == .green) + #expect(effectiveRange.length == string.length) + } +} diff --git a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/2FA/TwoFAViewController.swift b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/2FA/TwoFAViewController.swift index d28708fa6a7d..138a00aa99af 100644 --- a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/2FA/TwoFAViewController.swift +++ b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/2FA/TwoFAViewController.swift @@ -599,7 +599,6 @@ private extension TwoFAViewController { /// func configureForAccessibility() { view.accessibilityElements = [ - codeField as Any, tableView as Any, submitButton as Any ] diff --git a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/Password/PasswordViewController.swift b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/Password/PasswordViewController.swift index bdeb49a8e5db..37ac8a7fd52c 100644 --- a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/Password/PasswordViewController.swift +++ b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/Password/PasswordViewController.swift @@ -523,7 +523,6 @@ private extension PasswordViewController { /// func configureForAccessibility() { view.accessibilityElements = [ - passwordField as Any, tableView as Any, submitButton as Any ] diff --git a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteAddressViewController.swift b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteAddressViewController.swift index 33ac6331b4e6..544b19c97637 100644 --- a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteAddressViewController.swift +++ b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteAddressViewController.swift @@ -141,7 +141,6 @@ final class SiteAddressViewController: LoginViewController { /// private func configureForAccessibility() { view.accessibilityElements = [ - siteURLField as Any, tableView as Any, submitButton as Any ] diff --git a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteCredentialsViewController.swift b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteCredentialsViewController.swift index 89b4d15d8eaa..232637b15952 100644 --- a/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteCredentialsViewController.swift +++ b/Sources/WordPressAuthenticator/Helpers/UnifiedAuth/ViewRelated/SiteAddress/SiteCredentialsViewController.swift @@ -133,7 +133,6 @@ final class SiteCredentialsViewController: LoginViewController { /// private func configureForAccessibility() { view.accessibilityElements = [ - usernameField as Any, tableView as Any, submitButton as Any ] diff --git a/Tests/KeystoneTests/Tests/Features/JetpackScanThreatContextTests.swift b/Tests/KeystoneTests/Tests/Features/JetpackScanThreatContextTests.swift new file mode 100644 index 000000000000..c72014c7a2a1 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/JetpackScanThreatContextTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import WordPressKit +@testable import WordPress + +final class JetpackScanThreatContextTests: XCTestCase { + + private let config = JetpackThreatContext.JetpackThreatContextRendererConfig( + numberAttributes: [.foregroundColor: UIColor.gray], + highlightedNumberAttributes: [.foregroundColor: UIColor.red], + contentsAttributes: [.foregroundColor: UIColor.label], + highlightedContentsAttributes: [.foregroundColor: UIColor.label], + highlightedSectionAttributes: [.backgroundColor: UIColor.yellow] + ) + + /// Regression test: file contents with multi-byte characters (emoji, CJK) + /// should not crash or produce incorrect attributed strings. + /// Previously, `String.count` was used instead of `String.utf16.count` + /// for NSRange construction, which could produce out-of-bounds ranges. + func testAttributedStringWithMultiByteContents() { + let lines: [JetpackThreatContext.ThreatContextLine] = [ + .init(lineNumber: 1, contents: " FormattableTextContent { let text = try mockActivity()["text"] as? String ?? "" return FormattableTextContent(text: text, ranges: [], actions: []) diff --git a/Tests/KeystoneTests/Tests/Features/Notifications/FormattableNotIconTests.swift b/Tests/KeystoneTests/Tests/Features/Notifications/FormattableNotIconTests.swift index b0c616ca4bbb..8d18c86a2134 100644 --- a/Tests/KeystoneTests/Tests/Features/Notifications/FormattableNotIconTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Notifications/FormattableNotIconTests.swift @@ -34,6 +34,35 @@ final class FormattableNotIconTests: XCTestCase { XCTAssertEqual(subject?.value, Constants.icon) } + // MARK: - apply(_:to:withShift:) + + func testApplyReturnsUTF16ShiftForMultiByteNoticon() { + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units. + // The noticon string is value + " ", so 12 UTF-16 code units total. + // With the old .count code, the shift would have been 2 (1 emoji + 1 space). + let familyEmoji = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ" + let noticonRange = FormattableNoticonRange(value: familyEmoji, range: NSRange(location: 0, length: 0)) + + let baseText = "Hello World" + let string = NSMutableAttributedString(string: baseText) + + let styles = SnippetsContentStyles(rangeStylesMap: [ + .noticon: [.foregroundColor: UIColor.red] + ]) + + let shift = noticonRange.apply(styles, to: string, withShift: 0) + + // The noticon is familyEmoji + " " = 12 UTF-16 code units + let expectedShift = (familyEmoji + " ").utf16.count + XCTAssertEqual(shift, expectedShift, "Shift should equal the UTF-16 count of the noticon, not the grapheme cluster count") + + // Verify styles were applied to the full noticon range + var effectiveRange = NSRange() + let color = string.attribute(.foregroundColor, at: 0, effectiveRange: &effectiveRange) + XCTAssertNotNil(color) + XCTAssertEqual(effectiveRange.length, expectedShift, "Style should cover the full UTF-16 range of the inserted noticon") + } + private func mockProperties() -> NotificationContentRange.Properties { return NotificationContentRange.Properties(range: Constants.range) } diff --git a/Tests/WordPressKitTests/CoreAPITests/WordPressOrgXMLRPCValidatorTests.swift b/Tests/WordPressKitTests/CoreAPITests/WordPressOrgXMLRPCValidatorTests.swift index a66c00d7abd1..c39e20266ec3 100644 --- a/Tests/WordPressKitTests/CoreAPITests/WordPressOrgXMLRPCValidatorTests.swift +++ b/Tests/WordPressKitTests/CoreAPITests/WordPressOrgXMLRPCValidatorTests.swift @@ -368,6 +368,76 @@ final class WordPressOrgXMLRPCValidatorTests: XCTestCase { wait(for: [failure], timeout: 0.3) } + // Regression test: emoji in HTML before the RSD link caused the regex + // range to be too short because `String.count` (grapheme clusters) was + // used instead of `String.utf16.count` (UTF-16 code units for NSRange). + func testSuccessWithRSDLinkAfterEmoji() throws { + let responseInvalidPath = try XCTUnwrap(xmlrpcResponseInvalidPath) + stub(condition: isHost("www.apple.com") && isPath("/blog/xmlrpc.php")) { _ in + return fixture(filePath: responseInvalidPath, status: 403, headers: nil) + } + + stub(condition: isHost("www.apple.com") && isPath("/blog")) { _ in + // The family emoji ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 1 grapheme cluster but 11 UTF-16 code units. + // Placing many of them *before* the RSD link tag means the gap between + // String.count and utf16.count is large enough that an NSRange built + // with String.count will be too short to cover the link tag. + let emoji = String(repeating: "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", count: 50) + let html = """ + + + + \(emoji) + + + hello world + + """ + return HTTPStubsResponse(data: html.data(using: .utf8)!, statusCode: 200, headers: nil) + } + + stub(condition: isAbsoluteURLString("https://www.apple.com/blog/rsd")) { _ in + let xml = """ + + + WordPress + https://wordpress.org/ + https://developer.wordpress.org + + + + + + """ + return HTTPStubsResponse( + data: xml.data(using: .utf8)!, + statusCode: 200, + headers: ["Content-Type": "application/xml"] + ) + } + + let responseList = try XCTUnwrap( + OHPathForFileInBundle("xmlrpc-response-list-methods.xml", Bundle.coreAPITestsBundle) + ) + stub(condition: isHost("www.apple.com") && isPath("/blog-xmlrpc.php")) { _ in + fixture( + filePath: responseList, + status: 200, + headers: ["Content-Type": "application/xml"] + ) + } + + let success = self.expectation(description: "success result") + let validator = WordPressOrgXMLRPCValidator() + validator.guessXMLRPCURLForSite("https://www.apple.com/blog", userAgent: "test/1.0", success: { + XCTAssertEqual($0.absoluteString, "https://www.apple.com/blog-xmlrpc.php") + success.fulfill() + }) { + XCTFail("Unexpected result: \($0)") + } + wait(for: [success], timeout: 0.3) + } + let xmlrpcResponseInvalidPath = OHPathForFileInBundle( "xmlrpc-response-invalid.html", Bundle.coreAPITestsBundle diff --git a/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift b/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift index 10727ac592c3..c2605a0804df 100644 --- a/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift +++ b/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressUI extension NSAttributedString { /// Creates an `NSAttributedString` with the styles defined in `attributes` applied. @@ -43,9 +44,7 @@ extension NSAttributedString { paragraphStyle.paragraphSpacing = 0 paragraphStyle.paragraphSpacingBefore = 0 - attributedString.addAttribute(.paragraphStyle, - value: paragraphStyle, - range: NSMakeRange(0, attributedString.string.count - 1)) + attributedString.applyAttribute(.paragraphStyle, value: paragraphStyle) return NSAttributedString(attributedString: attributedString) } diff --git a/WordPress/Classes/Jetpack/NUX/LandingScreen/Views/JetpackLandingScreenView.swift b/WordPress/Classes/Jetpack/NUX/LandingScreen/Views/JetpackLandingScreenView.swift index 14636f661729..9670a45e8c8e 100644 --- a/WordPress/Classes/Jetpack/NUX/LandingScreen/Views/JetpackLandingScreenView.swift +++ b/WordPress/Classes/Jetpack/NUX/LandingScreen/Views/JetpackLandingScreenView.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressUI final class JetpackLandingScreenView: UIView { @@ -72,7 +73,7 @@ final class JetpackLandingScreenView: UIView { return nil } let attributedString = NSMutableAttributedString(string: text) - attributedString.addAttributes(attributesForLabel(atIndex: index, traits: traits), range: NSMakeRange(0, text.utf16.count)) + attributedString.applyAttributes(attributesForLabel(atIndex: index, traits: traits)) return attributedString } diff --git a/WordPress/Classes/Models/Revisions/DiffAbstractValue+Attributes.swift b/WordPress/Classes/Models/Revisions/DiffAbstractValue+Attributes.swift index 4f426e60dbbd..01e2b5e24ea9 100644 --- a/WordPress/Classes/Models/Revisions/DiffAbstractValue+Attributes.swift +++ b/WordPress/Classes/Models/Revisions/DiffAbstractValue+Attributes.swift @@ -39,7 +39,7 @@ extension Array where Element == DiffAbstractValue { let attribute = NSMutableAttributedString(string: value) if let attributes = right.attributes { - attribute.addAttributes(attributes, range: NSRange(location: 0, length: value.count)) + attribute.applyAttributes(attributes) } left.append(attribute) return left diff --git a/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduleFormatter.swift b/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduleFormatter.swift index 5de223f97fcd..2ce115aba51c 100644 --- a/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduleFormatter.swift +++ b/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduleFormatter.swift @@ -189,6 +189,6 @@ private extension String { guard let expression = try? NSRegularExpression(pattern: "", options: .caseInsensitive) else { return self } - return expression.stringByReplacingMatches(in: self, range: NSMakeRange(0, self.count), withTemplate: String()) + return expression.stringByReplacingMatches(in: self, range: NSMakeRange(0, self.utf16.count), withTemplate: String()) } } diff --git a/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowCompletionViewController.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowCompletionViewController.swift index 09b7081e7ff8..c87473451134 100644 --- a/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowCompletionViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowCompletionViewController.swift @@ -177,7 +177,7 @@ final class BloggingRemindersFlowCompletionViewController: UIViewController { let promptText = NSMutableAttributedString(attributedString: formatter.longScheduleDescription(for: schedule, time: scheduler.scheduledTime(for: blog).toLocalTime())) - promptText.addAttributes(defaultAttributes, range: NSRange(location: 0, length: promptText.length)) + promptText.applyAttributes(defaultAttributes) promptLabel.attributedText = promptText } diff --git a/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowSettingsViewController.swift b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowSettingsViewController.swift index 61d8c31763b3..1f21d679a469 100644 --- a/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/BloggingReminders/BloggingRemindersFlowSettingsViewController.swift @@ -490,7 +490,7 @@ private extension BloggingRemindersFlowSettingsViewController { let frequencyDescription = scheduleFormatter.shortScheduleDescription(for: .weekdays(weekdays)) let attributedText = NSMutableAttributedString(attributedString: frequencyDescription) - attributedText.addAttributes(defaultAttributes, range: NSRange(location: 0, length: attributedText.length)) + attributedText.applyAttributes(defaultAttributes) frequencyLabel.attributedText = attributedText frequencyLabel.sizeToFit() diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift index f5d34c42e9dc..0872b6fffcd9 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressUI protocol DestructiveAlertHelperLogic { var valueToConfirm: String? { get } @@ -18,7 +19,7 @@ class DestructiveAlertHelper: DestructiveAlertHelperLogic { let attributedValue: NSMutableAttributedString = NSMutableAttributedString(string: valueToConfirm) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineBreakMode = .byCharWrapping - attributedValue.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, attributedValue.string.count - 1)) + attributedValue.applyAttribute(.paragraphStyle, value: paragraphStyle) attributedMessage.append(attributedValue) let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert) diff --git a/WordPress/Classes/ViewRelated/Blog/Site Monitoring/SiteMonitoringEntryDetailsView.swift b/WordPress/Classes/ViewRelated/Blog/Site Monitoring/SiteMonitoringEntryDetailsView.swift index cf5cb91ca3dc..64cb3c81e5ae 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Monitoring/SiteMonitoringEntryDetailsView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Monitoring/SiteMonitoringEntryDetailsView.swift @@ -1,6 +1,7 @@ import SwiftUI import WordPressData import WordPressKit +import WordPressUI struct SiteMonitoringEntryDetailsView: View { let text: NSAttributedString @@ -77,12 +78,12 @@ private func makeAttributedText(metadata: [(String, String?)], message: String? if let message { output.append(NSAttributedString(string: "\n" + message, attributes: [.font: regular])) } - output.addAttribute(.paragraphStyle, value: { + output.applyAttribute(.paragraphStyle, value: { let style = NSMutableParagraphStyle() style.lineSpacing = 3 return style - }(), range: NSRange(location: 0, length: output.length)) - output.addAttribute(.foregroundColor, value: UIColor.label, range: NSRange(location: 0, length: output.length)) + }()) + output.applyAttribute(.foregroundColor, value: UIColor.label) return output } diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift index 07748d35943c..ffc500bdab25 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift @@ -84,7 +84,7 @@ public class CommentDetailViewController: UIViewController, NoResultsViewHost { let attributedString = NSMutableAttributedString() attributedString.append(NSAttributedString(attachment: iconAttachment)) attributedString.append(.init(string: " " + .replyIndicatorLabelText)) - attributedString.addAttributes(Style.ReplyIndicator.textAttributes, range: NSMakeRange(0, attributedString.length)) + attributedString.applyAttributes(Style.ReplyIndicator.textAttributes) // reverse the attributed strings in RTL direction. if view.effectiveUserInterfaceLayoutDirection == .rightToLeft { diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.swift index 449cbf2e03f8..2f35fd9de6ac 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.swift @@ -147,7 +147,7 @@ class JetpackFullscreenOverlayViewController: UIViewController { .kern: Metrics.titleKern ] let attributedString = NSMutableAttributedString(string: viewModel.title) - attributedString.addAttributes(defaultAttributes, range: NSRange(location: 0, length: attributedString.length)) + attributedString.applyAttributes(defaultAttributes) titleLabel.attributedText = attributedString } diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanThreatViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanThreatViewModel.swift index 77d087e17874..ed5d8de41a72 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanThreatViewModel.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanThreatViewModel.swift @@ -409,7 +409,7 @@ struct JetpackScanThreatViewModel { } } -private extension JetpackThreatContext { +extension JetpackThreatContext { struct JetpackThreatContextRendererConfig { let numberAttributes: [NSAttributedString.Key: Any] @@ -461,26 +461,22 @@ private extension JetpackThreatContext { if let highlights = line.highlights { - numberAttr.setAttributes(config.highlightedNumberAttributes, - range: NSRange(location: 0, length: numberStr.count)) + numberAttr.applyAttributes(config.highlightedNumberAttributes) - contentsAttr.addAttributes(config.highlightedContentsAttributes, - range: NSRange(location: 0, length: contentsStr.count)) + contentsAttr.applyAttributes(config.highlightedContentsAttributes) for highlight in highlights { let location = highlight.location - let length = highlight.length + Constants.columnSpacer.count + let length = highlight.length + Constants.columnSpacer.utf16.count let range = NSRange(location: location, length: length) contentsAttr.addAttributes(config.highlightedSectionAttributes, range: range) } } else { - numberAttr.setAttributes(config.numberAttributes, - range: NSRange(location: 0, length: numberStr.count)) + numberAttr.applyAttributes(config.numberAttributes) - contentsAttr.setAttributes(config.contentsAttributes, - range: NSRange(location: 0, length: contentsStr.count)) + contentsAttr.applyAttributes(config.contentsAttributes) } attrString.append(numberAttr) diff --git a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Groups/BodyContentGroup.swift b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Groups/BodyContentGroup.swift index adced12f1874..8985dfe80ac9 100644 --- a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Groups/BodyContentGroup.swift +++ b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Groups/BodyContentGroup.swift @@ -85,9 +85,9 @@ class BodyContentGroup: FormattableContentGroup { } } - private class func pingbackReadMoreGroup(for url: URL) -> FormattableContentGroup { + static func pingbackReadMoreGroup(for url: URL) -> FormattableContentGroup { let text = NSLocalizedString("Read the source post", comment: "Displayed at the footer of a Pingback Notification.") - let textRange = NSRange(location: 0, length: text.count) + let textRange = NSRange(location: 0, length: text.utf16.count) let zeroRange = NSRange(location: 0, length: 0) var properties = NotificationContentRange.Properties(range: textRange) diff --git a/WordPress/Classes/ViewRelated/Post/Views/PostListItemViewModel.swift b/WordPress/Classes/ViewRelated/Post/Views/PostListItemViewModel.swift index d7e8a1cbd287..597c3aa0918b 100644 --- a/WordPress/Classes/ViewRelated/Post/Views/PostListItemViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/Views/PostListItemViewModel.swift @@ -1,6 +1,7 @@ import Foundation import WordPressData import WordPressShared +import WordPressUI final class PostListItemViewModel { let post: Post @@ -76,7 +77,7 @@ private func makeTitleString(for post: Post, isDisabled: Bool) -> NSAttributedSt paragraphStyle.lineBreakMode = .byTruncatingTail let string = NSMutableAttributedString(string: title, attributes: attributes) - string.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: string.length)) + string.applyAttribute(.paragraphStyle, value: paragraphStyle) return string } @@ -95,7 +96,7 @@ private func makeExcerptString(for post: Post, isDisabled: Bool) -> NSAttributed paragraphStyle.lineBreakMode = .byTruncatingTail let string = NSMutableAttributedString(string: excerpt, attributes: attributes) - string.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: string.length)) + string.applyAttribute(.paragraphStyle, value: paragraphStyle) return string } diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Helper.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Helper.swift index 40c90db83388..a644b0288c5f 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Helper.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController+Helper.swift @@ -185,7 +185,7 @@ extension ReaderStreamViewController { ]) let icon = UIImage.gridicon(.bookmarkOutline, size: CGSize(width: 18, height: 18)) string.replace("[bookmark-outline]", with: icon) - string.addAttribute(.foregroundColor, value: UIColor.secondaryLabel, range: NSRange(location: 0, length: string.length)) + string.applyAttribute(.foregroundColor, value: UIColor.secondaryLabel) return string } } diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderReadMoreView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderReadMoreView.swift index 7b461532c119..7b9aad1a9be8 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderReadMoreView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderReadMoreView.swift @@ -1,6 +1,7 @@ import UIKit import SafariServices import WordPressData +import WordPressUI // [โ€ฆ] final class ReaderReadMoreView: UIView, UIAdaptivePresentationControllerDelegate, UIPopoverPresentationControllerDelegate { @@ -25,7 +26,7 @@ final class ReaderReadMoreView: UIView, UIAdaptivePresentationControllerDelegate .font: UIFont.preferredFont(forTextStyle: .body) ]) if let postURL = post.permaLink.flatMap(URL.init) { - string.addAttribute(.link, value: postURL, range: NSRange(location: 0, length: string.length)) + string.applyAttribute(.link, value: postURL) self.postURL = postURL } textView.attributedText = string diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell.swift b/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell.swift index cf57c0886935..6dabe49de8de 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell.swift @@ -311,6 +311,8 @@ extension AddressTableViewCell { } let completeDomainName = NSMutableAttributedString(string: name, attributes: TextStyleAttributes.defaults) + // Domain names are ASCII-only (internationalized domains use Punycode), + // so .count == .utf16.count here and this is safe. let rangeOfCustomName = NSRange(location: 0, length: customName.count) completeDomainName.setAttributes(TextStyleAttributes.customName, range: rangeOfCustomName) diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/StatsTotalInsightsCell.swift b/WordPress/Classes/ViewRelated/Stats/Insights/StatsTotalInsightsCell.swift index 56ff84ae323d..4880d27641a8 100644 --- a/WordPress/Classes/ViewRelated/Stats/Insights/StatsTotalInsightsCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Insights/StatsTotalInsightsCell.swift @@ -91,17 +91,18 @@ struct StatsTotalInsightsData: Equatable { let attributedString = NSMutableAttributedString(string: formattedString) - let textRange = NSMakeRange(0, formattedString.count) - attributedString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .subheadline), range: textRange) - attributedString.addAttribute(.foregroundColor, value: UIColor.label, range: textRange) + attributedString.applyAttributes([ + .font: UIFont.preferredFont(forTextStyle: .subheadline), + .foregroundColor: UIColor.label + ]) let titlePlaceholderRange = (text as NSString).range(of: "%1$@") - let titleRange = NSMakeRange(titlePlaceholderRange.location, title.count) + let titleRange = NSMakeRange(titlePlaceholderRange.location, title.utf16.count) attributedString.addAttribute(.foregroundColor, value: UIAppColor.primary, range: titleRange) let formattedTitleString = String.localizedStringWithFormat(text, title, "%2$@") let countPlaceholderRange = (formattedTitleString as NSString).range(of: "%2$@") - let countRange = NSMakeRange(countPlaceholderRange.location, countString.count) + let countRange = NSMakeRange(countPlaceholderRange.location, countString.utf16.count) attributedString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .subheadline).bold(), range: countRange) return attributedString diff --git a/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift b/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift index 8928454a9e1f..96ac9195e55d 100644 --- a/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift @@ -38,8 +38,7 @@ struct OverviewTabData: FilterTabBarItem, Hashable { var attributedTitle: NSAttributedString? { let attributedTitle = NSMutableAttributedString(string: tabTitle.localizedUppercase) - attributedTitle.addAttributes([.font: WPStyleGuide.Stats.overviewCardFilterTitleFont], - range: NSMakeRange(0, attributedTitle.string.count)) + attributedTitle.applyAttributes([.font: WPStyleGuide.Stats.overviewCardFilterTitleFont]) let dataString: String = { if let tabDataStub { @@ -49,8 +48,7 @@ struct OverviewTabData: FilterTabBarItem, Hashable { }() let attributedData = NSMutableAttributedString(string: dataString) - attributedData.addAttributes([.font: WPStyleGuide.Stats.overviewCardFilterDataFont], - range: NSMakeRange(0, attributedData.string.count)) + attributedData.applyAttributes([.font: WPStyleGuide.Stats.overviewCardFilterDataFont]) attributedTitle.append(NSAttributedString(string: "\n")) attributedTitle.append(attributedData) diff --git a/WordPress/Classes/ViewRelated/System/FilterTabBar.swift b/WordPress/Classes/ViewRelated/System/FilterTabBar.swift index c6c6d6491e1d..99a23da75f61 100644 --- a/WordPress/Classes/ViewRelated/System/FilterTabBar.swift +++ b/WordPress/Classes/ViewRelated/System/FilterTabBar.swift @@ -1,5 +1,6 @@ import UIKit import WordPressShared +import WordPressUI /// Filter Tab Bar is a tabbed control (much like a segmented control), but /// has an appearance similar to Android tabs. @@ -344,7 +345,7 @@ public class FilterTabBar: UIControl { } let mutableString = NSMutableAttributedString(attributedString: attributedString) - mutableString.addAttributes([.foregroundColor: color], range: NSMakeRange(0, mutableString.string.count)) + mutableString.applyAttributes([.foregroundColor: color]) return mutableString } diff --git a/WordPress/WordPressShareExtension/Sources/Services/ShareExtractor.swift b/WordPress/WordPressShareExtension/Sources/Services/ShareExtractor.swift index 834375e39451..4909390d5c1c 100644 --- a/WordPress/WordPressShareExtension/Sources/Services/ShareExtractor.swift +++ b/WordPress/WordPressShareExtension/Sources/Services/ShareExtractor.swift @@ -578,7 +578,7 @@ private struct PlainTextExtractor: TypeBasedExtensionContentExtractor { // the selected text โ€” we just want to make sure shared URLs are handled). let types: NSTextCheckingResult.CheckingType = [.link] let detector = try? NSDataDetector(types: types.rawValue) - if let match = detector?.firstMatch(in: payload, options: [], range: NSMakeRange(0, payload.count)), + if let match = detector?.firstMatch(in: payload, options: [], range: NSMakeRange(0, payload.utf16.count)), match.resultType == .link, let url = match.url, url.absoluteString.count == payload.count {