// // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation 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.. 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 } } }