313 lines
13 KiB
Swift
313 lines
13 KiB
Swift
//
|
|
// 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 `%<arg-number>$@` 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..<range.lowerBound]
|
|
formattedCopyWithAttributes.append(
|
|
String(chunkUpToPlaceholder),
|
|
attributes: defaultAttributes,
|
|
)
|
|
|
|
// ...and mark that the next chunk starts at the end of this placeholder.
|
|
nextChunkStartIndex = range.upperBound
|
|
|
|
// Add the substitution and attribute it. Always wrap the
|
|
// substitution in Unicode isolates, to avoid any potential
|
|
// RTL/LTR formatting issues.
|
|
formattedCopyWithAttributes.append(
|
|
"\(Character.unicodeFirstStrongIsolate)\(placeholder.substitutionToApply)\(Character.unicodePopDirectionalIsolate)",
|
|
attributes: placeholder.attributesToApply,
|
|
)
|
|
}
|
|
|
|
let chunkAfterFinalPlaceholder = String(formattedCopyWithPlaceholders[nextChunkStartIndex..<formattedCopyWithPlaceholders.endIndex])
|
|
formattedCopyWithAttributes.append(
|
|
chunkAfterFinalPlaceholder,
|
|
attributes: defaultAttributes,
|
|
)
|
|
|
|
return formattedCopyWithAttributes
|
|
} catch let error {
|
|
// OWSAssertionError has internal logging logic
|
|
if !(error is OWSAssertionError) {
|
|
owsFailDebug("Error: \(error)")
|
|
}
|
|
|
|
Logger.warn("Returning unattributed string.")
|
|
|
|
// If we failed to add the attributes for whatever reason, return
|
|
// an unattributed version.
|
|
|
|
let formattedString = String(
|
|
format: format,
|
|
locale: NSLocale.current,
|
|
arguments: formatArgs.map { $0.fallback },
|
|
)
|
|
|
|
return NSAttributedString(string: formattedString, attributes: defaultAttributes)
|
|
}
|
|
}
|
|
|
|
private struct FormatArgPlaceholder {
|
|
let value: String = UUID().uuidString
|
|
let substitutionToApply: CVarArg
|
|
let attributesToApply: AttributedFormatArg.Attributes
|
|
}
|
|
|
|
private static func formatWithPlaceholders(
|
|
format: String,
|
|
formatArgs: [AttributedFormatArg],
|
|
) throws -> (
|
|
formattedCopyWithPlaceholders: String,
|
|
placeholdersInStringOrder: [(placeholder: FormatArgPlaceholder, range: Range<String.Index>)],
|
|
) {
|
|
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<String.Index>) 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..<formattedCopyWithPlaceholders.index(after: range.upperBound)
|
|
}
|
|
}
|
|
|
|
return (placeholder: placeholder, range: range)
|
|
}.sorted {
|
|
$0.range.lowerBound < $1.range.lowerBound
|
|
}
|
|
|
|
return (
|
|
formattedCopyWithPlaceholders: formattedCopyWithPlaceholders,
|
|
placeholdersInStringOrder: placeholdersInStringOrder,
|
|
)
|
|
}
|
|
}
|
|
|
|
public extension NSMutableAttributedString {
|
|
func applyAttributesToRangeAvoidingSubrange(attributes: [NSAttributedString.Key: Any], range: NSRange, subrangeToAvoid: NSRange) {
|
|
|
|
guard NSIntersectionRange(range, subrangeToAvoid).length > 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
|
|
}
|
|
}
|
|
}
|