├── .github
└── workflows
│ ├── ci.yml
│ └── documentation.yml
├── .gitignore
├── LICENSE.md
├── Package.resolved
├── Package.swift
├── README.md
├── Sources
└── CommonMarkAttributedString
│ ├── Array+Extensions.swift
│ ├── AttributedStringConvertible.swift
│ ├── CommonMark+Extensions.swift
│ ├── NSAttributedString+Extensions.swift
│ ├── NSFont+Extensions.swift
│ └── UIFont+Extensions.swift
└── Tests
├── CommonMarkAttributedStringTests
└── CommonMarkAttributedStringTests.swift
└── LinuxMain.swift
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | macos:
11 | runs-on: macos-latest
12 |
13 | strategy:
14 | matrix:
15 | destination:
16 | - platform=macOS
17 | - platform=iOS Simulator,name=iPhone 11
18 | - platform=tvOS Simulator,name=Apple TV
19 |
20 | steps:
21 | - name: Checkout
22 | uses: actions/checkout@v1
23 | - name: Generate Xcode Project
24 | run: swift package generate-xcodeproj
25 | - name: Run Test Target
26 | run: xcodebuild test -destination '${{ matrix.destination }}' -scheme CommonMarkAttributedString-Package
27 |
--------------------------------------------------------------------------------
/.github/workflows/documentation.yml:
--------------------------------------------------------------------------------
1 | name: Documentation
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | paths:
8 | - .github/workflows/documentation.yml
9 | - Sources/**.swift
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v1
18 | - name: Generate Documentation
19 | uses: SwiftDocOrg/swift-doc@master
20 | with:
21 | inputs: "Sources"
22 | output: "Documentation"
23 | - name: Upload Documentation to Wiki
24 | uses: SwiftDocOrg/github-wiki-publish-action@master
25 | with:
26 | path: "Documentation"
27 | env:
28 | GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GITHUB_PERSONAL_ACCESS_TOKEN }}
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | .swiftpm
7 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020 Mattt (https://mat.tt)
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "CommonMark",
6 | "repositoryURL": "https://github.com/SwiftDocOrg/CommonMark.git",
7 | "state": {
8 | "branch": null,
9 | "revision": "9ff339ed62d7ca4b227262bf4c4de6288c5ec3e8",
10 | "version": "0.3.0"
11 | }
12 | },
13 | {
14 | "package": "cmark",
15 | "repositoryURL": "https://github.com/SwiftDocOrg/swift-cmark.git",
16 | "state": {
17 | "branch": null,
18 | "revision": "2a766030bee955b4806044fd7aca1b6884475138",
19 | "version": "0.28.3+20200110.2a76603"
20 | }
21 | }
22 | ]
23 | },
24 | "version": 1
25 | }
26 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.1
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "CommonMarkAttributedString",
8 | platforms: [
9 | .macOS(.v10_10),
10 | .iOS(.v9),
11 | .tvOS(.v9)
12 | ],
13 | products: [
14 | .library(
15 | name: "CommonMarkAttributedString",
16 | targets: ["CommonMarkAttributedString"]),
17 | ],
18 | dependencies: [
19 | .package(url: "https://github.com/SwiftDocOrg/CommonMark.git", .upToNextMinor(from: "0.3.0")),
20 | ],
21 | targets: [
22 | .target(
23 | name: "CommonMarkAttributedString",
24 | dependencies: ["CommonMark"]),
25 | .testTarget(
26 | name: "CommonMarkAttributedStringTests",
27 | dependencies: ["CommonMarkAttributedString"]),
28 | ]
29 | )
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CommonMarkAttributedString
2 |
3 | ![CI][ci badge]
4 | [![Documentation][documentation badge]][documentation]
5 |
6 | **CommonMarkAttributedString** is a Swift package that lets you
7 | create attributed strings using familiar CommonMark (Markdown) syntax.
8 | It's built on top of [CommonMark][commonmark],
9 | which is fully compliant with the [CommonMark Spec][commonmark spec].
10 |
11 | ## Supported Platforms
12 |
13 | - macOS 10.10+
14 | - Mac Catalyst 13.0+
15 | - iOS 9.0+
16 | - tvOS 9.0+
17 |
18 | ## Usage
19 |
20 | ```swift
21 | import CommonMarkAttributedString
22 |
23 | let commonmark = "A *bold* way to add __emphasis__ to your `code`"
24 |
25 | let attributes: [NSAttributedString.Key: Any] = [
26 | .font: NSFont.systemFont(ofSize: 24.0),
27 | .foregroundColor: NSColor.systemBlue,
28 | ]
29 |
30 | let attributedString = try NSAttributedString(commonmark: commonmark, attributes: attributes)
31 | ```
32 |
33 | ![Result][screenshot-1]
34 |
35 | You can also use CommonMarkAttributedString
36 | to create attributed strings that have multiple paragraphs,
37 | with links, headings, lists, and images.
38 |
39 | ```swift
40 | let commonmark = #"""
41 | # [Universal Declaration of Human Rights][uhdr]
42 |
43 | ## Article 1.
44 |
45 | All human beings are born free and equal in dignity and rights.
46 | They are endowed with reason and conscience
47 | and should act towards one another in a spirit of brotherhood.
48 |
49 | [uhdr]: https://www.un.org/en/universal-declaration-human-rights/ "View full version"
50 | """#
51 |
52 | let attributes: [NSAttributedString.Key: Any] = [
53 | .font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
54 | .foregroundColor: NSColor.textColor,
55 | .backgroundColor: NSColor.textBackgroundColor,
56 | ]
57 |
58 | let attributedString = try NSAttributedString(commonmark: commonmark, attributes: attributes)
59 | ```
60 |
61 | ![Result][screenshot-2]
62 |
63 | ## Supported CommonMark Elements
64 |
65 | - [x] `Code`
66 | - [x] _Emphasis_
67 | - [x] [Link](#) _(inline links, link references, and autolinks)_
68 | - [x] **Strong**
69 | - [x] > Block Quotes
70 | - [x] Headings
71 | - [x] Raw `` *
72 | - [x] • Bulleted Lists *
73 | - [x] 1. Ordered Lists *
74 | - [x] 🖼 Images *
75 |
76 | ### Raw Inline HTML
77 |
78 | According to the [CommonMark specification][commonmark spec § 6.8],
79 | each inline HTML tag is considered its own element.
80 | That is to say,
81 | CommonMark doesn't have a concept of opening or closing tags.
82 | So, for example,
83 | the CommonMark string `hello`
84 | corresponds to a paragraph block containing three inline elements:
85 |
86 | - `Code` (``)
87 | - `Text` (`hello`)
88 | - `Code` (``)
89 |
90 | Parsing and rendering HTML is out of scope for this library,
91 | so whenever CommonMarkAttributedString receives text containing any HTML,
92 | it falls back on `NSAttributedString`'s built-in HTML initializer.
93 |
94 | ### Bulleted and Ordered Lists
95 |
96 | CommonMarkAttributedString renders bulleted and ordered lists
97 | using conventional indentation and markers ---
98 | disc (•), circle(◦), and square (■)
99 | for unordered lists
100 | and
101 | decimal numerals (1.), lowercase roman numerals (i.), and lowercase letters (a.)
102 | for ordered lists.
103 |
104 | - Level 1
105 | - Level 2
106 | - Level 3
107 |
108 |
109 |
110 | 1. Level 1
111 | 1. Level 2
112 | 1. Level 3
113 |
114 |
115 | ### Images
116 |
117 | Attributed strings can embed images using the `NSTextAttachment` class.
118 | However,
119 | there's no built-in way to load images asynchronously.
120 | Rather than load images synchronously as they're encountered in CommonMark text,
121 | CommonMarkAttributedString provides an optional `attachments` parameter
122 | that you can use to associate existing text attachments
123 | with image URL strings.
124 |
125 | ```swift
126 | let commonmark = ""
127 |
128 | let attachments: [String: NSTextAttachment] = [
129 | "https://example.com/image.png": NSTextAttachment(data: <#...#>, ofType: "public.png")
130 | ]
131 |
132 | let attributedString = try NSAttributedString(commonmark: commonmark, attributes: attributes, attachments: attachments)
133 | ```
134 |
135 |
136 | ## Requirements
137 |
138 | - Swift 5.1+
139 |
140 | ## Installation
141 |
142 | ### Swift Package Manager
143 |
144 | Add the CommonMarkAttributedString package to your target dependencies in `Package.swift`:
145 |
146 | ```swift
147 | import PackageDescription
148 |
149 | let package = Package(
150 | name: "YourProject",
151 | dependencies: [
152 | .package(
153 | url: "https://github.com/mattt/CommonMarkAttributedString",
154 | from: "0.2.0"
155 | ),
156 | ]
157 | )
158 | ```
159 |
160 | Then run the `swift build` command to build your project.
161 |
162 | ## License
163 |
164 | MIT
165 |
166 | ## Contact
167 |
168 | Mattt ([@mattt](https://twitter.com/mattt))
169 |
170 | [commonmark]: https://github.com/SwiftDocOrg/CommonMark
171 | [commonmark spec]: https://spec.commonmark.org
172 | [commonmark spec § 6.8]: https://spec.commonmark.org/0.29/#raw-html
173 |
174 | [screenshot-1]: https://user-images.githubusercontent.com/7659/76089806-35fcf400-5f6f-11ea-934c-b676b6af99cf.png
175 | [screenshot-2]: https://user-images.githubusercontent.com/7659/76094168-fe924580-5f76-11ea-821b-aa2f07c0e21b.png
176 |
177 | [ci badge]: https://github.com/mattt/CommonMarkAttributedString/workflows/CI/badge.svg
178 | [documentation badge]: https://github.com/mattt/CommonMarkAttributedString/workflows/Documentation/badge.svg
179 | [documentation]: https://github.com/mattt/CommonMarkAttributedString/wiki
180 |
--------------------------------------------------------------------------------
/Sources/CommonMarkAttributedString/Array+Extensions.swift:
--------------------------------------------------------------------------------
1 | #if canImport(AppKit)
2 | import class AppKit.NSAttributedString
3 | import class AppKit.NSMutableAttributedString
4 | #elseif canImport(UIKit)
5 | import class UIKit.NSAttributedString
6 | import class UIKit.NSMutableAttributedString
7 | #endif
8 |
9 | extension Array where Element == NSAttributedString {
10 | func joined(separator: String? = nil) -> NSAttributedString {
11 | guard let first = first else { return NSAttributedString() }
12 | guard count > 1 else { return first }
13 |
14 | return suffix(from: startIndex.advanced(by: 1)).reduce(NSMutableAttributedString(attributedString: first)) { (mutableAttributedString, attributedString) in
15 | if let separator = separator {
16 | mutableAttributedString.append(NSAttributedString(string: separator))
17 | }
18 |
19 | mutableAttributedString.append(attributedString)
20 |
21 | return mutableAttributedString
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Sources/CommonMarkAttributedString/AttributedStringConvertible.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | #if canImport(UIKit)
4 | import class UIKit.NSTextAttachment
5 | #elseif canImport(AppKit)
6 | import class AppKit.NSTextAttachment
7 | #endif
8 |
9 | protocol AttributedStringConvertible {
10 | func attributes(with attributes: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any]
11 | func attributedString(attributes: [NSAttributedString.Key: Any], attachments: [String: NSTextAttachment]) throws -> NSAttributedString
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/CommonMarkAttributedString/CommonMark+Extensions.swift:
--------------------------------------------------------------------------------
1 | import CommonMark
2 | import Foundation
3 | import class Foundation.NSAttributedString
4 | import struct CoreGraphics.CGFloat
5 |
6 | #if canImport(UIKit)
7 | import class UIKit.UIFont
8 | import class UIKit.NSTextAttachment
9 | #elseif canImport(AppKit)
10 | import class AppKit.NSFont
11 | import class AppKit.NSTextAttachment
12 | import class AppKit.NSTextList
13 | #endif
14 |
15 | // MARK: -
16 |
17 | extension Node: AttributedStringConvertible {
18 | @objc func attributes(with attributes: [NSAttributedString.Key : Any]) -> [NSAttributedString.Key : Any] {
19 | return attributes
20 | }
21 |
22 | @objc func attributedString(attributes: [NSAttributedString.Key: Any], attachments: [String: NSTextAttachment]) throws -> NSAttributedString {
23 | let attributes = self.attributes(with: attributes)
24 |
25 | switch self {
26 | case is SoftLineBreak:
27 | return NSAttributedString(string: " ", attributes: attributes)
28 | case is HardLineBreak, is ThematicBreak:
29 | return NSAttributedString(string: "\u{2028}", attributes: attributes)
30 | case let literal as Literal:
31 | return NSAttributedString(string: literal.literal ?? "", attributes: attributes)
32 | case let container as ContainerOfBlocks:
33 | guard !container.children.contains(where: { $0 is HTMLBlock }) else {
34 | let html = try Document(container.description).render(format: .html)
35 | return try NSAttributedString(html: html, attributes: attributes) ?? NSAttributedString()
36 | }
37 |
38 | return try container.children.map { try $0.attributedString(attributes: attributes, attachments: attachments) }.joined(separator: "\u{2029}")
39 | case let container as ContainerOfInlineElements:
40 | guard !container.children.contains(where: { $0 is RawHTML }) else {
41 | let html = try Document(container.description).render(format: .html)
42 | return try NSAttributedString(html: html, attributes: attributes) ?? NSAttributedString()
43 | }
44 |
45 | return try container.children.map { try $0.attributedString(attributes: attributes, attachments: attachments) }.joined()
46 | case let list as List:
47 | return try list.children.enumerated().map { try $1.attributedString(in: list, at: $0, attributes: attributes, attachments: attachments) }.joined(separator: "\u{2029}")
48 | default:
49 | return NSAttributedString()
50 | }
51 | }
52 | }
53 |
54 | // MARK: Block Elements
55 |
56 | extension BlockQuote {
57 | override func attributes(with attributes: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] {
58 | var attributes = attributes
59 |
60 | #if canImport(UIKit)
61 | let font = attributes[.font] as? UIFont ?? UIFont.preferredFont(forTextStyle: .body)
62 | attributes[.font] = font.addingSymbolicTraits(.traitItalic)
63 | #elseif canImport(AppKit)
64 | let font = attributes[.font] as? NSFont ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)
65 | attributes[.font] = font.addingSymbolicTraits(.italic)
66 | #endif
67 |
68 | return attributes
69 | }
70 | }
71 |
72 | extension CodeBlock {
73 | override func attributes(with attributes: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] {
74 | var attributes = attributes
75 |
76 | #if canImport(UIKit)
77 | let font = attributes[.font] as? UIFont ?? UIFont.preferredFont(forTextStyle: .body)
78 | attributes[.font] = font.monospaced
79 | #elseif canImport(AppKit)
80 | let font = attributes[.font] as? NSFont ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)
81 | attributes[.font] = font.monospaced
82 | #endif
83 |
84 | return attributes
85 | }
86 | }
87 |
88 | extension Heading {
89 | private var fontSizeMultiplier: CGFloat {
90 | switch level {
91 | case 1: return 2.00
92 | case 2: return 1.50
93 | case 3: return 1.17
94 | case 4: return 1.00
95 | case 5: return 0.83
96 | case 6: return 0.67
97 | default:
98 | return 1.00
99 | }
100 | }
101 |
102 | override func attributes(with attributes: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] {
103 | var attributes = attributes
104 |
105 | #if canImport(UIKit)
106 | let font = attributes[.font] as? UIFont ?? UIFont.preferredFont(forTextStyle: .body)
107 | attributes[.font] = UIFont(descriptor: font.fontDescriptor, size: font.pointSize * fontSizeMultiplier).addingSymbolicTraits(.traitBold)
108 | #elseif canImport(AppKit)
109 | let font = attributes[.font] as? NSFont ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)
110 | attributes[.font] = NSFont(descriptor: font.fontDescriptor, size: font.pointSize * fontSizeMultiplier)?.addingSymbolicTraits(.bold)
111 | #endif
112 |
113 | return attributes
114 | }
115 | }
116 |
117 | extension List {
118 | fileprivate var nestingLevel: Int {
119 | sequence(first: self) { $0.parent }.map { ($0 is List) ? 1 : 0}.reduce(0, +)
120 | }
121 |
122 | fileprivate var markerLevel: Int {
123 | sequence(first: self) { $0.parent }.map { ($0 as? List)?.kind == kind ? 1 : 0}.reduce(0, +)
124 | }
125 | }
126 |
127 | extension List.Item {
128 | private func ordinal(at position: Int) -> String {
129 | "\(position + 1)."
130 | }
131 |
132 | // TODO: Represent lists with NSTextList on macOS
133 | fileprivate func attributedString(in list: List, at position: Int, attributes: [NSAttributedString.Key: Any], attachments: [String: NSTextAttachment]) throws -> NSAttributedString {
134 |
135 | var delimiter: String = list.kind == .ordered ? "\(position + 1)." : "•"
136 | #if os(macOS) && canImport(AppKit)
137 | if #available(OSX 10.13, *) {
138 | let format: NSTextList.MarkerFormat
139 | switch (list.kind, list.markerLevel) {
140 | case (.bullet, 1): format = .disc
141 | case (.bullet, 2): format = .circle
142 | case (.bullet, _): format = .square
143 | case (.ordered, 1): format = .decimal
144 | case (.ordered, 2): format = .lowercaseAlpha
145 | case (.ordered, _): format = .lowercaseRoman
146 | }
147 |
148 | delimiter = NSTextList(markerFormat: format, options: 0).marker(forItemNumber: position + 1)
149 | }
150 | #endif
151 |
152 | let indentation = String(repeating: "\t", count: list.nestingLevel)
153 |
154 | let mutableAttributedString = NSMutableAttributedString(string: indentation + delimiter + " ", attributes: attributes)
155 | mutableAttributedString.append(try children.map { try $0.attributedString(attributes: attributes, attachments: attachments) }.joined(separator: "\u{2029}"))
156 | return mutableAttributedString
157 | }
158 | }
159 |
160 | // MARK: Inline Elements
161 |
162 | extension Code {
163 | override func attributes(with attributes: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] {
164 | var attributes = attributes
165 |
166 | #if canImport(UIKit)
167 | let font = attributes[.font] as? UIFont ?? UIFont.preferredFont(forTextStyle: .body)
168 | attributes[.font] = font.monospaced
169 | #elseif canImport(AppKit)
170 | let font = attributes[.font] as? NSFont ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)
171 | attributes[.font] = font.monospaced
172 | #endif
173 |
174 | return attributes
175 | }
176 | }
177 |
178 | extension Emphasis {
179 | override func attributes(with attributes: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] {
180 | var attributes = attributes
181 |
182 | #if canImport(UIKit)
183 | let font = attributes[.font] as? UIFont ?? UIFont.preferredFont(forTextStyle: .body)
184 | attributes[.font] = font.addingSymbolicTraits(.traitItalic)
185 | #elseif canImport(AppKit)
186 | let font = attributes[.font] as? NSFont ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)
187 | attributes[.font] = font.addingSymbolicTraits(.italic)
188 | #endif
189 |
190 |
191 | return attributes
192 | }
193 | }
194 |
195 | extension Image {
196 | override func attributedString(attributes: [NSAttributedString.Key: Any], attachments: [String: NSTextAttachment]) throws -> NSAttributedString {
197 | guard let urlString = urlString else { return NSAttributedString() }
198 | guard let attachment = attachments[urlString] else { fatalError("missing attachment for \(urlString)") }
199 | return NSAttributedString(attachment: attachment)
200 | }
201 | }
202 |
203 | extension Link {
204 | override func attributes(with attributes: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] {
205 | var attributes = attributes
206 |
207 | if let urlString = urlString, let url = URL(string: urlString) {
208 | attributes[.link] = url
209 | }
210 |
211 | #if os(macOS) && canImport(AppKit)
212 | if let title = title {
213 | attributes[.toolTip] = title
214 | }
215 | #endif
216 |
217 | return attributes
218 | }
219 | }
220 |
221 | extension Strong {
222 | override func attributes(with attributes: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] {
223 | var attributes = attributes
224 |
225 | #if canImport(UIKit)
226 | let font = attributes[.font] as? UIFont ?? UIFont.preferredFont(forTextStyle: .body)
227 | attributes[.font] = font.addingSymbolicTraits(.traitBold)
228 | #elseif canImport(AppKit)
229 | let font = attributes[.font] as? NSFont ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)
230 | attributes[.font] = font.addingSymbolicTraits(.bold)
231 | #endif
232 |
233 | return attributes
234 | }
235 | }
236 |
237 | extension Text {
238 | override func attributedString(attributes: [NSAttributedString.Key: Any], attachments: [String: NSTextAttachment]) throws -> NSAttributedString {
239 | return NSAttributedString(string: literal ?? "", attributes: attributes)
240 | }
241 | }
242 |
--------------------------------------------------------------------------------
/Sources/CommonMarkAttributedString/NSAttributedString+Extensions.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | #if canImport(UIKit)
4 | import class UIKit.NSTextAttachment
5 | #elseif canImport(AppKit)
6 | import class AppKit.NSTextAttachment
7 | #endif
8 |
9 | import CommonMark
10 |
11 | extension NSAttributedString {
12 | /**
13 | Create an attributed string from CommonMark text.
14 | - Parameters:
15 | - commonmark: A string containing text in CommonMark format.
16 | - attributes: A dictionary of base attributes to apply, if any.
17 | - attachments: A dictionary of text attachments keyed by URL strings
18 | corresponding to images in the CommonMark text.
19 | */
20 | public convenience init(commonmark: String, attributes: [NSAttributedString.Key: Any]? = nil, attachments: [String: NSTextAttachment]? = nil) throws {
21 | let document = try CommonMark.Document(commonmark)
22 | try self.init(attributedString: document.attributedString(attributes: attributes ?? [:], attachments: attachments ?? [:]))
23 | }
24 |
25 | convenience init?(html: String, attributes: [NSAttributedString.Key: Any]) throws {
26 | guard let data = html.data(using: .utf8) else { return nil }
27 |
28 | let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
29 | .documentType: NSAttributedString.DocumentType.html,
30 | .characterEncoding: String.Encoding.utf8.rawValue
31 | ]
32 |
33 | var documentAttributes: NSDictionary? = [:]
34 | #if canImport(UIKit)
35 | let mutableAttributedString = try NSMutableAttributedString(data: data, options: options, documentAttributes: &documentAttributes)
36 | #elseif canImport(AppKit)
37 | guard let mutableAttributedString = NSMutableAttributedString(html: data, options: options, documentAttributes: &documentAttributes) else {
38 | return nil
39 | }
40 | #endif
41 |
42 | mutableAttributedString.addAttributes(attributes, range: NSMakeRange(0, mutableAttributedString.length))
43 |
44 | self.init(attributedString: mutableAttributedString)
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Sources/CommonMarkAttributedString/NSFont+Extensions.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import struct CoreGraphics.CGFloat
3 |
4 | #if canImport(AppKit) && !canImport(UIKit)
5 | import class AppKit.NSFont
6 | import class AppKit.NSFontDescriptor
7 |
8 | extension NSFont {
9 | func addingSymbolicTraits(_ traits: NSFontDescriptor.SymbolicTraits) -> NSFont? {
10 | var symbolicTraits = fontDescriptor.symbolicTraits
11 | symbolicTraits.insert(traits)
12 | return NSFont(descriptor: fontDescriptor.withSymbolicTraits(symbolicTraits), size: pointSize)
13 | }
14 |
15 | var monospaced: NSFont? {
16 | var symbolicTraits = fontDescriptor.symbolicTraits
17 | symbolicTraits.insert(.monoSpace)
18 |
19 | guard let fontDescriptor = NSFont.userFixedPitchFont(ofSize: pointSize)?.fontDescriptor.withSymbolicTraits(symbolicTraits) else { return nil }
20 |
21 | return NSFont(descriptor: fontDescriptor, size: pointSize)
22 | }
23 | }
24 | #endif
25 |
--------------------------------------------------------------------------------
/Sources/CommonMarkAttributedString/UIFont+Extensions.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import struct CoreGraphics.CGFloat
3 |
4 | #if canImport(UIKit)
5 | import class UIKit.UIFont
6 | import class UIKit.UIFontDescriptor
7 |
8 | extension UIFont {
9 | func addingSymbolicTraits(_ traits: UIFontDescriptor.SymbolicTraits) -> UIFont? {
10 | var monospaceSymbolicTraits = fontDescriptor.symbolicTraits
11 | monospaceSymbolicTraits.insert(traits)
12 | guard let monospaceFontDescriptor = fontDescriptor.withSymbolicTraits(monospaceSymbolicTraits) else { return nil }
13 |
14 | return UIFont(descriptor: monospaceFontDescriptor, size: pointSize)
15 | }
16 |
17 | var traits: [UIFontDescriptor.TraitKey: Any] {
18 | return fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any]
19 | ?? [:]
20 | }
21 |
22 | var weight: UIFont.Weight {
23 | guard let number = traits[.weight] as? NSNumber else { return .regular }
24 | return UIFont.Weight(rawValue: CGFloat(number.doubleValue))
25 | }
26 |
27 | var monospaced: UIFont? {
28 | if #available(iOS 13.0, tvOS 13.0, watchOS 4.0, *) {
29 | return UIFont.monospacedSystemFont(ofSize: pointSize, weight: weight)
30 | } else {
31 | let monospaceFontDescriptor = fontDescriptor.addingAttributes([
32 | .family: "Menlo"
33 | ])
34 |
35 | return UIFont(descriptor: monospaceFontDescriptor, size: pointSize)
36 | }
37 | }
38 | }
39 | #endif
40 |
--------------------------------------------------------------------------------
/Tests/CommonMarkAttributedStringTests/CommonMarkAttributedStringTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import CommonMarkAttributedString
3 |
4 | #if canImport(UIKit)
5 | import UIKit
6 | #elseif canImport(AppKit)
7 | import AppKit
8 | #endif
9 |
10 | final class CommonMarkAttributedStringTests: XCTestCase {
11 | func testReadmeExample() throws {
12 | let commonmark = "A *bold* way to add __emphasis__ to your `code`"
13 |
14 | #if canImport(UIKit)
15 | let attributes: [NSAttributedString.Key: Any] = [
16 | .font: UIFont.systemFont(ofSize: 24.0),
17 | .foregroundColor: UIColor.systemBlue
18 | ]
19 | #elseif canImport(AppKit)
20 | let attributes: [NSAttributedString.Key: Any] = [
21 | .font: NSFont.systemFont(ofSize: 24.0),
22 | .foregroundColor: NSColor.systemBlue
23 | ]
24 | #endif
25 |
26 | let attributedString = try NSAttributedString(commonmark: commonmark, attributes: attributes)
27 |
28 | XCTAssertEqual(attributedString.string, "A bold way to add emphasis to your code")
29 | }
30 |
31 | func testUHDR() throws {
32 | let commonmark = #"""
33 | # [Universal Declaration of Human Rights][uhdr]
34 |
35 | ## Article 1.
36 |
37 | All human beings are born free and equal in dignity and rights.
38 | They are endowed with reason and conscience
39 | and should act towards one another in a spirit of brotherhood.
40 |
41 | [uhdr]: https://www.un.org/en/universal-declaration-human-rights/ "View full version"
42 | """#
43 |
44 | #if canImport(UIKit)
45 | var attributes: [NSAttributedString.Key: Any] = [
46 | .font: UIFont.preferredFont(forTextStyle: .body),
47 | ]
48 | if #available(iOS 13.0, macCatalyst 13.0, tvOS 13.0, *) {
49 | attributes[.foregroundColor] = UIColor.label
50 | #if os(iOS)
51 | attributes[.backgroundColor] = UIColor.systemBackground
52 | #endif
53 | } else {
54 | attributes[.foregroundColor] = UIColor.black
55 | attributes[.backgroundColor] = UIColor.white
56 | }
57 | #elseif canImport(AppKit)
58 | let attributes: [NSAttributedString.Key: Any] = [
59 | .font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
60 | .foregroundColor: NSColor.textColor,
61 | .backgroundColor: NSColor.textBackgroundColor
62 | ]
63 | #endif
64 |
65 | let attributedString = try NSAttributedString(commonmark: commonmark, attributes: attributes)
66 |
67 | XCTAssert(attributedString.string.starts(with: "Universal Declaration of Human Rights"))
68 | }
69 |
70 | func testHeaderFont() throws {
71 | let commonmark = #"""
72 | # Helvetica
73 |
74 | > You can say, "I love you," in Helvetica.
75 | > And you can say it with Helvetica Extra Light
76 | > if you want to be really fancy.
77 | > Or you can say it with the Extra Bold
78 | > if it's really intensive and passionate, you know,
79 | > and it might work.
80 | >
81 | > ― Massimo Vignelli
82 | """#
83 |
84 | #if canImport(UIKit)
85 | let font = UIFont.boldSystemFont(ofSize: 10)
86 | let attributedString = try NSAttributedString(commonmark: commonmark, attributes: [.font: font])
87 | let actualAttributes = attributedString.attributes(at: 0, effectiveRange: nil)
88 |
89 | XCTAssertEqual((actualAttributes[.font] as? UIFont)?.fontName, font.fontName)
90 | XCTAssertGreaterThan((actualAttributes[.font] as? UIFont)?.pointSize ?? 0, font.pointSize)
91 | #elseif canImport(AppKit)
92 | let font = NSFont.boldSystemFont(ofSize: 10)
93 | let attributedString = try NSAttributedString(commonmark: commonmark, attributes: [.font: font])
94 | let actualAttributes = attributedString.attributes(at: 0, effectiveRange: nil)
95 |
96 | XCTAssertEqual((actualAttributes[.font] as? NSFont)?.displayName, font.displayName)
97 | XCTAssertGreaterThan((actualAttributes[.font] as? NSFont)?.pointSize ?? 0, font.pointSize)
98 | #endif
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | fatalError("Run with `swift test --enable-test-discovery`")
2 |
--------------------------------------------------------------------------------