// // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import UIKit public import UniformTypeIdentifiers /// Represents an argument passed when creating an attributed string using /// formatting, where attributes may be applied to the substituted value of the /// argument in the formatted string. public enum AttributedFormatArg { public typealias Attributes = [NSAttributedString.Key: Any] /// Substitute the argument as-is, without applying attributes. case raw(_ value: CVarArg) /// Substitute the string and apply the given attributes. case string(_ string: String, attributes: Attributes) fileprivate var fallback: CVarArg { switch self { case let .raw(value): return value case let .string(value, _): return value } } } public extension NSAttributedString { /// The challenge here is: given a format string, which may be of either /// Localizable.strings format or PluralAware.stringsdict format (see below /// for examples), and a set of arguments that will be substituted into the /// format string, apply attributes specific to each argument to the range /// in the formatted string that will contain the substituted argument. /// /// For strings from `Localizable.strings` files, the format arg placeholders /// are well-known and consistent: `%@` for a single-argument format string, /// and `%$@` for a multiple-argument format string. For strings /// from `PluralAware.stringsdict` files there is another layer of /// indirection: the format string is assembled by the system in response to /// the format arg indicating the degree of the plural-aware string (e.g., /// "zero, one, or more"), and the full format string (w/o substitutions) is /// not available to us. Consequently, we must take an approach that is /// agnostic to the format string, and instead leverages a fully-formatted /// string. /// /// The approach taken here uses placeholders. We substitute into the format /// string, but for each argument we want to substitute we will instead /// substitute a "placeholder" UUID string we associate with the arg. We /// then search for those UUIDs in the formatted string, and assemble a new /// string in which each UUID is replaced with the argument for which it is /// placeholding. While replacing, we track the ranges of the args and use /// them to associate that arg's attributes. /// /// This approach allows us to avoid collisions between the substituted /// arguments either with each other or the text of the format string. It /// is also agnostic to properties such as RTL, or if the format string or /// arguments contain Unicode. /// /// - Parameter fromFormat: the format string to substitute into. /// - Parameter attributedFormatArgs: format args, with their respective attributes. /// - Parameter defaultAttributes: attributes to apply to portions of the string without substitutions. static func make( fromFormat format: String, attributedFormatArgs formatArgs: [AttributedFormatArg], defaultAttributes: AttributedFormatArg.Attributes = [:], ) -> NSAttributedString { do { // Confirm format string does not contain Unicode isolates, since // we'll be adding those ourselves later. guard !format.contains(where: { c in c == .unicodeFirstStrongIsolate || c == .unicodePopDirectionalIsolate }) else { throw OWSAssertionError("Format string contained unicode isolates!") } // Format the string, and get the placeholders in string order let (formattedCopyWithPlaceholders, placeholdersInStringOrder) = try formatWithPlaceholders( format: format, formatArgs: formatArgs, ) // Build an attributed string from the formatted string, replacing // the placeholder values with substitutions attributed with their // corresponding attributes. let formattedCopyWithAttributes = NSMutableAttributedString() var nextChunkStartIndex = formattedCopyWithPlaceholders.startIndex for (placeholder, range) in placeholdersInStringOrder { // Grab the chunk of the string up to the start of this placeholder... let chunkUpToPlaceholder = formattedCopyWithPlaceholders[nextChunkStartIndex.. ( formattedCopyWithPlaceholders: String, placeholdersInStringOrder: [(placeholder: FormatArgPlaceholder, range: Range)], ) { var placeholders = [FormatArgPlaceholder]() let formattedCopyWithPlaceholders = String( format: format, locale: Locale.current, arguments: formatArgs.map { arg -> CVarArg in switch arg { case let .raw(value): return value case let .string(value, attributes): let placeholder = FormatArgPlaceholder( substitutionToApply: value, attributesToApply: attributes, ) placeholders.append(placeholder) return placeholder.value } }, ) // Find the ranges of the placeholder values, in order let placeholdersInStringOrder = try placeholders .map { placeholder throws -> (placeholder: FormatArgPlaceholder, range: Range) in guard var range = formattedCopyWithPlaceholders.range(of: placeholder.value) else { throw OWSAssertionError("Placeholder value unexpectedly missing from formatted copy") } // iOS may wrap the placeholder in Unicode isolates // automatically if it thinks it should per the locale, // format string, arg, etc. If it did, we want to include // the isolates in the placeholder range. if range.lowerBound > formattedCopyWithPlaceholders.startIndex, range.upperBound < formattedCopyWithPlaceholders.endIndex { let prevIdx = formattedCopyWithPlaceholders.index(before: range.lowerBound) let prevChar = formattedCopyWithPlaceholders[prevIdx] // Because ranges are exclusive of the upper bound, the // "next" char is at the upper bound. This is safe since // we checked against `.endIndex` above. let nextChar = formattedCopyWithPlaceholders[range.upperBound] if prevChar == Character.unicodeFirstStrongIsolate, nextChar == Character.unicodePopDirectionalIsolate { range = prevIdx.. 0 else { // subrange does not exist in range, can apply to entire range. for (key, value) in attributes { addAttribute(key, value: value, range: range) } return } let rangeEnd = NSMaxRange(range) let subrangeToAvoidStart = subrangeToAvoid.location let subrangeToAvoidEnd = NSMaxRange(subrangeToAvoid) if subrangeToAvoidStart > range.location { let before = NSRange( location: range.location, length: subrangeToAvoidStart - range.location, ) for (key, value) in attributes { addAttribute(key, value: value, range: before) } } if subrangeToAvoidEnd < rangeEnd { let after = NSRange( location: subrangeToAvoidEnd, length: rangeEnd - subrangeToAvoidEnd, ) for (key, value) in attributes { addAttribute(key, value: value, range: after) } } } } /// Unicode isolates help us avoid RTL/LTR formatting issues when substituting /// strings into other strings. /// /// See: /// https://www.unicode.org/reports/tr9/#Explicit_Directional_Isolates /// https://en.wikipedia.org/wiki/Bidirectional_text#Isolates private extension Character { static let unicodeFirstStrongIsolate: Character = "\u{2068}" static let unicodePopDirectionalIsolate: Character = "\u{2069}" } /// Equivalent to NSAdaptiveImageGlyph but available prior to iOS 18 being the deployment target. public struct OWSAdaptiveImageGlyph { public let imageContent: Data public let contentIdentifier: String public let contentDescription: String public let contentType: UTType init(imageContent: Data, contentIdentifier: String, contentDescription: String, contentType: UTType) { self.imageContent = imageContent self.contentIdentifier = contentIdentifier self.contentDescription = contentDescription self.contentType = contentType } @available(iOS 18, *) init(_ glyph: NSAdaptiveImageGlyph) { self.init( imageContent: glyph.imageContent, contentIdentifier: glyph.contentIdentifier, contentDescription: glyph.contentDescription, contentType: type(of: glyph).contentType, ) } /// Remove any NSAdaptiveImageGlyph from the passed in attributes, returning our representation of it if present. public static func remove(from attributesParam: inout [NSAttributedString.Key: Any]?) -> OWSAdaptiveImageGlyph? { guard var attributes = attributesParam else { return nil } defer { attributesParam = attributes } if #available(iOS 18, *) { let glyph = attributes.removeValue(forKey: .adaptiveImageGlyph) guard let glyph = glyph as? NSAdaptiveImageGlyph else { return nil } return .init(glyph) } else { return nil } } }