├── .github └── FUNDING.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Package.swift ├── README.md ├── Source └── Formatting.swift └── Tests ├── FormattingTests.swift └── PerformanceTests.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: kean 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xcuserstate 23 | 24 | ## Obj-C/Swift specific 25 | *.hmap 26 | *.ipa 27 | *.dSYM.zip 28 | *.dSYM 29 | 30 | ## Playgrounds 31 | timeline.xctimeline 32 | playground.xcworkspace 33 | 34 | # Swift Package Manager 35 | # 36 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 37 | # Packages/ 38 | .build/ 39 | .swiftpm/ 40 | 41 | # CocoaPods 42 | # 43 | # We recommend against adding the Pods directory to your .gitignore. However 44 | # you should judge for yourself, the pros and cons are mentioned at: 45 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 46 | # 47 | # Pods/ 48 | 49 | # Carthage 50 | # 51 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 52 | # Carthage/Checkouts 53 | 54 | Carthage/Build 55 | 56 | # fastlane 57 | # 58 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 59 | # screenshots whenever they are needed. 60 | # For more information about the recommended setup visit: 61 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md 62 | 63 | fastlane/report.xml 64 | fastlane/Preview.html 65 | fastlane/screenshots 66 | fastlane/test_output 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Formatting 0.x 2 | 3 | ## Formatting 0.2 4 | 5 | *Apr 13, 2024* 6 | 7 | - Move previews to the same file as the library and leverage SwiftUI #preview 8 | - Remove CocoaPods support 9 | - Increase minimum supported Xcode version to 14.3 10 | 11 | ## Formatting 0.1.2 12 | 13 | *Jun 24, 2022* 14 | 15 | - Fix build on macOS and add podspec file - [#4](https://github.com/kean/Formatting/pull/4), thanks to [Sergey Kazakov](https://github.com/KazaiMazai) 16 | 17 | ## Formatting 0.1.1 18 | 19 | *Apr 4, 2022* 20 | 21 | - Fix an issue with handling of characters consisting of more than one unicode scalars - [#3](https://github.com/kean/Formatting/issues/3) 22 | 23 | ## Formatting 0.1 24 | 25 | *May 31, 2021* 26 | 27 | Initial version 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2024 Alexander Grebenyuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.8 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Formatting", 7 | platforms: [ 8 | .macOS(.v10_14), 9 | .iOS(.v12), 10 | .tvOS(.v12), 11 | .watchOS(.v5) 12 | ], 13 | products: [ 14 | .library(name: "Formatting", targets: ["Formatting"]) 15 | ], 16 | dependencies: [], 17 | targets: [ 18 | .target(name: "Formatting", dependencies: [], path: "Source"), 19 | .testTarget(name: "FormattingTests", dependencies: ["Formatting"], path: "Tests") 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Formatting 2 | 3 | An example code for [kean.blog: Formatted Localizable Strings](https://kean.blog/post/formatted-strings). Demonstrates how to implement basic string formatting using XML tags. 4 | 5 | ```swift 6 | let input = "M1 delivers up to 2.8x faster processing performance than the previous generation." 7 | let text = String(format: input, "https://support.apple.com/kb/SP799") 8 | let style = FormattedStringStyle(attributes: [ 9 | "body": [.font: UIFont.systemFont(ofSize: 15)], 10 | "b": [.font: UIFont.boldSystemFont(ofSize: 15)], 11 | "a": [.underlineColor: UIColor.clear] 12 | ]) 13 | label.attributedText = NSAttributedString(formatting: text, style: style) 14 | ``` 15 | 16 | Result using standard `UILabel`: 17 | 18 | ![Screen Shot 2020-11-29 at 18 07 03](https://user-images.githubusercontent.com/1567433/100556269-29dc6380-326f-11eb-8afe-769d48706362.png) 19 | 20 | ## Minimum Requirements 21 | 22 | | Versio | Swift | Xcode | Platforms | 23 | |-----------------|-----------|-------------|----------------------------------------------| 24 | | Formatting 4.0 | Swift 5.8 | Xcode 14.3 | iOS 12.0, tvOS 12.0, watchOS 5.0, macOS 10.5 | 25 | 26 | # License 27 | 28 | Formatting is available under the MIT license. See the LICENSE file for more info. 29 | -------------------------------------------------------------------------------- /Source/Formatting.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | #if !os(macOS) 7 | import UIKit 8 | #else 9 | import AppKit 10 | #endif 11 | 12 | public extension NSAttributedString { 13 | /// Initializes the string with the given formatted string. 14 | /// 15 | /// ``` 16 | /// let string = """ 17 | /// MacBook Pro. Power, Moves. Learn more. 18 | /// """ 19 | /// let style = FormattedStringStyle(attributes: [ 20 | /// ["body": [.font: UIFont.systemFont(ofSize: 16)]], 21 | /// ["b": [.font: UIFont.boldSystemFont(ofSize: 16)]] 22 | /// ]) 23 | /// let _ = NSAttriburedString(formatting: string, style: style) 24 | /// ``` 25 | /// 26 | /// Thread safe. 27 | convenience init(formatting string: String, style: FormattedStringStyle) { 28 | let parser = Parser(style: style) 29 | do { 30 | let output = try parser.parse(string) 31 | self.init(attributedString: output) 32 | } catch { 33 | self.init(string: string, attributes: style.attributes(forElement: "body", attributes: [:])) 34 | } 35 | } 36 | } 37 | 38 | public struct FormattedStringStyle { 39 | private var attributes = [String: [NSAttributedString.Key: Any]]() 40 | 41 | public init(attributes: [String: [NSAttributedString.Key: Any]]) { 42 | self.attributes = attributes 43 | } 44 | 45 | func attributes(forElement element: String, attributes: [String: String]) -> [NSAttributedString.Key: Any]? { 46 | self.attributes[element] 47 | } 48 | } 49 | 50 | private final class Parser: NSObject, XMLParserDelegate { 51 | private var text = "" 52 | private let style: FormattedStringStyle 53 | private var elements = [Element]() 54 | private var attributes = [(NSRange, [NSAttributedString.Key: Any])]() 55 | private var parseError: Error? 56 | 57 | private struct Element { 58 | let name: String 59 | let startOffset: Int 60 | let attributes: [NSAttributedString.Key: Any] 61 | } 62 | 63 | init(style: FormattedStringStyle) { 64 | self.style = style 65 | } 66 | 67 | func parse(_ string: String) throws -> NSAttributedString { 68 | guard let data = preprocess(string).data(using: .utf8) else { 69 | throw NSError(domain: "com.github.parser", code: -1, userInfo: [NSDebugDescriptionErrorKey: "Failed to process the input string"]) 70 | } 71 | let parser = XMLParser(data: data) 72 | parser.delegate = self 73 | parser.parse() 74 | if let parseError = self.parseError { 75 | throw parseError 76 | } 77 | return makeAttributedString() 78 | } 79 | 80 | private static let hrefRegex = try? Regex("]*?href=\"([^\"]+)\">") 81 | 82 | private func preprocess(_ string: String) -> String { 83 | var string = string 84 | 85 | // Replaces '
' with "line separtor" (doesn't separate paragraphs). 86 | // To separate paragraphs, use '\b'. 87 | string = string.replacingOccurrences(of: "
", with: "\u{2028}", options: .regularExpression, range: nil) 88 | 89 | // Sanitize URLs by replacing & (unsupported in XML and strict HTML) with & 90 | string = preprocessLinks(string) 91 | 92 | // Enclose the string in a `` tag to make it proper XML. 93 | return "\(string)" 94 | } 95 | 96 | private func preprocessLinks(_ string: String) -> String { 97 | guard let regex = Parser.hrefRegex else { 98 | return string 99 | } 100 | return regex.replaceMatches(in: string, sanitizeURL) 101 | } 102 | 103 | private func sanitizeURL(_ url: Substring) -> String? { 104 | guard url.contains("&") else { 105 | return nil 106 | } 107 | guard var comp = URLComponents(string: String(url)) else { 108 | return nil 109 | } 110 | let query = (comp.queryItems ?? []) 111 | .map { "\($0.name)=\($0.value ?? "")" } 112 | .joined(separator: "&") 113 | comp.queryItems = nil 114 | var output = comp.url?.absoluteString 115 | if !query.isEmpty { 116 | output?.append("?\(query)") 117 | } 118 | return output 119 | } 120 | 121 | private func makeAttributedString() -> NSAttributedString { 122 | let output = NSMutableAttributedString(string: text) 123 | // Apply tags in reverse, more specific tags are applied last. 124 | for (range, attributes) in attributes.reversed() { 125 | let lb = text.index(text.startIndex, offsetBy: range.lowerBound) 126 | let ub = text.index(text.startIndex, offsetBy: range.upperBound) 127 | let range = NSRange(lb.. Bool { 178 | let range = NSRange(s.startIndex.. [Match] { 183 | let range = NSRange(s.startIndex.. String?) -> String { 197 | var offset = 0 198 | var string = string 199 | for group in matches(in: string).flatMap(\.groups) { 200 | guard let replacement = transform(group) else { 201 | continue 202 | } 203 | let startIndex = string.index(group.startIndex, offsetBy: offset) 204 | let endIndex = string.index(group.endIndex, offsetBy: offset) 205 | string.replaceSubrange(startIndex..=5.9) && DEBUG 220 | @available(iOS 17, *) 221 | #Preview { 222 | let label = UILabel() 223 | label.textColor = .black 224 | label.numberOfLines = 0 225 | label.textAlignment = .center 226 | 227 | let input = "M1 delivers up to 2.8x faster processing performance than the previous generation." 228 | let text = String(format: input, "https://support.apple.com/kb/SP799") 229 | let style = FormattedStringStyle(attributes: [ 230 | "body": [.font: UIFont.systemFont(ofSize: 15)], 231 | "b": [.font: UIFont.boldSystemFont(ofSize: 15)], 232 | "a": [.underlineColor: UIColor.clear] 233 | ]) 234 | label.attributedText = NSAttributedString(formatting: text, style: style) 235 | 236 | return label 237 | } 238 | #endif 239 | -------------------------------------------------------------------------------- /Tests/FormattingTests.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import XCTest 6 | import Formatting 7 | 8 | class FormattingsTests: XCTestCase { 9 | 10 | func testBodyFont() throws { 11 | // GIVEN 12 | let style = FormattedStringStyle(attributes: [ 13 | "body": [.font: UIFont(name: "HelveticaNeue-Light", size: 20)!] 14 | ]) 15 | 16 | // WHEN 17 | let input = "Hello" 18 | let output = NSAttributedString(formatting: input, style: style) 19 | 20 | // THEN 21 | let allAttributes = output.attributes 22 | XCTAssertEqual(allAttributes.count, 1) 23 | 24 | do { 25 | let body = try XCTUnwrap(allAttributes.first { $0.range == NSRange(0..<5) }?.attributes) 26 | XCTAssertEqual(body.count, 1) 27 | 28 | let font = try XCTUnwrap(body[.font] as? UIFont) 29 | XCTAssertEqual(font.fontName, "HelveticaNeue-Light") 30 | XCTAssertEqual(font.pointSize, 20) 31 | } 32 | } 33 | 34 | func testBoldFont() throws { 35 | // GIVEN 36 | let style = FormattedStringStyle(attributes: [ 37 | "body": [.font: UIFont(name: "HelveticaNeue-Light", size: 20)!], 38 | "b": [.font: UIFont(name: "HelveticaNeue-Medium", size: 20)!] 39 | ]) 40 | 41 | // WHEN 42 | let input = "Hello World" 43 | let output = NSAttributedString(formatting: input, style: style) 44 | 45 | // THEN 46 | let allAttributes = output.attributes 47 | XCTAssertEqual(allAttributes.count, 2) 48 | 49 | do { 50 | let body = try XCTUnwrap(allAttributes.first { $0.range == NSRange(0..<6) }?.attributes) 51 | XCTAssertEqual(body.count, 1) 52 | 53 | let font = try XCTUnwrap(body[.font] as? UIFont) 54 | XCTAssertEqual(font.fontName, "HelveticaNeue-Light") 55 | XCTAssertEqual(font.pointSize, 20) 56 | } 57 | 58 | do { 59 | let bold = try XCTUnwrap(allAttributes.first { $0.range == NSRange(6..<11) }?.attributes) 60 | XCTAssertEqual(bold.count, 1) 61 | 62 | let font = try XCTUnwrap(bold[.font] as? UIFont) 63 | XCTAssertEqual(font.fontName, "HelveticaNeue-Medium") 64 | XCTAssertEqual(font.pointSize, 20) 65 | } 66 | } 67 | 68 | func testLink() throws { 69 | // GIVEN 70 | let style = FormattedStringStyle(attributes: [ 71 | "body": [.font: UIFont(name: "HelveticaNeue-Light", size: 20)!] 72 | ]) 73 | 74 | // WHEN 75 | let input = "Tap this" 76 | let output = NSAttributedString(formatting: input, style: style) 77 | 78 | // THEN 79 | let allAttributes = output.attributes 80 | XCTAssertEqual(allAttributes.count, 2) 81 | 82 | do { 83 | let body = try XCTUnwrap(allAttributes.first { $0.range == NSRange(0..<4) }?.attributes) 84 | XCTAssertEqual(body.count, 1) 85 | 86 | let font = try XCTUnwrap(body[.font] as? UIFont) 87 | XCTAssertEqual(font.fontName, "HelveticaNeue-Light") 88 | XCTAssertEqual(font.pointSize, 20) 89 | } 90 | 91 | do { 92 | let link = try XCTUnwrap(allAttributes.first { $0.range == NSRange(4..<8) }?.attributes) 93 | XCTAssertEqual(link.count, 2) 94 | 95 | let url = try XCTUnwrap(link[.link] as? URL) 96 | XCTAssertEqual(url.absoluteString, "https://google.com") 97 | 98 | let font = try XCTUnwrap(link[.font] as? UIFont) 99 | XCTAssertEqual(font.fontName, "HelveticaNeue-Light") 100 | XCTAssertEqual(font.pointSize, 20) 101 | } 102 | } 103 | 104 | func testLinkWithAmpersands() throws { 105 | // GIVEN 106 | let style = FormattedStringStyle(attributes: [ 107 | "body": [.font: UIFont(name: "HelveticaNeue-Light", size: 20)!] 108 | ]) 109 | 110 | // WHEN 111 | let input = "Tap this" 112 | let output = NSAttributedString(formatting: input, style: style) 113 | 114 | // THEN 115 | let allAttributes = output.attributes 116 | XCTAssertEqual(allAttributes.count, 2) 117 | 118 | XCTAssertEqual(output.string, "Tap this") 119 | 120 | do { 121 | let body = try XCTUnwrap(allAttributes.first { $0.range == NSRange(0..<4) }?.attributes) 122 | XCTAssertEqual(body.count, 1) 123 | 124 | let font = try XCTUnwrap(body[.font] as? UIFont) 125 | XCTAssertEqual(font.fontName, "HelveticaNeue-Light") 126 | XCTAssertEqual(font.pointSize, 20) 127 | } 128 | 129 | do { 130 | let link = try XCTUnwrap(allAttributes.first { $0.range == NSRange(4..<8) }?.attributes) 131 | XCTAssertEqual(link.count, 2) 132 | 133 | let url = try XCTUnwrap(link[.link] as? URL) 134 | XCTAssertEqual(url.absoluteString, "https://google.com?a=1&b=2") 135 | 136 | let font = try XCTUnwrap(link[.font] as? UIFont) 137 | XCTAssertEqual(font.fontName, "HelveticaNeue-Light") 138 | XCTAssertEqual(font.pointSize, 20) 139 | } 140 | } 141 | 142 | func testLineBreaks() throws { 143 | // GIVEN 144 | let style = FormattedStringStyle(attributes: [ 145 | "body": [.font: UIFont(name: "HelveticaNeue-Light", size: 20)!] 146 | ]) 147 | 148 | func format(_ string: String) -> String { 149 | NSAttributedString(formatting: string, style: style).string 150 | } 151 | 152 | // WHEN/THEN 153 | XCTAssertEqual(format("a
b"), "a\u{2028}b") 154 | XCTAssertEqual(format("a
b"), "a\u{2028}b") 155 | XCTAssertEqual(format("a
b"), "a\u{2028}b") 156 | } 157 | 158 | // This used to result in a crash https://github.com/kean/Formatting/issues/1 159 | // because of the String index mutation during `append` 160 | func testCyrillicText() throws { 161 | // GIVEN 162 | let style = FormattedStringStyle(attributes: [ 163 | "body": [.font: UIFont(name: "HelveticaNeue-Light", size: 20)!], 164 | "b": [.font: UIFont(name: "HelveticaNeue-Medium", size: 20)!] 165 | ]) 166 | 167 | // WHEN 168 | let input = "Если вы забыли свой пароль на новом устройстве, вам также будет отправлен 6-значный код проверки" 169 | let output = NSAttributedString(formatting: input, style: style) 170 | 171 | // THEN 172 | let allAttributes = output.attributes 173 | XCTAssertEqual(allAttributes.count, 2) 174 | 175 | do { 176 | let body = try XCTUnwrap(allAttributes.first { $0.range == NSRange(0..<74) }?.attributes) 177 | XCTAssertEqual(body.count, 1) 178 | 179 | let font = try XCTUnwrap(body[.font] as? UIFont) 180 | XCTAssertEqual(font.fontName, "HelveticaNeue-Light") 181 | XCTAssertEqual(font.pointSize, 20) 182 | } 183 | 184 | do { 185 | let bold = try XCTUnwrap(allAttributes.first { $0.range == NSRange(74..<96) }?.attributes) 186 | XCTAssertEqual(bold.count, 1) 187 | 188 | let font = try XCTUnwrap(bold[.font] as? UIFont) 189 | XCTAssertEqual(font.fontName, "HelveticaNeue-Medium") 190 | XCTAssertEqual(font.pointSize, 20) 191 | } 192 | } 193 | 194 | func testEmoji() throws { 195 | // GIVEN 196 | let style = FormattedStringStyle(attributes: [ 197 | "t": [.foregroundColor: UIColor.red] 198 | ]) 199 | 200 | // WHEN 201 | // important: ⚠️ contains two unicode scalars 202 | let input = "⚠️ Text with emoji" 203 | let output = NSAttributedString(formatting: input, style: style) 204 | 205 | // THEN 206 | let allAttributes = output.attributes 207 | XCTAssertEqual(allAttributes.count, 2) 208 | 209 | let colorAttribute = try XCTUnwrap(allAttributes.first { $0.attributes.first?.key == NSAttributedString.Key.foregroundColor }) 210 | let range = colorAttribute.range 211 | XCTAssertEqual((output.string as NSString).substring(with: range), "emoji") 212 | } 213 | } 214 | 215 | private extension NSAttributedString { 216 | var attributes: [(range: NSRange, attributes: [NSAttributedString.Key: Any])] { 217 | var output = [(NSRange, [NSAttributedString.Key: Any])]() 218 | var range = NSRange() 219 | var index = 0 220 | 221 | while index < length { 222 | let attributes = self.attributes(at: index, effectiveRange: &range) 223 | output.append((range, attributes)) 224 | index = max(index + 1, Range(range)?.endIndex ?? 0) 225 | } 226 | return output 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /Tests/PerformanceTests.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import XCTest 6 | import Formatting 7 | 8 | class FormattingsPerformanceTests: XCTestCase { 9 | let input = """ 10 | Let's test bold.

And a link google. 11 | """ 12 | 13 | func testFormattingPerformance() { 14 | let style = FormattedStringStyle(attributes: [ 15 | "body": [.font: UIFont(name: "HelveticaNeue-Light", size: 20)!] 16 | ]) 17 | 18 | measure { 19 | for _ in 0...50 { 20 | let _ = NSAttributedString(formatting: input, style: style) 21 | } 22 | } 23 | } 24 | 25 | func testHTMLParsingPerformance() { 26 | let data = input.data(using: .utf8)! 27 | measure { 28 | for _ in 0...50 { 29 | let _ = try! NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) 30 | } 31 | } 32 | } 33 | } 34 | --------------------------------------------------------------------------------