├── .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 = "![](https://example.com/image.png)" 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 | --------------------------------------------------------------------------------