├── .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 | 
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 |
--------------------------------------------------------------------------------