├── .gitignore ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── AttributedStringBuilder │ ├── AddOutline.swift │ ├── AttributedStringBuilder.swift │ ├── AttributedStringConvertible.swift │ ├── AttributedStringToPDF_TextKit.swift │ ├── Attributes.swift │ ├── Checklist.swift │ ├── CustomKeys.swift │ ├── Environment.swift │ ├── Footnote.swift │ ├── Heading.swift │ ├── Image.swift │ ├── InternalLinks.swift │ ├── Join.swift │ ├── LayoutManagerHelpers.swift │ ├── MarkdownHelper.swift │ ├── MarkdownString.swift │ ├── MarkdownStylesheet.swift │ ├── Modify.swift │ ├── PDF.swift │ ├── State.swift │ ├── StringHelpers.swift │ ├── SwiftUI.swift │ └── Table.swift └── Tests │ ├── Environment.swift │ ├── MarkdownTests.swift │ ├── ReadmeTests.swift │ └── Tests.swift └── TODO.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-cmark", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-cmark.git", 7 | "state" : { 8 | "branch" : "gfm", 9 | "revision" : "86aeb491675de6f077a3a6df6cbcac1a25dcbee1" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-markdown", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apple/swift-markdown", 16 | "state" : { 17 | "branch" : "main", 18 | "revision" : "ad0b81fed55c4a72fdaeb5535884bd2886de96f4" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "attributed-string-builder", 7 | platforms: [.macOS(.v13)], 8 | products: [ 9 | .library( 10 | name: "AttributedStringBuilder", 11 | targets: ["AttributedStringBuilder"]), 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/apple/swift-markdown", branch: "main"), 15 | ], 16 | targets: [ 17 | .target( 18 | name: "AttributedStringBuilder", 19 | dependencies: [ 20 | .product(name: "Markdown", package: "swift-markdown"), 21 | ]), 22 | .testTarget( 23 | name: "Tests", 24 | dependencies: [ 25 | "AttributedStringBuilder" 26 | // .product(name: "AttributedStringBuilder"), 27 | ]), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AttributedString Builder 2 | 3 | A simple way to build up attributed strings using result builders from a variety of sources. Based on the episodes from [Swift Talk](https://talk.objc.io/episodes/S01E337-attributed-string-builder-part-1). Here are the things you can embed: 4 | 5 | - Plain strings 6 | - Markdown 7 | - Images 8 | - SwiftUI Views 9 | - Table support 10 | - Multi-page PDF export 11 | - Footnotes 12 | 13 | Here's an example showing plain strings, Markdown and SwiftUI views: 14 | 15 | ```swift 16 | @AttributedStringBuilder 17 | var example: some AttributedStringConvertible { 18 | "Hello, World!" 19 | .bold() 20 | .modify { $0.backgroundColor = .yellow } 21 | """ 22 | This is some markdown with **strong** `code` text. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas tempus, tortor eu maximus gravida, ante diam fermentum magna, in gravida ex tellus ac purus. 23 | 24 | - One 25 | - Two 26 | - Three 27 | 28 | > A blockquote. 29 | """.markdown() 30 | Embed { 31 | HStack { 32 | Image(systemName: "hand.wave") 33 | .font(.largeTitle) 34 | Text("Hello from SwiftUI") 35 | Color.red.frame(width: 100, height: 50) 36 | } 37 | } 38 | ``` 39 | 40 | You can then turn this example into a multi-page PDF like this: 41 | 42 | ```swift 43 | let data = await example 44 | .joined(separator: "\n") // join the parts using newlines 45 | .run(environment: .init(attributes: sampleAttributes)) // turn into a single `NSAttributedString` 46 | .pdf() // render as PDF 47 | try! data.write(to: .desktopDirectory.appending(component: "out.pdf")) 48 | ``` 49 | 50 | Here's [a larger sample](Sources/Tests/Tests.swift). 51 | 52 | ## Features 53 | 54 | ### Attributes 55 | 56 | The [Attributes](Sources/AttributedStringBuilder/Attributes.swift) struct is a value type representing the attributes in an `NSAttributedString`. During the building of the attributed string, this is passed on through the environment. For example, this is how you can build a simple attributed string using plain strings and `.modify`: 57 | 58 | ```swift 59 | @AttributedStringBuilder var sample1: some AttributedStringConvertible { 60 | "Hello" 61 | "World".modify { $0.textColor = .red } 62 | } 63 | ``` 64 | 65 | ### Strings 66 | 67 | You can turn any string directly into an attributed string. The attributes from the environment are used to do this. You can also modify the environment in a way very similar to what SwiftUI does. For example, you can write `"Hello".bold()" to take the current attributes, make them bold, and then render the string `"Hello"` using these modified attributes. 68 | 69 | ### Markdown 70 | 71 | You can take any Markdown string and render it into an attributed string as well. For most customization, you can pass in a custom [stylesheet](Sources/AttributedStringBuilder/MarkdownStylesheet.swift). In the Markdown string literal, you can embed other values that convert to `AttributedStringConvertible`: 72 | 73 | ```swift 74 | @AttributedStringBuilder var sample2: some AttributedStringConvertible { 75 | Markdown(""" 76 | This is *Markdown* syntax. 77 | 78 | With \("inline".modify { $0.underlineStyle = .single }) nesting. 79 | """) 80 | } 81 | ``` 82 | 83 | ### Images 84 | 85 | You can embed any `NSImage` into the attributed string, they're rendered as-is. 86 | 87 | ### SwiftUI Views 88 | 89 | SwiftUI views can be embedded using the [Embed](Sources/AttributedStringBuilder/SwiftUI.swift) modifier. By default, it proposes `nil⨉nil` to the view, but this can be customized. SwiftUI views are rendered into a PDF context and are embedded as vector graphics. 90 | 91 | ### Tables 92 | 93 | You can construct tables in attributed strings using the [Table](Sources/AttributedStringBuilder/Table.swift) support. This interface might still change (ideally, we'd use result builders for this as well). 94 | 95 | ### Environment 96 | 97 | You can use the environment in a way similar to SwiftUI's Environment to pass values down the tree. 98 | 99 | ### State 100 | 101 | Similar to the environment, you can also thread state through. This is useful (for example) to number footnotes. While the modified environment is always passed to *children* of the current node, modified state is passed to the next nodes that are rendered. 102 | 103 | ## Swift Talk Episodes 104 | 105 | - [Writing the Builder](https://talk.objc.io/episodes/S01E337-attributed-string-builder-part-1) 106 | - [Joining Elements](https://talk.objc.io/episodes/S01E338-attributed-string-builder-part-2) 107 | - [Syntax Highlighting](https://talk.objc.io/episodes/S01E339-attributed-string-builder-part-3) 108 | - [Rendering SwiftUI Views](https://talk.objc.io/episodes/S01E340-attributed-string-builder-part-4) 109 | - [Rendering Markdown](https://talk.objc.io/episodes/S01E341-attributed-string-builder-part-5) 110 | - [Creating a PDF](https://talk.objc.io/episodes/S01E342-attributed-string-builder-part-6) 111 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/AddOutline.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PDFKit 3 | import Markdown 4 | 5 | struct HeadingTree { 6 | var item: MyHeading 7 | var children: [HeadingTree] 8 | } 9 | 10 | extension Array where Element == MyHeading { 11 | func asTree() -> [HeadingTree] { 12 | var remainder = self[...] 13 | var result: [HeadingTree] = [] 14 | while !remainder.isEmpty { 15 | guard let next = remainder.parse(currentLevel: 0) else { 16 | fatalError() 17 | } 18 | result.append(next) 19 | } 20 | return result 21 | } 22 | } 23 | 24 | extension ArraySlice where Element == MyHeading { 25 | mutating func parse(currentLevel: Int) -> HeadingTree? { 26 | guard let f = first else { return nil } 27 | guard f.level > currentLevel else { return nil } 28 | removeFirst() 29 | var result = HeadingTree(item: f, children: []) 30 | while let child = parse(currentLevel: f.level) { 31 | result.children.append(child) 32 | } 33 | return result 34 | } 35 | } 36 | 37 | 38 | extension PDFDocument { 39 | func buildOutline(child: HeadingTree) -> PDFOutline { 40 | let result = PDFOutline() 41 | result.label = child.item.title 42 | let page = page(at: child.item.pageNumber)! 43 | /* debug */ 44 | // let annotation = PDFAnnotation(bounds: child.item.bounds, forType: .highlight, withProperties: [:]) 45 | // page.addAnnotation(annotation) 46 | /* end debug */ 47 | result.destination = PDFDestination(page: page, at: child.item.bounds.origin) 48 | for c in child.children.reversed() { 49 | result.insertChild(buildOutline(child: c), at: 0) 50 | } 51 | return result 52 | } 53 | 54 | 55 | public func addOutline(headings: [MyHeading]) { 56 | // For some reason, we need a root and a child and then the actual outline. 57 | let child = PDFOutline() 58 | child.label = "Child" 59 | 60 | let tree = headings.asTree() 61 | for h in tree.reversed() { 62 | child.insertChild(buildOutline(child: h), at: 0) 63 | } 64 | 65 | let root = PDFOutline() 66 | root.label = "Root" 67 | root.insertChild(child, at: 0) 68 | self.outlineRoot = root 69 | } 70 | 71 | public func addLinks(namedParts: [NamedPart], links: [MyLink]) { 72 | for l in links { 73 | guard let dest = namedParts.first(where: { $0.name == l.name }) else { 74 | print("No destination named: \(l.name)") 75 | continue 76 | } 77 | let sourcePage = page(at: l.pageNumber)! 78 | let page = page(at: dest.pageNumber)! 79 | let ann = PDFAnnotation(bounds: l.bounds, forType: .link, withProperties: [ 80 | : 81 | ]) 82 | let d = PDFDestination(page: page, at: dest.bounds.origin) 83 | ann.action = PDFActionGoTo(destination: d) 84 | sourcePage.addAnnotation(ann) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/AttributedStringBuilder.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | @resultBuilder 4 | public 5 | struct AttributedStringBuilder { 6 | public static func buildBlock(_ components: AttributedStringConvertible...) -> some AttributedStringConvertible { 7 | [components] 8 | } 9 | 10 | public static func buildOptional(_ component: C?) -> some AttributedStringConvertible { 11 | component.map { [$0] } ?? [] 12 | } 13 | 14 | public static func buildEither(first component: L) -> Either { 15 | Either.l(component) 16 | } 17 | 18 | public static func buildEither(second component: R) -> Either { 19 | Either.r(component) 20 | } 21 | } 22 | 23 | public enum Either { 24 | case l(A) 25 | case r(B) 26 | } 27 | 28 | extension Either: AttributedStringConvertible where A: AttributedStringConvertible, B: AttributedStringConvertible { 29 | public func attributedString(context: inout Context) -> [NSAttributedString] { 30 | switch self { 31 | case let .l(l): return l.attributedString(context: &context) 32 | case let .r(r): return r.attributedString(context: &context) 33 | } 34 | } 35 | } 36 | 37 | extension AttributedStringConvertible { 38 | @MainActor 39 | public func run(context: inout Context) -> NSAttributedString { 40 | Joined(separator: "", content: { 41 | self 42 | }).single(context: &context) 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/AttributedStringConvertible.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Context { 4 | public init(environment: EnvironmentValues) { 5 | self.environment = environment 6 | } 7 | 8 | public var environment: EnvironmentValues 9 | public var state: StateValues = .init() 10 | } 11 | 12 | public protocol AttributedStringConvertible { 13 | @MainActor 14 | func attributedString(context: inout Context) -> [NSAttributedString] 15 | } 16 | 17 | public struct Group: AttributedStringConvertible where Content: AttributedStringConvertible { 18 | var content: Content 19 | 20 | public init(@AttributedStringBuilder content: () -> Content) { 21 | self.content = content() 22 | } 23 | 24 | @MainActor 25 | public func attributedString(context: inout Context) -> [NSAttributedString] { 26 | content.attributedString(context: &context) 27 | } 28 | } 29 | 30 | extension Int: AttributedStringConvertible { 31 | public func attributedString(context: inout Context) -> [NSAttributedString] { 32 | [.init(string: "\(self)", attributes: context.environment.attributes.atts)] 33 | } 34 | } 35 | 36 | extension String: AttributedStringConvertible { 37 | public func attributedString(context: inout Context) -> [NSAttributedString] { 38 | [.init(string: self, attributes: context.environment.attributes.atts)] 39 | } 40 | } 41 | 42 | extension AttributedString: AttributedStringConvertible { 43 | public func attributedString(context: inout Context) -> [NSAttributedString] { 44 | [.init(self)] 45 | } 46 | } 47 | 48 | extension NSAttributedString: AttributedStringConvertible { 49 | public func attributedString(context: inout Context) -> [NSAttributedString] { 50 | [self] 51 | } 52 | } 53 | 54 | extension Array: AttributedStringConvertible where Element == AttributedStringConvertible { 55 | @MainActor 56 | public func attributedString(context: inout Context) -> [NSAttributedString] { 57 | var result: [NSAttributedString] = [] 58 | for el in self { 59 | result.append(contentsOf: el.attributedString(context: &context)) 60 | } 61 | return result 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/AttributedStringToPDF_TextKit.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import SwiftUI 3 | import OSLog 4 | 5 | /* 6 | 7 | From Marcin Krzyzanowski: 8 | 9 | the algo is "greedy" approach: procees pages from the beginning to end of the book and create/update page sizes based on what's found on current page. First, set page footer and header, then add annotations if any. Page header/footer is static size, but annotations area size is dynamic. For each page try to layout all three headers sections + text body section. If the number of annotations (for the current page) before and after adding annotations/headers is different, that means header/footers caused cut the page earlier → text with annotation will be now on the following (next) page → update annotations footer for the current page while keep the text body size. Then proceed to next page. It works because we process page by page while adjusting the page "end index". 10 | 11 | */ 12 | 13 | public struct MyHeading { 14 | public var pageNumber: Int 15 | public var title: String 16 | public var level: Int 17 | public var bounds: CGRect 18 | } 19 | 20 | public struct NamedPart { 21 | public var pageNumber: Int 22 | public var name: String 23 | public var bounds: CGRect 24 | } 25 | 26 | public struct MyLink { 27 | public var pageNumber: Int 28 | public var name: String 29 | public var bounds: CGRect 30 | } 31 | 32 | 33 | public struct PageInfo { 34 | public var pageNumber: Int 35 | public var chapterTitle: String 36 | } 37 | 38 | public struct PDFResult { 39 | public var data: Data 40 | public var headings: [MyHeading] 41 | public var namedParts: [NamedPart] 42 | public var links: [MyLink] 43 | } 44 | 45 | extension NSAttributedString { 46 | @MainActor 47 | public func fancyPDF( 48 | pageSize: CGSize = .a4, 49 | pageMargin: @escaping (_ pageNumber: Int) -> NSEdgeInsets = { _ in NSEdgeInsets(top: .pointsPerInch/2, left: .pointsPerInch/2, bottom: .pointsPerInch/2, right: .pointsPerInch/2)}, 50 | header: ((Int) -> Accessory)? = nil, 51 | footer: Accessory? = nil, 52 | annotationsPadding: NSEdgeInsets = .init(), 53 | highlightWarnings: Bool = false 54 | ) -> PDFResult { 55 | let r = PDFRenderer(pageSize: pageSize, 56 | pageMargin: pageMargin, 57 | string: self, 58 | header: header, 59 | footer: footer, 60 | annotationsPadding: annotationsPadding, 61 | highlightWarnings: highlightWarnings 62 | ) 63 | 64 | let data = r.render() 65 | return PDFResult(data: data, headings: r.headings, namedParts: r.namedParts, links: r.links) 66 | } 67 | } 68 | 69 | public struct Accessory { 70 | public var string: (PageInfo) -> NSAttributedString 71 | public var padding: NSEdgeInsets 72 | 73 | public init(string: @escaping (PageInfo) -> NSAttributedString, padding: NSEdgeInsets) { 74 | self.string = string 75 | self.padding = padding 76 | } 77 | } 78 | 79 | // From: https://stackoverflow.com/questions/58483933/create-pdf-with-multiple-pages 80 | @MainActor 81 | class PDFRenderer { 82 | 83 | 84 | private struct Page { 85 | let container: NSTextContainer? // nil is an empty page 86 | let annotations: AccessoryInfo? 87 | let header: AccessoryInfo? 88 | let footer: AccessoryInfo? 89 | let frameRect: CGRect 90 | } 91 | 92 | private struct AccessoryInfo { 93 | // keep reference 94 | private let storage: NSTextStorage 95 | private let manager: NSLayoutManager 96 | 97 | var containers: [NSTextContainer] { 98 | manager.textContainers 99 | } 100 | 101 | var containersHeight: CGFloat { 102 | containers.map(\.size.height).reduce(0, +) 103 | } 104 | 105 | init(manager: NSLayoutManager) { 106 | self.storage = manager.textStorage! 107 | self.manager = manager 108 | } 109 | } 110 | 111 | private var pageSize: CGSize 112 | private var pageMargin: (_ pageNumber: Int) -> NSEdgeInsets 113 | private var header: ((Int) -> Accessory)? 114 | private var footer: Accessory? 115 | private var annotationsPadding: NSEdgeInsets 116 | private var highlightWarnings: Bool 117 | 118 | private var pageRect: CGRect 119 | 120 | private var bookLayoutManager: NSLayoutManager 121 | private var bookTextStorage: NSTextStorage 122 | 123 | private var pages: [Page] = [] 124 | private(set) var headings: [MyHeading] = [] 125 | private(set) var namedParts: [NamedPart] = [] 126 | private(set) var links: [MyLink] = [] 127 | 128 | public init( 129 | pageSize: CGSize, 130 | pageMargin: @escaping (_ pageNumber: Int) -> NSEdgeInsets, 131 | string: NSAttributedString, 132 | header: ((Int) -> Accessory)? = nil, 133 | footer: Accessory? = nil, 134 | annotationsPadding: NSEdgeInsets = .init(), 135 | highlightWarnings: Bool = false 136 | ) { 137 | self.pageSize = pageSize 138 | self.pageMargin = pageMargin 139 | self.header = header 140 | self.footer = footer 141 | self.annotationsPadding = annotationsPadding 142 | self.pageRect = CGRect(origin: .zero, size: pageSize) 143 | self.highlightWarnings = highlightWarnings 144 | 145 | self.bookTextStorage = NSTextStorage(attributedString: string) 146 | self.bookLayoutManager = NSLayoutManager() 147 | bookLayoutManager.usesFontLeading = true 148 | bookLayoutManager.allowsNonContiguousLayout = true 149 | bookTextStorage.addLayoutManager(bookLayoutManager) 150 | } 151 | 152 | 153 | public func render() -> Data { 154 | var rect = pageRect 155 | let mutableData = NSMutableData() 156 | let consumer = CGDataConsumer(data: mutableData)! 157 | let context = CGContext(consumer: consumer, mediaBox: &rect, nil)! 158 | _render(context: context) 159 | return mutableData as Data 160 | } 161 | 162 | public func render(url: URL) { 163 | var rect = pageRect 164 | let context = CGContext(url as CFURL, mediaBox: &rect, nil)! 165 | _render(context: context) 166 | } 167 | 168 | private func _resetLayoutManager() { 169 | for i in 0.. Bool { 182 | guard let lastPage = pages.last(where: { $0.container != nil }) else { return true } 183 | let containerGlyphRange = bookLayoutManager.glyphRange(for: lastPage.container!) 184 | return NSMaxRange(containerGlyphRange) < bookLayoutManager.numberOfGlyphs 185 | } 186 | 187 | while addMode() { 188 | 189 | func computeFrameRect(margins: NSEdgeInsets) -> CGRect { 190 | var copy = pageRect 191 | copy.origin.x += margins.left 192 | copy.size.width -= margins.left + margins.right 193 | copy.origin.y += margins.top 194 | copy.size.height -= margins.top + margins.bottom 195 | return copy 196 | } 197 | 198 | let margins = pageMargin(pages.count) 199 | var frameRect = computeFrameRect(margins: margins) 200 | 201 | 202 | func accessoryInfo(accessory: Accessory?) -> AccessoryInfo { 203 | let storage = NSTextStorage(attributedString: accessory?.string(.init(pageNumber: pages.count + 1, chapterTitle: chapterTitle ?? "")) ?? NSAttributedString()) 204 | let layoutManager = NSLayoutManager() 205 | layoutManager.usesFontLeading = bookLayoutManager.usesFontLeading 206 | layoutManager.allowsNonContiguousLayout = bookLayoutManager.allowsNonContiguousLayout 207 | storage.addLayoutManager(layoutManager) 208 | 209 | layoutManager.addTextContainer( 210 | NSTextContainer(size: CGSize( 211 | width: frameRect.size.width - (accessory?.padding.horizontal ?? 0), 212 | height: boundingRect(of: storage, maxWidth: frameRect.size.width).height + (accessory?.padding.vertical ?? 0)) 213 | ) 214 | ) 215 | 216 | return AccessoryInfo(manager: layoutManager) 217 | } 218 | 219 | func annotationsAccessoryInfo(pageContentContainer: NSTextContainer) -> AccessoryInfo { 220 | // Combine annotations and static footer 221 | let pageLayoutManager = pageContentContainer.layoutManager! 222 | let storage = NSTextStorage() 223 | let layoutManager = NSLayoutManager() 224 | layoutManager.usesFontLeading = bookLayoutManager.usesFontLeading 225 | layoutManager.allowsNonContiguousLayout = bookLayoutManager.allowsNonContiguousLayout 226 | storage.addLayoutManager(layoutManager) 227 | 228 | let characterRange = pageLayoutManager.characterRange(forGlyphRange: pageLayoutManager.glyphRange(for: pageContentContainer), actualGlyphRange: nil) 229 | let annotations = bookTextStorage.annotations(in: characterRange) 230 | if !annotations.isEmpty { 231 | for (offset, element) in annotations.map(\.0).enumerated() { 232 | storage.append(element) 233 | if offset < annotations.count - 1 { 234 | storage.append(NSAttributedString(string: "\n")) 235 | } 236 | } 237 | 238 | // Add annotations container 239 | layoutManager.addTextContainer( 240 | NSTextContainer(size: CGSize( 241 | width: frameRect.size.width - annotationsPadding.horizontal, 242 | height: boundingRect(of: storage, maxWidth: frameRect.size.width).height + annotationsPadding.vertical) 243 | ) 244 | ) 245 | } 246 | 247 | return AccessoryInfo(manager: layoutManager) 248 | } 249 | 250 | let pageContentContainer = NSTextContainer(size: frameRect.size) 251 | bookLayoutManager.addTextContainer(pageContentContainer) 252 | let pageLayoutManager = pageContentContainer.layoutManager! 253 | 254 | let pageGlyphRange = pageLayoutManager.glyphRange(for: pageContentContainer) 255 | let pageCharacterRange = pageLayoutManager.characterRange(forGlyphRange: pageGlyphRange, actualGlyphRange: nil) 256 | 257 | let spreadBreak = bookTextStorage.values(type: Bool.self, for: .spreadBreak, in: pageCharacterRange).first?.value ?? false 258 | if spreadBreak && pages.count.isMultiple(of: 2) { 259 | pages.append(Page(container: nil, annotations: nil, header: nil, footer: nil, frameRect: frameRect)) 260 | } 261 | 262 | if let customMargins = bookTextStorage.values(type: NSEdgeInsets.self, for: .pageMargin, in: pageCharacterRange).first { 263 | frameRect = computeFrameRect(margins: customMargins.value) 264 | pageContentContainer.containerSize = frameRect.size 265 | } 266 | 267 | let headings = bookTextStorage.values(type: HeadingInfo.self, for: .heading, in: pageCharacterRange) 268 | if let info = headings.first(where: { $0.value.level == 1 }) { 269 | chapterTitle = info.value.text 270 | } 271 | 272 | let suppressHeadings = bookTextStorage.values(type: Bool.self, for: .suppressHeader, in: pageCharacterRange).map { $0.value }.allSatisfy { $0 } 273 | 274 | let pageAnnotationsBefore = bookTextStorage.annotations(in: pageCharacterRange) 275 | 276 | // Add header container 277 | let pageHeaderInfo = accessoryInfo(accessory: header?(pages.count)) 278 | // Add annotations if any 279 | var annotationsInfo = annotationsAccessoryInfo(pageContentContainer: pageContentContainer) 280 | 281 | // Add footer container 282 | var pageFooterInfo = accessoryInfo(accessory: footer) 283 | 284 | // adjust current page contentContainer height and re-layout 285 | pageContentContainer.size = CGSize( 286 | width: max(1, pageContentContainer.size.width), 287 | height: max(1, pageContentContainer.size.height 288 | - annotationsInfo.containersHeight 289 | - pageHeaderInfo.containersHeight 290 | - pageFooterInfo.containersHeight) 291 | ) 292 | 293 | // if pageAnnotationsBefore != pageAnnotationsAfter 294 | // keep the pageContentContainer size and use pageAnnotationsAfter 295 | // recalculate annotationsInfo and pageFooterInfo 296 | let pageAnnotationsAfter = bookTextStorage.annotations(in: pageLayoutManager.characterRange(forGlyphRange: pageLayoutManager.glyphRange(for: pageContentContainer), actualGlyphRange: nil)) 297 | if pageAnnotationsAfter.count != pageAnnotationsBefore.count { 298 | annotationsInfo = annotationsAccessoryInfo(pageContentContainer: pageContentContainer) 299 | pageFooterInfo = accessoryInfo(accessory: footer) 300 | if pageAnnotationsAfter.count != pageAnnotationsBefore.count { 301 | logger.warning("Annotations have changed multiple times on page \(pages.count)") 302 | } 303 | } 304 | 305 | pages.append( 306 | Page( 307 | container: pageContentContainer, 308 | annotations: annotationsInfo, 309 | header: suppressHeadings ? nil : pageHeaderInfo, 310 | footer: pageFooterInfo, 311 | frameRect: frameRect 312 | ) 313 | ) 314 | } 315 | 316 | self.pages = pages 317 | } 318 | 319 | let logger = Logger() 320 | 321 | private func addWidowAndOrphanWarnings() { 322 | let range = bookTextStorage.string.startIndex.. 1 else { return } 329 | 330 | for range in [pageRanges.first!, pageRanges.last!] { 331 | if bookLayoutManager.lineFragmentRects(for: range).count == 1 { 332 | let charRange = bookLayoutManager.characterRange(forGlyphRange: range, actualGlyphRange: nil) 333 | // print((bookTextStorage.string as NSString).substring(with: charRange).utf8) 334 | bookTextStorage.addAttribute(.backgroundColor, value: NSColor.yellow, range: charRange) 335 | } 336 | } 337 | } 338 | } 339 | 340 | private func _render(context: CGContext) { 341 | NSGraphicsContext.saveGraphicsState() 342 | NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true) 343 | 344 | defer { 345 | NSGraphicsContext.restoreGraphicsState() 346 | } 347 | 348 | _layoutPages() 349 | if highlightWarnings { 350 | addWidowAndOrphanWarnings() 351 | } 352 | 353 | // Print containers 354 | for (pageNo, page) in pages.enumerated() { 355 | var x = pageRect 356 | context.beginPage(mediaBox: &x) 357 | defer { context.endPage() } 358 | 359 | guard let container = page.container else { continue } // empty page 360 | 361 | context.translateBy(x: 0, y: pageRect.height) 362 | context.concatenate(.init(scaleX: 1, y: -1)) 363 | 364 | let range = bookLayoutManager.glyphRange(for: container) 365 | let location = range.location + range.length/2 366 | if location < bookTextStorage.length { 367 | let attributes = bookTextStorage.attributes(at: location, effectiveRange: nil) 368 | if let pageBackground = attributes[.pageBackground] as? NSColor { 369 | context.saveGState() 370 | context.setFillColor(pageBackground.cgColor) 371 | context.fill(pageRect) 372 | context.restoreGState() 373 | } 374 | 375 | if let backgroundView = attributes[.pageBackgroundView] as? AnyView { 376 | let renderer = ImageRenderer(content: backgroundView) 377 | renderer.proposedSize = ProposedViewSize(pageRect.size) 378 | context.concatenate(.init(scaleX: 1, y: -1)) 379 | context.translateBy(x: 0, y: -pageRect.height) 380 | renderer.render { size, renderer in 381 | renderer(context) 382 | } 383 | // This manually restores the context, because saveGState/restoreGState didn't work here 384 | context.translateBy(x: 0, y: pageRect.height) 385 | context.concatenate(.init(scaleX: 1, y: -1)) 386 | } 387 | } 388 | 389 | // Draw header and content top bottom 390 | do { 391 | var origin = page.frameRect.origin 392 | 393 | var headerHeight: CGFloat = 0 394 | // Draw header 395 | if let header = self.header?(pageNo), let headerInfoContainers = page.header?.containers { 396 | let padding = header.padding.top 397 | origin.y += padding 398 | origin = _draw(containers: headerInfoContainers, startAt: origin) 399 | origin.y -= padding 400 | headerHeight = origin.y-page.frameRect.origin.y 401 | } 402 | 403 | // Compute the local position within the page for the range 404 | func computeBounds(range: NSRange) -> CGRect { 405 | var rect = bookLayoutManager.boundingRect(forGlyphRange: range, in: container) 406 | rect.origin.y += page.frameRect.minY 407 | rect.origin.x += page.frameRect.minX 408 | rect.origin.y = pageRect.height - rect.origin.y - rect.height 409 | return rect 410 | } 411 | 412 | func drawSwiftUIBackgrounds() { 413 | 414 | let backgroundViews: [(value: AnyView, range: NSRange)] = bookTextStorage.values(for: .backgroundView, in: range) 415 | for (value, range) in backgroundViews { 416 | let b = computeBounds(range: range) 417 | let renderer = ImageRenderer(content: value) 418 | renderer.proposedSize = .init(width: b.width, height: b.height) 419 | renderer.render(rasterizationScale: 1) { size, render in 420 | context.saveGState() 421 | context.concatenate(.init(scaleX: 1, y: -1)) 422 | context.translateBy(x: 0, y: -pageRect.height - headerHeight) 423 | context.translateBy(x: b.minX, y: b.minY) 424 | render(context) 425 | context.restoreGState() 426 | } 427 | } 428 | } 429 | 430 | // Draw content 431 | bookLayoutManager.drawBackground(forGlyphRange: range, at: origin) 432 | drawSwiftUIBackgrounds() 433 | bookLayoutManager.drawGlyphs(forGlyphRange: range, at: origin) 434 | origin.y += container.size.height 435 | 436 | let headings: [(value: HeadingInfo, range: NSRange)] = bookTextStorage.values(for: .heading, in: range) 437 | self.headings.append(contentsOf: headings.map { h in 438 | return MyHeading(pageNumber: pageNo, title: h.value.text, level: h.value.level, bounds: computeBounds(range: h.range)) 439 | }) 440 | 441 | let namedParts: [(value: String, range: NSRange)] = bookTextStorage.values(for: .internalName, in: range) 442 | self.namedParts.append(contentsOf: namedParts.map { part in 443 | NamedPart(pageNumber: pageNo, name: part.value, bounds: computeBounds(range: part.range)) 444 | }) 445 | 446 | let links: [(value: String, range: NSRange)] = bookTextStorage.values(for: .internalLink, in: range) 447 | self.links.append(contentsOf: links.map { part in 448 | MyLink(pageNumber: pageNo, name: part.value, bounds: computeBounds(range: part.range)) 449 | }) 450 | 451 | // Draw Annotations 452 | if let annotationContainers = page.annotations?.containers { 453 | origin.y += annotationsPadding.top 454 | origin = _draw(containers: annotationContainers, startAt: origin) 455 | origin.y -= annotationsPadding.top 456 | } 457 | 458 | // Draw Footer 459 | if let footer = self.footer, let footerInfoContainers = page.footer?.containers { 460 | origin.y += footer.padding.top 461 | origin = _draw(containers: footerInfoContainers, startAt: origin) 462 | origin.y -= footer.padding.top 463 | } 464 | } 465 | } 466 | context.closePDF() 467 | } 468 | 469 | private func _draw(containers: [NSTextContainer], startAt origin: CGPoint) -> CGPoint { 470 | var origin = origin 471 | for container in containers where container.layoutManager != nil { 472 | let manager = container.layoutManager! 473 | let range = manager.glyphRange(for: container) 474 | manager.drawBackground(forGlyphRange: range, at: origin) 475 | manager.drawGlyphs(forGlyphRange: range, at: origin) 476 | origin.y += container.size.height 477 | } 478 | return origin 479 | } 480 | 481 | } 482 | 483 | /* 484 | extension NSAttributedString { 485 | 486 | public func pdf( 487 | pageSize: CGSize = .a4, 488 | pageMargin: CGSize = .init(width: 10 * CGFloat.pointsPerMM, height: 10 * CGFloat.pointsPerMM), 489 | header headerString: NSAttributedString? = nil, 490 | headerPadding: NSEdgeInsets = .init(), 491 | footer footerString: NSAttributedString? = nil, 492 | footerPadding: NSEdgeInsets = .init(), 493 | annotationsPadding: NSEdgeInsets = .init() 494 | ) -> Data { 495 | PDFRenderer( 496 | pageSize: pageSize, 497 | pageMargin: pageMargin, 498 | string: self, 499 | header: headerString.map { 500 | PDFRenderer.Accessory(string: $0, padding: headerPadding) 501 | }, 502 | footer: footerString.map { 503 | PDFRenderer.Accessory(string: $0, padding: footerPadding) 504 | }, 505 | annotationsPadding: annotationsPadding 506 | ).render() 507 | } 508 | 509 | } 510 | */ 511 | 512 | 513 | 514 | private extension NSAttributedString { 515 | 516 | func values(type: Value.Type = Value.self, for key: NSAttributedString.Key, in range: NSRange? = nil) -> [(value: Value, range: NSRange)] { 517 | var values: [(Value, NSRange)] = [] 518 | enumerateAttribute(key, in: range ?? NSRange(location: 0, length: length)) { value, range, stop in 519 | guard let v = value as? Value else { 520 | return 521 | } 522 | 523 | values.append((v, range)) 524 | } 525 | return values 526 | } 527 | 528 | func annotations(in range: NSRange? = nil) -> [(NSAttributedString, NSRange)] { 529 | let result = values(type: NSAttributedString.self, for: .annotation, in: range) 530 | return result 531 | } 532 | 533 | } 534 | 535 | private func boundingRect(of attributedString: NSAttributedString, maxWidth: CGFloat, lineFragmentPadding: CGFloat? = nil) -> CGSize { 536 | let textStorage = NSTextStorage(attributedString: attributedString) 537 | let container = NSTextContainer(size: NSSize(width: maxWidth, height: .greatestFiniteMagnitude)) 538 | let layoutManager = NSLayoutManager() 539 | layoutManager.addTextContainer(container) 540 | layoutManager.usesFontLeading = true 541 | layoutManager.allowsNonContiguousLayout = true 542 | textStorage.addLayoutManager(layoutManager) 543 | if let lineFragmentPadding = lineFragmentPadding { 544 | container.lineFragmentPadding = lineFragmentPadding 545 | } 546 | _ = layoutManager.glyphRange(for: container) 547 | return layoutManager.boundingRect(forGlyphRange: layoutManager.glyphRange(for: container), in: container).size 548 | } 549 | 550 | private extension NSEdgeInsets { 551 | var horizontal: CGFloat { 552 | left + right 553 | } 554 | 555 | var vertical: CGFloat { 556 | top + bottom 557 | } 558 | } 559 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/Attributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Attributes.swift 3 | // 4 | // 5 | // Created by Juul Spee on 08/07/2022. 6 | 7 | import AppKit 8 | 9 | /// Attributes for `NSAttributedString`, wrapped in a struct for convenience. 10 | public struct Attributes { 11 | public init( 12 | family: String = "Helvetica", 13 | size: CGFloat = 14, 14 | bold: Bool = false, 15 | italic: Bool = false, 16 | monospace: Bool = false, 17 | textColor: NSColor = .textColor, 18 | backgroundColor: NSColor? = nil, 19 | kern: CGFloat = 0, 20 | firstlineHeadIndent: CGFloat = 0, 21 | headIndent: CGFloat = 0, 22 | tailIndent: CGFloat = 0, 23 | tabStops: [NSTextTab] = (1..<10).map { 24 | NSTextTab(textAlignment: .left, 25 | location: CGFloat($0) * 2 * 16) 26 | }, 27 | alignment: NSTextAlignment = .left, 28 | lineHeightMultiple: CGFloat = 1.3, 29 | minimumLineHeight: CGFloat? = nil, 30 | maximumLineHeight: CGFloat? = nil, 31 | paragraphSpacing: CGFloat = 14, 32 | paragraphSpacingBefore: CGFloat = 0, 33 | link: URL? = nil, 34 | cursor: NSCursor? = nil, 35 | underlineColor: NSColor? = nil, 36 | underlineStyle: NSUnderlineStyle? = nil, 37 | suppressHeader: Bool = false) { 38 | self.family = family 39 | self.size = size 40 | self.bold = bold 41 | self.italic = italic 42 | self.monospace = monospace 43 | self.textColor = textColor 44 | self.backgroundColor = backgroundColor 45 | self.kern = kern 46 | self.firstlineHeadIndent = firstlineHeadIndent 47 | self.headIndent = headIndent 48 | self.tailIndent = tailIndent 49 | self.tabStops = tabStops 50 | self.alignment = alignment 51 | self.lineHeightMultiple = lineHeightMultiple 52 | self.minimumLineHeight = minimumLineHeight 53 | self.maximumLineHeight = maximumLineHeight 54 | self.paragraphSpacing = paragraphSpacing 55 | self.paragraphSpacingBefore = paragraphSpacingBefore 56 | self.link = link 57 | self.cursor = cursor 58 | self.underlineColor = underlineColor 59 | self.underlineStyle = underlineStyle 60 | self.suppressHeader = suppressHeader 61 | } 62 | 63 | public var family: String 64 | public var size: CGFloat 65 | public var bold: Bool = false 66 | public var italic: Bool = false 67 | public var monospace: Bool = false 68 | public var textColor: NSColor = .textColor 69 | public var backgroundColor: NSColor? = nil 70 | public var kern: CGFloat = 0 71 | public var firstlineHeadIndent: CGFloat = 0 72 | public var headIndent: CGFloat = 0 73 | public var tailIndent: CGFloat = 0 74 | public var tabStops: [NSTextTab] = (1..<10).map { NSTextTab(textAlignment: .left, location: CGFloat($0) * 2 * 16) } 75 | public var alignment: NSTextAlignment = .left 76 | public var lineHeightMultiple: CGFloat = 1.3 77 | public var minimumLineHeight: CGFloat? = nil 78 | public var maximumLineHeight: CGFloat? = nil 79 | public var paragraphSpacing: CGFloat = 0 80 | public var paragraphSpacingBefore: CGFloat = 0 81 | public var link: URL? = nil 82 | public var cursor: NSCursor? = nil 83 | public var underlineColor: NSColor? 84 | public var underlineStyle: NSUnderlineStyle? 85 | // public var suppressHeading: Bool? 86 | public var customAttributes: [String: Any] = [:] 87 | } 88 | 89 | extension Attributes { 90 | public mutating func setIndent(_ value: CGFloat) { 91 | firstlineHeadIndent = value 92 | headIndent = value 93 | } 94 | 95 | public var computedFont: NSFont { 96 | font 97 | } 98 | 99 | fileprivate var font: NSFont { 100 | var fontDescriptor = NSFontDescriptor(name: family, size: size) 101 | 102 | var traits = NSFontDescriptor.SymbolicTraits() 103 | if monospace { traits.formUnion(.monoSpace) } 104 | if bold { traits.formUnion(.bold) } 105 | if italic { traits.formUnion(.italic )} 106 | if !traits.isEmpty { fontDescriptor = fontDescriptor.withSymbolicTraits(traits) } 107 | guard let font = NSFont(descriptor: fontDescriptor, size: size) else { 108 | print("Font creation with traits failed: \(traits). Fallback to named font.") 109 | return NSFont(name: family, size: size) ?? .systemFont(ofSize: size) 110 | } 111 | return font 112 | } 113 | 114 | public var paragraphStyle: NSParagraphStyle { 115 | let paragraphStyle = NSMutableParagraphStyle() 116 | paragraphStyle.firstLineHeadIndent = firstlineHeadIndent 117 | paragraphStyle.headIndent = headIndent 118 | paragraphStyle.tailIndent = tailIndent 119 | paragraphStyle.tabStops = tabStops 120 | paragraphStyle.alignment = alignment 121 | paragraphStyle.lineHeightMultiple = lineHeightMultiple 122 | paragraphStyle.minimumLineHeight = minimumLineHeight ?? 0 123 | paragraphStyle.maximumLineHeight = maximumLineHeight ?? 0 // 0 is the default 124 | paragraphStyle.paragraphSpacing = paragraphSpacing 125 | paragraphStyle.paragraphSpacingBefore = paragraphSpacingBefore 126 | return paragraphStyle 127 | } 128 | 129 | fileprivate var attachmentParagraphStyle: NSParagraphStyle { 130 | let ps = NSMutableParagraphStyle() 131 | ps.firstLineHeadIndent = firstlineHeadIndent 132 | ps.headIndent = headIndent 133 | ps.tailIndent = tailIndent 134 | ps.alignment = alignment 135 | ps.paragraphSpacing = paragraphSpacing 136 | ps.paragraphSpacingBefore = paragraphSpacingBefore 137 | return ps 138 | } 139 | 140 | /// Outputs a dictionary of the attributes that can be passed into an attributed string. 141 | public var atts: [NSAttributedString.Key:Any] { 142 | var result: [NSAttributedString.Key: Any] = [ 143 | .font: font, 144 | .foregroundColor: textColor, 145 | .kern: kern, 146 | .paragraphStyle: paragraphStyle, 147 | ] 148 | if let bg = backgroundColor { 149 | result[.backgroundColor] = bg 150 | } 151 | if let url = link { 152 | result[.link] = url 153 | } 154 | if let cursor { 155 | result[.cursor] = cursor 156 | } 157 | if let underlineColor { 158 | result[.underlineColor] = underlineColor 159 | } 160 | if let underlineStyle { 161 | result[.underlineStyle] = underlineStyle.rawValue 162 | } 163 | result[.suppressHeader] = suppressHeader 164 | for (key, value) in customAttributes { 165 | result[NSAttributedString.Key(key)] = value 166 | } 167 | return result 168 | } 169 | 170 | public var attachmentAtts: [NSAttributedString.Key: Any] { 171 | [.paragraphStyle: attachmentParagraphStyle] 172 | } 173 | } 174 | 175 | extension NSAttributedString { 176 | public convenience init(string: String, attributes: Attributes) { 177 | self.init(string: string, attributes: attributes.atts) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/Checklist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Checklist.swift 3 | // Workshop Instructor 4 | // 5 | // Created by Juul Spee on 20/01/2023. 6 | // 7 | 8 | import Foundation 9 | import Markdown 10 | import SwiftUI 11 | 12 | public struct CheckboxItem: Equatable, Identifiable { 13 | public struct ID: Equatable, Hashable { 14 | let rawValue: Int 15 | 16 | init(_ sourceLocation: SourceRange) { 17 | self.rawValue = sourceLocation.hashValue 18 | } 19 | 20 | fileprivate init(rawValue: Int) { 21 | self.rawValue = rawValue 22 | } 23 | } 24 | 25 | public let id: ID 26 | var isChecked: Bool 27 | 28 | init?(_ listItem: ListItem) { 29 | guard let checkbox = listItem.checkbox, 30 | let id = Self.id(for: listItem) 31 | else { 32 | return nil 33 | } 34 | self.id = id 35 | self.isChecked = checkbox == .checked 36 | } 37 | 38 | public func isIdentical(to other: ListItem) -> Bool { 39 | self.id == Self.id(for: other) 40 | } 41 | 42 | static private func id(for listItem: ListItem) -> ID? { 43 | // Take child because its reported source location is stable 44 | let node = listItem.childCount > 0 ? listItem.child(at: 0)! : listItem 45 | return node.range.map { ID($0) } 46 | } 47 | 48 | // MARK: - URLs 49 | 50 | /// Encodes `self` into a `URL`. 51 | /// - Returns: The URL if the combination of components results in a valid result, or `nil` otherwise. 52 | public func url(scheme: String, endpoint: String) -> URL? { 53 | URL(string: "\(scheme):\(endpoint)/\(id.rawValue)/\(!isChecked)") 54 | } 55 | 56 | /// Parses a `URL` into a `CheckboxItem`. 57 | public init?(url: URL, scheme: String, endpoint: String) { 58 | guard url.scheme == scheme, 59 | let components = URLComponents(url: url, resolvingAgainstBaseURL: false) 60 | else { return nil } 61 | 62 | let pathComponents = components.path.split(separator: "/", omittingEmptySubsequences: true) 63 | 64 | guard pathComponents.count == 3, 65 | pathComponents[0] == endpoint, 66 | let raw = Int(pathComponents[1]) 67 | else { return nil } 68 | 69 | self.id = ID(rawValue: raw) 70 | self.isChecked = pathComponents[2] == "true" 71 | } 72 | } 73 | 74 | public final class CheckboxModel: ObservableObject { 75 | public static let shared = CheckboxModel() 76 | 77 | private init() { } 78 | 79 | @Published public var checkboxItems: [CheckboxItem] = [] 80 | 81 | public func update(checkboxItem: CheckboxItem) { 82 | guard let index = checkboxItems.firstIndex(where: { $0.id == checkboxItem.id }) 83 | else { 84 | return 85 | } 86 | checkboxItems[index].isChecked = checkboxItem.isChecked 87 | } 88 | 89 | public func rewrite(document: Document) -> Document { 90 | var walker = ChecklistWalker(checkboxItems: checkboxItems) 91 | let updated = walker.visit(document) as! Document 92 | self.checkboxItems = walker.checkboxItems 93 | return updated 94 | } 95 | } 96 | 97 | struct ChecklistWalker: MarkupRewriter { 98 | var checkboxItems: [CheckboxItem] 99 | 100 | mutating func visitListItem(_ listItem: ListItem) -> Markup? { 101 | if let checkboxItem = checkboxItems.first(where: { $0.isIdentical(to: listItem) }) { 102 | /// Item found in the model; update its checkbox and return the rewritten item. 103 | var copy = listItem 104 | copy.checkbox = checkboxItem.isChecked ? .checked : .unchecked 105 | return copy 106 | } 107 | 108 | /// Store the new list item if it contains a checkbox. 109 | if let checkboxItem = CheckboxItem(listItem) { 110 | checkboxItems.append(checkboxItem) 111 | } 112 | return listItem 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/CustomKeys.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | extension NSAttributedString.Key { 5 | // Set this key to make an entire PDF page filled with this color as the background color 6 | static public let pageBackground = NSAttributedString.Key("pageBackground") 7 | static public let annotation = NSAttributedString.Key("48611742167f11ed861d0242ac120002") 8 | static public let pageMargin = NSAttributedString.Key("io.objc.pageMargin") 9 | static public let pageBackgroundView = NSAttributedString.Key("io.objc.pageBackgroundView") 10 | static public let spreadBreak = NSAttributedString.Key("io.objc.spreadBreak") 11 | static public let suppressHeader = NSAttributedString.Key("io.objc.suppressHeader") 12 | static public let backgroundView = NSAttributedString.Key("io.objc.backgroundView") 13 | } 14 | 15 | extension Attributes { 16 | var annotation: NSAttributedString { 17 | get { 18 | customAttributes[NSAttributedString.Key.annotation.rawValue] as! NSAttributedString 19 | } 20 | set { 21 | customAttributes[NSAttributedString.Key.annotation.rawValue] = newValue 22 | } 23 | } 24 | 25 | public var pageMargin: NSEdgeInsets? { 26 | get { 27 | customAttributes[NSAttributedString.Key.pageMargin.rawValue] as? NSEdgeInsets 28 | } 29 | set { 30 | customAttributes[NSAttributedString.Key.pageMargin.rawValue] = newValue 31 | } 32 | } 33 | 34 | public var pageBackgroundView: AnyView? { 35 | get { 36 | customAttributes[NSAttributedString.Key.pageBackgroundView.rawValue] as? AnyView 37 | } 38 | set { 39 | customAttributes[NSAttributedString.Key.pageBackgroundView.rawValue] = newValue 40 | } 41 | } 42 | 43 | // For now, this doesn't work correctly across multiple lines, it takes the complete bounding box and draws the background behind there. 44 | var backgroundView: AnyView? { 45 | get { 46 | customAttributes[NSAttributedString.Key.backgroundView.rawValue] as? AnyView 47 | } 48 | set { 49 | customAttributes[NSAttributedString.Key.backgroundView.rawValue] = newValue 50 | } 51 | } 52 | 53 | public var spreadBreak: Bool { 54 | get { 55 | (customAttributes[NSAttributedString.Key.spreadBreak.rawValue] as? Bool) ?? false 56 | } 57 | set { 58 | customAttributes[NSAttributedString.Key.spreadBreak.rawValue] = newValue 59 | } 60 | } 61 | 62 | public var suppressHeader: Bool { 63 | get { 64 | (customAttributes[NSAttributedString.Key.suppressHeader.rawValue] as? Bool) ?? false 65 | } 66 | set { 67 | customAttributes[NSAttributedString.Key.suppressHeader.rawValue] = newValue 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/Environment.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct EnvironmentValues { 4 | public init(attributes: Attributes = Attributes()) { 5 | self.attributes = attributes 6 | } 7 | 8 | public var attributes = Attributes() 9 | 10 | var userDefined: [ObjectIdentifier:Any] = [:] 11 | 12 | public subscript(key: Key.Type = Key.self) -> Key.Value { 13 | get { 14 | userDefined[ObjectIdentifier(key)] as? Key.Value ?? Key.defaultValue 15 | } 16 | set { 17 | userDefined[ObjectIdentifier(key)] = newValue 18 | } 19 | } 20 | } 21 | 22 | public protocol EnvironmentKey { 23 | associatedtype Value 24 | static var defaultValue: Value { get } 25 | } 26 | 27 | public struct EnvironmentReader: AttributedStringConvertible where Content: AttributedStringConvertible { 28 | public init(_ keyPath: KeyPath, @AttributedStringBuilder content: @escaping (Part) -> Content) { 29 | self.keyPath = keyPath 30 | self.content = content 31 | } 32 | 33 | var keyPath: KeyPath 34 | var content: (Part) -> Content 35 | 36 | public func attributedString(context: inout Context) -> [NSAttributedString] { 37 | content(context.environment[keyPath: keyPath]).attributedString(context: &context) 38 | } 39 | } 40 | 41 | fileprivate struct EnvironmentModifier: AttributedStringConvertible where Content: AttributedStringConvertible { 42 | var keyPath: WritableKeyPath 43 | var modify: (inout Part) -> () 44 | var content: Content 45 | 46 | public func attributedString(context: inout Context) -> [NSAttributedString] { 47 | let oldEnv = context.environment 48 | defer { context.environment = oldEnv } 49 | modify(&context.environment[keyPath: keyPath]) 50 | return content.attributedString(context: &context) 51 | } 52 | } 53 | 54 | extension AttributedStringConvertible { 55 | public func environment(_ keyPath: WritableKeyPath, value: Value) -> some AttributedStringConvertible { 56 | EnvironmentModifier(keyPath: keyPath, modify: { $0 = value }, content: self) 57 | } 58 | 59 | public func transformEnvironment(_ keyPath: WritableKeyPath, transform: @escaping (inout Value) -> ()) -> some AttributedStringConvertible { 60 | EnvironmentModifier(keyPath: keyPath, modify: transform, content: self) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/Footnote.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct FootnoteCounter: StateKey { 4 | static var initialValue = 1 5 | } 6 | 7 | extension StateValues { 8 | public var footnoteCounter: Int { 9 | get { self[FootnoteCounter.self] } 10 | set { self[FootnoteCounter.self] = newValue } 11 | } 12 | } 13 | 14 | public struct Footnote: AttributedStringConvertible { 15 | public init(@AttributedStringBuilder contents: () -> Contents) { 16 | self.contents = contents() 17 | } 18 | 19 | var contents: Contents 20 | 21 | public func attributedString(context: inout Context) -> [NSAttributedString] { 22 | defer { context.state.footnoteCounter += 1 } 23 | let counter = "\(context.state.footnoteCounter)" 24 | let stylesheet = context.environment.markdownStylesheet 25 | let annotation = Joined(separator: " ") { 26 | "\(counter)\t" 27 | contents 28 | } 29 | .modify { 30 | stylesheet.footnote(attributes: &$0) 31 | $0.headIndent = $0.tabStops[0].location 32 | } 33 | .joined() 34 | let c = context 35 | let result = "\(counter)" 36 | .superscript() 37 | .modify { attrs in 38 | var copiedContext = c 39 | stylesheet.footnote(attributes: &attrs) 40 | attrs.annotation = annotation.run(context: &copiedContext) 41 | } 42 | return result.attributedString(context: &context) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/Heading.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import Foundation 4 | 5 | extension NSAttributedString.Key { 6 | static public let heading: Self = .init("io.objc.heading") 7 | } 8 | 9 | public struct HeadingInfo: Codable, Hashable { 10 | public init(text: String, level: Int) { 11 | self.text = text 12 | self.level = level 13 | } 14 | 15 | public var text: String 16 | public var level: Int 17 | } 18 | 19 | extension Attributes { 20 | mutating func heading(title: String, level: Int) { 21 | customAttributes[NSAttributedString.Key.heading.rawValue] = HeadingInfo(text: title, level: level) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/Image.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | extension NSImage: AttributedStringConvertible { 4 | public func attributedString(context: inout Context) -> [NSAttributedString] { 5 | let attachment = NSTextAttachment() 6 | attachment.image = self 7 | let str = NSMutableAttributedString(attachment: attachment) 8 | str.addAttributes(context.environment.attributes.attachmentAtts, range: NSRange(location: 0, length: str.length)) 9 | return [ 10 | str 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/InternalLinks.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | extension NSAttributedString.Key { 4 | static let internalName: Self = .init(rawValue: "io.objc.internalName") 5 | static let internalLink: Self = .init(rawValue: "io.objc.internalLink") 6 | } 7 | 8 | extension AttributedStringConvertible { 9 | public func internalName(name: N) -> some AttributedStringConvertible where N.RawValue == String { 10 | modify { $0.setInternalName(name: name) } 11 | } 12 | 13 | public func internalLink(name: N) -> some AttributedStringConvertible where N.RawValue == String { 14 | self.modify { 15 | $0.setInternalLink(name: name) 16 | } 17 | } 18 | } 19 | 20 | extension Attributes { 21 | mutating public func setInternalName(name: N) { 22 | customAttributes[NSAttributedString.Key.internalName.rawValue] = name.rawValue 23 | } 24 | 25 | mutating public func setInternalLink(name: N) { 26 | customAttributes[NSAttributedString.Key.internalLink.rawValue] = name.rawValue 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/Join.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | struct Joined: AttributedStringConvertible { 4 | var separator: AttributedStringConvertible = "\n" 5 | @AttributedStringBuilder var content: Content 6 | 7 | func attributedString(context: inout Context) -> [NSAttributedString] { 8 | [single(context: &context)] 9 | } 10 | 11 | @MainActor 12 | func single(context: inout Context) -> NSAttributedString { 13 | let pieces = content.attributedString(context: &context) 14 | guard let f = pieces.first else { return .init() } 15 | let result = NSMutableAttributedString(attributedString: f) 16 | let sep = separator.attributedString(context: &context) 17 | for piece in pieces.dropFirst() { 18 | for sepPiece in sep { 19 | result.append(sepPiece) 20 | } 21 | result.append(piece) 22 | } 23 | return result 24 | } 25 | } 26 | 27 | extension AttributedStringConvertible { 28 | public func joined(separator: AttributedStringConvertible = "\n") -> some AttributedStringConvertible { 29 | Joined(separator: separator, content: { 30 | self 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/LayoutManagerHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Florian Kugler on 25.04.23. 6 | // 7 | 8 | import Cocoa 9 | 10 | extension NSLayoutManager { 11 | func lineFragmentRects(for glyphRange: NSRange) -> [CGRect] { 12 | var lineRange = NSRange() 13 | var location = glyphRange.location 14 | var result: [CGRect] = [] 15 | while location < glyphRange.upperBound { 16 | let rect = lineFragmentRect(forGlyphAt: location, effectiveRange: &lineRange) 17 | result.append(rect) 18 | location = lineRange.upperBound 19 | } 20 | return result 21 | } 22 | 23 | func glyphPageRanges(for characterRange: NSRange) -> [NSRange] { 24 | var result: [NSRange] = [] 25 | let glyphRange = glyphRange(forCharacterRange: characterRange, actualCharacterRange: nil) 26 | var location = glyphRange.location 27 | var effectiveRange = NSRange() 28 | while location < glyphRange.upperBound { 29 | if let _ = textContainer(forGlyphAt: location, effectiveRange: &effectiveRange) { // todo not sure if this should be an if-let 30 | result.append(effectiveRange.intersection(glyphRange)!) 31 | location = effectiveRange.upperBound 32 | } else { 33 | location = glyphRange.upperBound 34 | } 35 | } 36 | return result 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/MarkdownHelper.swift: -------------------------------------------------------------------------------- 1 | import Markdown 2 | import AppKit 3 | 4 | public struct DefaultStylesheet: Stylesheet { } 5 | 6 | extension Stylesheet where Self == DefaultStylesheet { 7 | static public var `default`: Self { 8 | DefaultStylesheet() 9 | } 10 | } 11 | 12 | struct HighlightCode: EnvironmentKey { 13 | static var defaultValue: ((Code) -> any AttributedStringConvertible)? = nil 14 | } 15 | 16 | extension EnvironmentValues { 17 | public var highlightCode: ((Code) -> any AttributedStringConvertible)? { 18 | get { 19 | self[HighlightCode.self] 20 | } 21 | set { 22 | self[HighlightCode.self] = newValue 23 | } 24 | } 25 | } 26 | 27 | struct Rewriters: EnvironmentKey { 28 | static var defaultValue: [any MarkupRewriter] = [] 29 | } 30 | 31 | extension EnvironmentValues { 32 | var rewriters: [any MarkupRewriter] { 33 | get { self[Rewriters.self] } 34 | set { self[Rewriters.self] = newValue } 35 | } 36 | } 37 | 38 | struct CustomLinkRewriter: EnvironmentKey { 39 | static var defaultValue: ((Link, NSAttributedString) -> any AttributedStringConvertible)? = nil 40 | } 41 | 42 | extension EnvironmentValues { 43 | @_spi(Internal) public var linkRewriter: ((Link, NSAttributedString) -> any AttributedStringConvertible)? { 44 | get { self[CustomLinkRewriter.self] } 45 | set { self[CustomLinkRewriter.self] = newValue } 46 | } 47 | } 48 | 49 | extension AttributedStringConvertible { 50 | public func rewriter(_ r: any MarkupRewriter) -> some AttributedStringConvertible { 51 | transformEnvironment(\.rewriters, transform: { 52 | $0.append(r) 53 | }) 54 | } 55 | } 56 | 57 | public struct Code: Hashable, Codable { 58 | public init(language: String? = nil, code: String) { 59 | self.language = language 60 | self.code = code 61 | } 62 | 63 | public var language: String? 64 | public var code: String 65 | } 66 | 67 | @MainActor(unsafe) 68 | struct AttributedStringWalker: MarkupWalker { 69 | var interpolationSegments: [any AttributedStringConvertible] 70 | var context: Context 71 | var attributes: Attributes { 72 | get { context.environment.attributes } 73 | set { context.environment.attributes = newValue } 74 | } 75 | 76 | let stylesheet: Stylesheet 77 | var listLevel = 0 78 | var headingPath: [String] = [] 79 | var makeCheckboxURL: ((ListItem) -> URL?)? 80 | 81 | var highlightCode: ((Code) -> any AttributedStringConvertible)? { 82 | context.environment.highlightCode 83 | } 84 | 85 | var attributedStringStack: [NSMutableAttributedString] = [NSMutableAttributedString()] 86 | var attributedString: NSMutableAttributedString { 87 | get { attributedStringStack[attributedStringStack.endIndex-1] } 88 | } 89 | 90 | var customLinkVisitor: ((Link, NSAttributedString) -> any AttributedStringConvertible)? 91 | 92 | mutating func visitDocument(_ document: Document) -> () { 93 | for block in document.blockChildren { 94 | if !attributedString.string.isEmpty { 95 | attributedString.append(NSAttributedString(string: "\n", attributes: attributes)) 96 | } 97 | visit(block) 98 | } 99 | } 100 | 101 | func visitText(_ text: Text) -> () { 102 | attributedString.append(NSAttributedString(string: text.string, attributes: attributes)) 103 | } 104 | 105 | func visitLineBreak(_ lineBreak: LineBreak) -> () { 106 | attributedString.append(NSAttributedString(string: "\n", attributes: attributes)) 107 | } 108 | 109 | func visitSoftBreak(_ softBreak: SoftBreak) -> () { 110 | return 111 | } 112 | 113 | func visitInlineCode(_ inlineCode: InlineCode) -> () { 114 | var attributes = attributes 115 | stylesheet.inlineCode(attributes: &attributes) 116 | attributedString.append(NSAttributedString(string: inlineCode.code, attributes: attributes)) 117 | } 118 | 119 | mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> () { 120 | var attributes = attributes 121 | let code = codeBlock.code.trimmingCharacters(in: .whitespacesAndNewlines) 122 | if let h = highlightCode { 123 | let result = h(Code(language: codeBlock.language, code: codeBlock.code)).attributedString(context: &context) 124 | for r in result { 125 | attributedString.append(r) 126 | } 127 | } else { 128 | stylesheet.codeBlock(attributes: &attributes) 129 | attributedString.append(NSAttributedString(string: code, attributes: attributes)) 130 | } 131 | } 132 | 133 | func visitInlineHTML(_ inlineHTML: InlineHTML) -> () { 134 | fatalError() 135 | } 136 | 137 | func visitHTMLBlock(_ html: HTMLBlock) -> () { 138 | fatalError() 139 | } 140 | 141 | mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> () { 142 | let prefixStr = "io.objc.interpolate." 143 | var remainder = symbolLink.destination ?? "" 144 | guard remainder.hasPrefix(prefixStr) else { 145 | attributedString.append(NSAttributedString(string: remainder, attributes: attributes)) 146 | return 147 | } 148 | remainder.removeFirst(prefixStr.count) 149 | guard let i = Int(remainder) else { 150 | fatalError() 151 | } 152 | let component = interpolationSegments[i] 153 | attributedString.append(component.run(context: &context)) 154 | } 155 | 156 | mutating func visitEmphasis(_ emphasis: Emphasis) -> () { 157 | let original = attributes 158 | defer { attributes = original } 159 | 160 | stylesheet.emphasis(attributes: &attributes) 161 | 162 | for child in emphasis.children { 163 | visit(child) 164 | } 165 | } 166 | 167 | mutating func visitStrong(_ strong: Strong) -> () { 168 | let original = attributes 169 | defer { attributes = original } 170 | 171 | stylesheet.strong(attributes: &attributes) 172 | 173 | for child in strong.children { 174 | visit(child) 175 | } 176 | } 177 | 178 | func visitCustomBlock(_ customBlock: CustomBlock) -> () { 179 | fatalError() 180 | } 181 | 182 | func visitCustomInline(_ customInline: CustomInline) -> () { 183 | fatalError() 184 | } 185 | 186 | mutating func visitLink(_ link: Link) -> () { 187 | let original = attributes 188 | defer { attributes = original } 189 | 190 | stylesheet.link(attributes: &attributes, destination: link.destination ?? "") 191 | if let c = customLinkVisitor { 192 | attributedStringStack.append(NSMutableAttributedString()) 193 | } 194 | for child in link.children { 195 | visit(child) 196 | } 197 | 198 | if let c = customLinkVisitor { 199 | let linkText = attributedStringStack.popLast()! 200 | attributes = original 201 | for part in c(link, linkText).attributedString(context: &context) { 202 | attributedString.append(part) 203 | } 204 | } 205 | } 206 | 207 | mutating func visitHeading(_ heading: Heading) -> () { 208 | let original = attributes 209 | defer { attributes = original } 210 | let l = heading.level-1 211 | if headingPath.count > l { 212 | headingPath.removeSubrange(l...) 213 | } 214 | if headingPath.count < l { 215 | headingPath.append(contentsOf: Array(repeating: "", count: l-headingPath.count)) 216 | } 217 | headingPath.append(heading.plainText) 218 | stylesheet.headingLink(path: headingPath, attributes: &attributes) 219 | 220 | stylesheet.heading(level: heading.level, attributes: &attributes) 221 | attributes.heading(title: heading.plainText, level: heading.level) 222 | for child in heading.children { 223 | visit(child) 224 | } 225 | } 226 | 227 | mutating func visitOrderedList(_ orderedList: OrderedList) -> () { 228 | visit(list: orderedList) 229 | } 230 | 231 | mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> () { 232 | visit(list: unorderedList) 233 | } 234 | 235 | mutating private func visit(list: ListItemContainer) { 236 | let original = attributes 237 | defer { attributes = original } 238 | 239 | stylesheet.list(attributes: &attributes, level: listLevel) 240 | listLevel += 1 241 | defer { listLevel -= 1 } 242 | 243 | let isOrdered = list is OrderedList 244 | let startIndex = Int((list as? OrderedList)?.startIndex ?? 1) 245 | 246 | attributes.headIndent = attributes.tabStops[1].location 247 | 248 | for (item, number) in zip(list.listItems, startIndex...) { 249 | // Append list item prefix 250 | let prefix: String 251 | var prefixAttributes = attributes 252 | 253 | if let checkbox = item.checkbox { 254 | switch checkbox { 255 | case .checked: 256 | prefix = stylesheet.checkboxCheckedPrefix 257 | stylesheet.checkboxCheckedPrefix(attributes: &prefixAttributes) 258 | case .unchecked: 259 | prefix = stylesheet.checkboxUncheckedPrefix 260 | stylesheet.checkboxUncheckedPrefix(attributes: &prefixAttributes) 261 | } 262 | if let url = makeCheckboxURL?(item) { 263 | prefixAttributes.link = url 264 | } 265 | } else { 266 | if isOrdered { 267 | stylesheet.orderedListItemPrefix(attributes: &prefixAttributes) 268 | prefix = stylesheet.orderedListItemPrefix(number: number) 269 | } else { 270 | stylesheet.unorderedListItemPrefix(attributes: &prefixAttributes) 271 | prefix = stylesheet.unorderedListItemPrefix 272 | } 273 | } 274 | 275 | if number == list.childCount { 276 | // Restore spacing for last list item 277 | attributes.paragraphSpacing = original.paragraphSpacing 278 | prefixAttributes.paragraphSpacing = original.paragraphSpacing 279 | } 280 | 281 | attributedString.append(NSAttributedString(string: "\t", attributes: attributes)) 282 | attributedString.append(NSAttributedString(string: prefix, attributes: prefixAttributes)) 283 | attributedString.append(NSAttributedString(string: "\t", attributes: attributes)) 284 | 285 | visit(item) 286 | if number < list.childCount { 287 | attributedString.append(NSAttributedString(string: "\n", attributes: attributes)) 288 | } 289 | } 290 | } 291 | 292 | mutating func visitListItem(_ listItem: ListItem) -> () { 293 | let original = attributes 294 | defer { attributes = original } 295 | 296 | stylesheet.listItem(attributes: &attributes, checkbox: listItem.checkbox?.bool) 297 | 298 | var first = true 299 | for child in listItem.children { 300 | if !first { 301 | attributedString.append(NSAttributedString(string: "\n", attributes: attributes)) 302 | } 303 | first = false 304 | visit(child) 305 | } 306 | } 307 | 308 | mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> () { 309 | let original = attributes 310 | defer { attributes = original } 311 | stylesheet.blockQuote(attributes: &attributes) 312 | for child in blockQuote.children { 313 | visit(child) 314 | } 315 | } 316 | 317 | func visitThematicBreak(_ thematicBreak: ThematicBreak) -> () { 318 | // TODO we could consider making this stylable, but ideally the stylesheet doesn't know about NSAttributedString? 319 | let thematicBreak = NSAttributedString(string: "\n\r\u{00A0} \u{0009} \u{00A0}\n\n", attributes: [.strikethroughStyle: NSUnderlineStyle.single.rawValue, .strikethroughColor: NSColor.gray]) 320 | attributedString.append(thematicBreak) 321 | 322 | } 323 | } 324 | 325 | extension Checkbox { 326 | var bool: Bool { 327 | get { 328 | self == .checked 329 | } 330 | set { 331 | self = newValue ? .checked : .unchecked 332 | } 333 | } 334 | } 335 | 336 | fileprivate struct MarkdownHelper: AttributedStringConvertible { 337 | var segments: [any AttributedStringConvertible] 338 | var document: Document 339 | var stylesheet: any Stylesheet 340 | var makeCheckboxURL: ((ListItem) -> URL?)? 341 | 342 | func attributedString(context: inout Context) -> [NSAttributedString] { 343 | var copy = document 344 | let rewriters = context.environment.rewriters 345 | copy.rewrite(rewriters) 346 | let linkRewriter = context.environment.linkRewriter 347 | var walker = AttributedStringWalker(interpolationSegments: segments, context: context, stylesheet: stylesheet, makeCheckboxURL: makeCheckboxURL, customLinkVisitor: linkRewriter) 348 | walker.visit(copy) 349 | context.state = walker.context.state 350 | return [walker.attributedString] 351 | } 352 | } 353 | 354 | public struct Markdown: AttributedStringConvertible { 355 | public var source: MarkdownString 356 | public init(_ source: MarkdownString) { 357 | self.source = source 358 | } 359 | 360 | public func attributedString(context: inout Context) -> [NSAttributedString] { 361 | EnvironmentReader(\.markdownStylesheet) { stylesheet in 362 | MarkdownHelper(string: source, stylesheet: stylesheet) 363 | }.attributedString(context: &context) 364 | } 365 | } 366 | 367 | extension Document { 368 | mutating func rewrite(_ rewriters: [any MarkupRewriter]) { 369 | for var r in rewriters.reversed() { 370 | guard let d = r.visit(self) as? Document else { 371 | fatalError() 372 | } 373 | self = d 374 | } 375 | } 376 | } 377 | 378 | extension MarkdownHelper { 379 | init(string: MarkdownString, stylesheet: any Stylesheet) { 380 | var components: [any AttributedStringConvertible] = [] 381 | let str = string.pieces.map { 382 | switch $0 { 383 | case .raw(let s): return s 384 | case .component(let c): 385 | defer { components.append(c) } 386 | return "``io.objc.interpolate.\(components.count)``" 387 | } 388 | }.joined(separator: "") 389 | self.segments = components 390 | self.document = Document(parsing: str, options: .parseSymbolLinks) 391 | self.stylesheet = stylesheet 392 | self.makeCheckboxURL = nil 393 | } 394 | 395 | init(verbatim: String, stylesheet: any Stylesheet) { 396 | self.segments = [] 397 | self.document = Document(parsing: verbatim, options: .parseSymbolLinks) 398 | self.stylesheet = stylesheet 399 | self.makeCheckboxURL = nil 400 | } 401 | } 402 | 403 | struct MarkdownStylesheetKey: EnvironmentKey { 404 | static var defaultValue: any Stylesheet = .default 405 | } 406 | 407 | extension EnvironmentValues { 408 | public var markdownStylesheet: any Stylesheet { 409 | get { self[MarkdownStylesheetKey.self] } 410 | set { self[MarkdownStylesheetKey.self] = newValue } 411 | } 412 | } 413 | 414 | extension String { 415 | public func markdown(stylesheet: any Stylesheet = .default, highlightCode: ((Code) -> NSAttributedString)? = nil) -> some AttributedStringConvertible { 416 | MarkdownHelper(verbatim: self, stylesheet: stylesheet) 417 | } 418 | } 419 | 420 | extension Document { 421 | public func markdown(stylesheet: any Stylesheet = .default, makeCheckboxURL: ((ListItem) -> URL?)? = nil) -> some AttributedStringConvertible { 422 | MarkdownHelper(segments: [], document: self, stylesheet: stylesheet, makeCheckboxURL: makeCheckboxURL) 423 | } 424 | } 425 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/MarkdownString.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import Foundation 4 | 5 | public enum Piece { 6 | case raw(String) 7 | case component(any AttributedStringConvertible) 8 | } 9 | 10 | // This is a string-like type that allows for interpolation of custom segments that conform to AttributedStringConvertible. 11 | public struct MarkdownString: ExpressibleByStringInterpolation { 12 | public var pieces: [Piece] = [] 13 | 14 | public init(stringLiteral value: String) { 15 | pieces = [.raw(value)] 16 | } 17 | 18 | public init(stringInterpolation: Interpolation) { 19 | pieces = stringInterpolation.pieces 20 | } 21 | 22 | public init(pieces: [Piece]) { 23 | self.pieces = pieces 24 | } 25 | 26 | public struct Interpolation: StringInterpolationProtocol { 27 | var pieces: [Piece] = [] 28 | public init(literalCapacity: Int, interpolationCount: Int) { 29 | } 30 | 31 | public init(stringLiteral value: StringLiteralType) { 32 | pieces = [.raw(value)] 33 | } 34 | 35 | mutating public func appendLiteral(_ s: String) { 36 | pieces.append(.raw(s)) 37 | } 38 | 39 | mutating public func appendInterpolation(_ value: S) { 40 | pieces.append(.component(value)) 41 | } 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/MarkdownStylesheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Stylesheet.swift 3 | // 4 | // 5 | // Created by Juul Spee on 08/07/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A type that defines styles for various markdown elements by setting properties on a given `Attributes` value. 11 | public protocol Stylesheet { 12 | func emphasis(attributes: inout Attributes) 13 | func strong(attributes: inout Attributes) 14 | func inlineCode(attributes: inout Attributes) 15 | func codeBlock(attributes: inout Attributes) 16 | func blockQuote(attributes: inout Attributes) 17 | func link(attributes: inout Attributes, destination: String) 18 | func heading(level: Int, attributes: inout Attributes) 19 | func headingLink(path: [String], attributes: inout Attributes) 20 | func listItem(attributes: inout Attributes, checkbox: Bool?) 21 | func list(attributes: inout Attributes, level: Int) 22 | func orderedListItemPrefix(number: Int) -> String 23 | func orderedListItemPrefix(attributes: inout Attributes) 24 | var unorderedListItemPrefix: String { get } 25 | func unorderedListItemPrefix(attributes: inout Attributes) 26 | var checkboxCheckedPrefix: String { get } 27 | func checkboxCheckedPrefix(attributes: inout Attributes) 28 | var checkboxUncheckedPrefix: String { get } 29 | func checkboxUncheckedPrefix(attributes: inout Attributes) 30 | func footnote(attributes: inout Attributes) 31 | } 32 | 33 | extension Stylesheet { 34 | public func emphasis(attributes: inout Attributes) { 35 | attributes.italic = true 36 | } 37 | 38 | public func strong(attributes: inout Attributes) { 39 | attributes.bold = true 40 | } 41 | 42 | public func link(attributes: inout Attributes, destination: String) { 43 | attributes.textColor = .blue 44 | if let u = URL(string: destination) { 45 | attributes.link = u 46 | } 47 | } 48 | 49 | public func blockQuote(attributes: inout Attributes) { 50 | attributes.italic = true 51 | attributes.firstlineHeadIndent += 20 52 | attributes.headIndent += 20 53 | } 54 | 55 | public func listItem(attributes: inout Attributes, checkbox: Bool?) { 56 | if checkbox == true { 57 | attributes.textColor = .secondaryLabelColor 58 | } 59 | } 60 | 61 | public func list(attributes: inout Attributes, level: Int) { 62 | } 63 | 64 | public func orderedListItemPrefix(number: Int) -> String { 65 | "\(number)." 66 | } 67 | 68 | public func orderedListItemPrefix(attributes: inout Attributes) { } 69 | 70 | public var unorderedListItemPrefix: String { 71 | "•" 72 | } 73 | 74 | public func unorderedListItemPrefix(attributes: inout Attributes) { } 75 | 76 | public var checkboxCheckedPrefix: String { 77 | "[x]" // TODO: fix NSTextView image clicks for "􀃳" 78 | } 79 | 80 | public func checkboxCheckedPrefix(attributes: inout Attributes) { 81 | attributes.textColor = .controlAccentColor 82 | attributes.cursor = .arrow 83 | attributes.family = "Monaco" // monospaced 84 | } 85 | 86 | public var checkboxUncheckedPrefix: String { 87 | "[ ]" // TODO: fix NSTextView image clicks for "􀂒" 88 | } 89 | 90 | public func checkboxUncheckedPrefix(attributes: inout Attributes) { 91 | attributes.textColor = .secondaryLabelColor 92 | attributes.cursor = .arrow 93 | attributes.family = "Monaco" // monospaced 94 | } 95 | 96 | public func footnote(attributes: inout Attributes) { 97 | attributes.size *= 0.8 98 | } 99 | 100 | public func inlineCode(attributes: inout Attributes) { 101 | attributes.family = "Monaco" 102 | } 103 | 104 | public func codeBlock(attributes: inout Attributes) { 105 | attributes.family = "Monaco" 106 | attributes.lineHeightMultiple = 1 107 | } 108 | 109 | public func heading(level: Int, attributes: inout Attributes) { 110 | attributes.bold = true 111 | switch level { 112 | case 1: attributes.size = 48 113 | case 2: attributes.size = 36 114 | case 3: attributes.size = 28 115 | default: () 116 | } 117 | } 118 | 119 | public func headingLink(path: [String], attributes: inout Attributes) { 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/Modify.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | public struct Modify: AttributedStringConvertible { 4 | var modify: (inout Attributes) -> () 5 | public var contents: AttributedStringConvertible 6 | 7 | public func attributedString(context: inout Context) -> [NSAttributedString] { 8 | let old = context.environment.attributes 9 | defer { context.environment.attributes = old } 10 | modify(&context.environment.attributes) 11 | return contents.attributedString(context: &context) 12 | } 13 | } 14 | 15 | extension AttributedStringConvertible { 16 | public func modify(perform: @escaping (inout Attributes) -> () ) -> some AttributedStringConvertible { 17 | Modify(modify: perform, contents: self) 18 | } 19 | 20 | public func bold() -> some AttributedStringConvertible { 21 | modify { $0.bold = true } 22 | } 23 | 24 | public func superscript() -> some AttributedStringConvertible { 25 | modify { 26 | $0.customAttributes[NSAttributedString.Key.superscript.rawValue] = true 27 | } 28 | } 29 | 30 | public func textColor(_ color: NSColor) -> some AttributedStringConvertible { 31 | modify { $0.textColor = color } 32 | } 33 | } 34 | 35 | 36 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/PDF.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Cocoa 3 | 4 | extension CGFloat { 5 | public static var pointsPerInch: CGFloat { 72 } 6 | public static var pointsPerMM: CGFloat { pointsPerInch / 25.4 } 7 | } 8 | 9 | extension CGSize { 10 | static public let a4 = CGSize(width: 8.25 * .pointsPerInch, height: 11.75 * .pointsPerInch) 11 | } 12 | 13 | extension NSAttributedString { 14 | public func pdf(size: CGSize = .a4, inset: CGSize = .init(width: .pointsPerInch, height: .pointsPerInch)) -> Data { 15 | 16 | let storage = NSTextStorage(attributedString: self) 17 | let layoutManager = NSLayoutManager() 18 | storage.addLayoutManager(layoutManager) 19 | 20 | let data = NSMutableData() 21 | let consumer = CGDataConsumer(data: data)! 22 | var pageRect = CGRect(origin: .zero, size: size) 23 | let contentRect = pageRect.insetBy(dx: inset.width, dy: inset.height) 24 | let context = CGContext(consumer: consumer, mediaBox: &pageRect, nil)! 25 | 26 | NSGraphicsContext.saveGraphicsState() 27 | defer { NSGraphicsContext.restoreGraphicsState() } 28 | NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true) 29 | 30 | var needsMoreContainers = true 31 | while needsMoreContainers { 32 | let container = NSTextContainer(size: contentRect.size) 33 | layoutManager.addTextContainer(container) 34 | let range = layoutManager.glyphRange(for: container) 35 | needsMoreContainers = range.location + range.length < layoutManager.numberOfGlyphs 36 | 37 | context.beginPDFPage(nil) 38 | context.translateBy(x: 0, y: pageRect.height) 39 | context.scaleBy(x: 1, y: -1) 40 | layoutManager.drawBackground(forGlyphRange: range, at: contentRect.origin) 41 | layoutManager.drawGlyphs(forGlyphRange: range, at: contentRect.origin) 42 | context.endPDFPage() 43 | } 44 | context.closePDF() 45 | return data as Data 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/State.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct StateValues { 4 | public init() { 5 | } 6 | 7 | var userDefined: [ObjectIdentifier:Any] = [:] 8 | 9 | public subscript(key: Key.Type = Key.self) -> Key.Value { 10 | get { 11 | userDefined[ObjectIdentifier(key)] as? Key.Value ?? Key.initialValue 12 | } 13 | set { 14 | userDefined[ObjectIdentifier(key)] = newValue 15 | } 16 | } 17 | } 18 | 19 | public protocol StateKey { 20 | associatedtype Value 21 | static var initialValue: Value { get } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/StringHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import Foundation 4 | 5 | extension String { 6 | func trim(_ range: inout Range) { 7 | guard !range.isEmpty else { return } 8 | var lower = range.lowerBound 9 | var upper = index(before: range.upperBound) 10 | guard lower < upper else { 11 | return 12 | } 13 | while self[lower].isWhitespace { 14 | formIndex(after: &lower) 15 | } 16 | 17 | guard lower < upper else { 18 | range = lower.. lower { 22 | formIndex(before: &upper) 23 | } 24 | range = lower..(@ViewBuilder content: () -> Content) -> some AttributedStringConvertible { 7 | let c = content() 8 | return EnvironmentReader(\.modifyEnv) { modifyEnv in 9 | self.modify(perform: { 10 | $0.backgroundView = AnyView(c 11 | .transformEnvironment(\.self, transform: modifyEnv) 12 | .font(Font($0.computedFont)) 13 | ) 14 | }) 15 | } 16 | } 17 | } 18 | 19 | 20 | extension View { 21 | func snapshot(proposal: ProposedViewSize) -> NSImage? { 22 | let controller = NSHostingController(rootView: self.frame(width: proposal.width, height: proposal.height)) 23 | let targetSize = controller.view.intrinsicContentSize 24 | let contentRect = NSRect(origin: .zero, size: targetSize) 25 | 26 | let window = NSWindow( 27 | contentRect: contentRect, 28 | styleMask: [.borderless], 29 | backing: .buffered, 30 | defer: false 31 | ) 32 | window.contentView = controller.view 33 | 34 | guard 35 | let bitmapRep = controller.view.bitmapImageRepForCachingDisplay(in: contentRect) 36 | else { return nil } 37 | 38 | controller.view.cacheDisplay(in: contentRect, to: bitmapRep) 39 | let image = NSImage(size: bitmapRep.size) 40 | image.addRepresentation(bitmapRep) 41 | return image 42 | } 43 | } 44 | 45 | struct ModifySwiftUIEnvironment: EnvironmentKey { 46 | static var defaultValue: (inout SwiftUI.EnvironmentValues) -> () = { _ in () } 47 | } 48 | 49 | extension EnvironmentValues { 50 | var modifyEnv: (inout SwiftUI.EnvironmentValues) -> () { 51 | get { self[ModifySwiftUIEnvironment.self] } 52 | set { self[ModifySwiftUIEnvironment.self] = newValue } 53 | } 54 | } 55 | 56 | extension AttributedStringConvertible { 57 | public func transformSwiftUIEnvironment(_ transform: @escaping (inout SwiftUI.EnvironmentValues) -> ()) -> some AttributedStringConvertible { 58 | environment(\.modifyEnv, value: transform) 59 | } 60 | } 61 | 62 | struct DefaultEmbedProposal: EnvironmentKey { 63 | static let defaultValue: ProposedViewSize = .unspecified 64 | } 65 | 66 | extension EnvironmentValues { 67 | /// The default proposal that's used for ``Embed`` 68 | public var defaultProposal: ProposedViewSize { 69 | get { self[DefaultEmbedProposal.self] } 70 | set { self[DefaultEmbedProposal.self] = newValue } 71 | } 72 | } 73 | 74 | /// This takes a SwiftUI view and renders it to an image that's embedded into the resulting attributed string. 75 | /// 76 | /// You can customize the default proposal through the ``defaultProposal`` property in the environment. 77 | public struct Embed: AttributedStringConvertible { 78 | /// Embed a SwiftUI view into an attributed string 79 | /// - Parameters: 80 | /// - proposal: The size that's proposed to the view or `nil` if you want to have the default proposal (from the environment). 81 | /// - scale: The scale at which the view should be rendered 82 | /// - bitmap: Whether or not to embed the rendered image as a bitmap 83 | /// - view: The view 84 | public init(proposal: ProposedViewSize? = nil, scale: CGFloat = 1, bitmap: Bool = false, @ViewBuilder view: () -> V) { 85 | self.proposal = proposal 86 | self.view = view() 87 | self.scale = scale 88 | self.bitmap = bitmap 89 | } 90 | 91 | var scale: CGFloat 92 | var proposal: ProposedViewSize? 93 | var bitmap: Bool 94 | var view: V 95 | 96 | @MainActor 97 | public func attributedString(context: inout Context) -> [NSAttributedString] { 98 | let proposal = self.proposal ?? context.environment.defaultProposal 99 | let theView = view 100 | .transformEnvironment(\.self, transform: context.environment.modifyEnv) 101 | .font(SwiftUI.Font(context.environment.attributes.computedFont)) 102 | if bitmap { 103 | let i = theView.snapshot(proposal: proposal)! 104 | i.size.width *= scale 105 | i.size.height *= scale 106 | return i.attributedString(context: &context) 107 | } else { 108 | let renderer = ImageRenderer(content: theView) 109 | renderer.proposedSize = proposal 110 | let _ = renderer.nsImage! // this is necessary to get the correct size in the .render closure, even for pdf 111 | let data = NSMutableData() 112 | renderer.render { size, renderer in 113 | var mediaBox = CGRect(origin: .zero, size: size) 114 | guard let consumer = CGDataConsumer(data: data), 115 | let pdfContext = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) 116 | else { 117 | return 118 | } 119 | pdfContext.beginPDFPage(nil) 120 | pdfContext.translateBy(x: mediaBox.size.width / 2 - size.width / 2, 121 | y: mediaBox.size.height / 2 - size.height / 2) 122 | renderer(pdfContext) 123 | pdfContext.endPDFPage() 124 | pdfContext.closePDF() 125 | } 126 | let i = NSImage(data: data as Data)! 127 | i.size.width *= scale 128 | i.size.height *= scale 129 | return i.attributedString(context: &context) 130 | } 131 | } 132 | } 133 | 134 | 135 | -------------------------------------------------------------------------------- /Sources/AttributedStringBuilder/Table.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Chris Eidhof on 15.07.22. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | // TODO would be nice to have a builder here as well 12 | 13 | @MainActor 14 | public struct Table: AttributedStringConvertible { 15 | public init(contentWidth: Width = .percentage(100), rows: [TableRow]) { 16 | self.rows = rows 17 | self.contentWidth = contentWidth 18 | } 19 | 20 | public var rows: [TableRow] 21 | public var contentWidth: Width 22 | 23 | public func attributedString(context: inout Context) -> [NSAttributedString] { 24 | guard !rows.isEmpty else { return [] } 25 | let result = NSMutableAttributedString() 26 | let table = NSTextTable() 27 | table.setContentWidth(contentWidth.value, type: contentWidth.type) 28 | table.numberOfColumns = rows[0].cells.count 29 | for (rowIx, row) in rows.enumerated() { 30 | assert(row.cells.count == table.numberOfColumns) 31 | row.render(row: rowIx, table: table, context: &context, result: result) 32 | } 33 | result.replaceCharacters(in: NSRange(location: result.length-1, length: 1), with: "") 34 | return [result] 35 | } 36 | } 37 | 38 | @MainActor 39 | public struct TableRow { 40 | public init(cells: [TableCell]) { 41 | self.cells = cells 42 | } 43 | 44 | public var cells: [TableCell] 45 | 46 | func render(row: Int, table: NSTextTable, context: inout Context, result: NSMutableAttributedString) { 47 | for (column, cell) in cells.enumerated() { 48 | let block = NSTextTableBlock(table: table, startingRow: row, rowSpan: 1, startingColumn: column, columnSpan: 1) 49 | if let w = cell.width { 50 | block.setContentWidth(w.value, type: w.type) 51 | } 52 | cell.render(block: block, context: &context, result: result) 53 | } 54 | } 55 | } 56 | 57 | @MainActor 58 | public struct TableCell { 59 | public init( 60 | width: Table.Width? = nil, 61 | height: Table.Width? = nil, 62 | borderColor: NSColor = .black, 63 | borderWidth: WidthValue = 0, 64 | padding: WidthValue = 0, 65 | margin: WidthValue = 0, 66 | alignment: NSTextAlignment = .left, 67 | verticalAlignment: NSTextBlock.VerticalAlignment = .topAlignment, 68 | contents: AttributedStringConvertible) { 69 | self.borderColor = borderColor 70 | self.borderWidth = borderWidth 71 | self.padding = padding 72 | self.margin = margin 73 | self.contents = contents 74 | self.width = width 75 | self.height = height 76 | self.alignment = alignment 77 | self.verticalAlignment = verticalAlignment 78 | } 79 | 80 | public var borderColor: NSColor = .black 81 | public var borderWidth: WidthValue = 0 82 | public var padding: WidthValue = 0 83 | public var margin: WidthValue = 0 84 | public var contents: AttributedStringConvertible 85 | public var width: Table.Width? 86 | public var height: Table.Width? = nil 87 | public var alignment: NSTextAlignment = .left 88 | public var verticalAlignment: NSTextBlock.VerticalAlignment 89 | 90 | @MainActor 91 | func render(block: NSTextTableBlock, context: inout Context, result: NSMutableAttributedString) { 92 | block.setBorderColor(borderColor) 93 | for (edge, value) in borderWidth.allEdges { 94 | block.setWidth(value.value, type: value.type, for: .border, edge: edge) 95 | } 96 | for (edge, value) in padding.allEdges { 97 | block.setWidth(value.value, type: value.type, for: .padding, edge: edge) 98 | } 99 | for (edge, value) in margin.allEdges { 100 | block.setWidth(value.value, type: value.type, for: .margin, edge: edge) 101 | } 102 | if let h = height { 103 | block.setValue(h.value, type: h.type, for: .height) 104 | } 105 | block.verticalAlignment = verticalAlignment 106 | 107 | let paragraph = NSMutableParagraphStyle() 108 | paragraph.alignment = alignment 109 | paragraph.textBlocks = [block] 110 | 111 | let contentsA = NSMutableAttributedString(attributedString: contents.joined().run(context: &context)) 112 | 113 | // Copy some style attributes from the cell contents if possible 114 | if let style = contentsA.attributes(at: 0, effectiveRange: nil)[.paragraphStyle] as? NSParagraphStyle { 115 | paragraph.lineHeightMultiple = style.lineHeightMultiple 116 | paragraph.minimumLineHeight = style.minimumLineHeight 117 | paragraph.maximumLineHeight = style.maximumLineHeight 118 | } 119 | 120 | contentsA.mutableString.append("\n") // This is necessary to be recognized as a cell! 121 | let range = NSRange(location: 0, length: contentsA.string.count) 122 | contentsA.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraph, range: range) 123 | result.append(contentsA) 124 | } 125 | 126 | } 127 | 128 | extension Table { 129 | public enum Width: Hashable, Codable { 130 | case absolute(CGFloat) 131 | case percentage(CGFloat) 132 | 133 | var value: CGFloat { 134 | switch self { 135 | case .absolute(let x): return x 136 | case .percentage(let x): return x 137 | } 138 | } 139 | 140 | var type: NSTextBlock.ValueType { 141 | switch self { 142 | case .percentage: return .percentageValueType 143 | case .absolute: return .absoluteValueType 144 | } 145 | } 146 | 147 | 148 | } 149 | } 150 | 151 | public struct WidthValue: ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral, Hashable, Codable { 152 | public init(integerLiteral value: Int) { 153 | self.init(floatLiteral: .init(value)) 154 | } 155 | 156 | public init(floatLiteral value: Double) { 157 | self.init(top: value, right: value, bottom: value, left: value) 158 | } 159 | 160 | public init(top: CGFloat = 0, right: CGFloat = 0, bottom: CGFloat = 0, left: CGFloat = 0) { 161 | self.top = .absolute(top) 162 | self.right = .absolute(right) 163 | self.bottom = .absolute(bottom) 164 | self.left = .absolute(left) 165 | } 166 | 167 | public init(top: Table.Width = .absolute(0), right: Table.Width = .absolute(0), bottom: Table.Width = .absolute(0), left: Table.Width = .absolute(0)) { 168 | self.top = top 169 | self.right = right 170 | self.bottom = bottom 171 | self.left = left 172 | } 173 | 174 | public var top, right, bottom, left: Table.Width // todo should these be Width? 175 | 176 | var allEdges: [NSRectEdge: Table.Width] { 177 | [.minY: top, .maxY: bottom, .minX: left, .maxX: right] 178 | } 179 | 180 | } 181 | -------------------------------------------------------------------------------- /Sources/Tests/Environment.swift: -------------------------------------------------------------------------------- 1 | import AttributedStringBuilder 2 | import XCTest 3 | 4 | struct Test: EnvironmentKey { 5 | static let defaultValue: String = "Test" 6 | } 7 | 8 | extension EnvironmentValues { 9 | var test: String { 10 | get { self[Test.self] } 11 | set { self[Test.self] = newValue } 12 | } 13 | } 14 | 15 | class EnvironmentTests: XCTestCase { 16 | @MainActor 17 | func testDefaultValue() throws { 18 | @AttributedStringBuilder var str: some AttributedStringConvertible { 19 | EnvironmentReader(\.test) { envValue in 20 | envValue 21 | } 22 | } 23 | var context = Context(environment: .init()) 24 | let result = str.run(context: &context) 25 | XCTAssertEqual(result.string, "Test") 26 | 27 | context = Context(environment: .init()) 28 | 29 | let result2 = str 30 | .environment(\.test, value: "Hello") 31 | .run(context: &context) 32 | XCTAssertEqual(result2.string, "Hello") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Tests/MarkdownTests.swift: -------------------------------------------------------------------------------- 1 | @_spi(Internal) import AttributedStringBuilder 2 | import XCTest 3 | import Markdown 4 | 5 | @MainActor 6 | class MarkdownTests: XCTestCase { 7 | func testSimpleList() async { 8 | var context = Context(environment: .init()) 9 | let markdown = """ 10 | - One 11 | - Two 12 | - Three 13 | """ 14 | let attrStr = markdown.markdown().run(context: &context) 15 | let expectation = """ 16 | \t•\tOne 17 | \t•\tTwo 18 | \t•\tThree 19 | """ 20 | XCTAssertEqual(attrStr.string, expectation) 21 | } 22 | 23 | func testOrderedList() async { 24 | var context = Context(environment: .init()) 25 | let markdown = """ 26 | 1. One 27 | 1. Two 28 | 1. Three 29 | """ 30 | let attrStr = markdown.markdown().run(context: &context) 31 | let expectation = """ 32 | \t1.\tOne 33 | \t2.\tTwo 34 | \t3.\tThree 35 | """ 36 | XCTAssertEqual(attrStr.string, expectation) 37 | } 38 | 39 | func testIndentedList() { 40 | var context = Context(environment: .init()) 41 | let markdown = Markdown(""" 42 | - One 43 | - Two 44 | - Three 45 | - Four 46 | - Five 47 | """) 48 | let attrStr = markdown.run(context: &context) 49 | let expectation = """ 50 | \t•\tOne 51 | \t•\tTwo 52 | \t•\tThree 53 | \t•\tFour 54 | \t•\tFive 55 | """ 56 | XCTAssertEqual(attrStr.string, expectation) 57 | 58 | // let str = "
  • Two
    • Three
    • Four
" 59 | // print(NSAttributedString(html: str.data(using: .utf8)!, documentAttributes: nil)?.string) 60 | } 61 | 62 | func testRewriting() { 63 | var context = Context(environment: .init()) 64 | let markdown = """ 65 | Hello [World](https://www.objc.io) 66 | """ 67 | let attrStr = markdown.markdown() 68 | .rewriter(MyRewriter()) 69 | .run(context: &context) 70 | let expectation = """ 71 | Hello xWorldy 72 | """ 73 | XCTAssertEqual(attrStr.string, expectation) 74 | } 75 | 76 | func testLinkRewriting() { 77 | var context = Context(environment: .init()) 78 | let markdown = """ 79 | Hello [World](https://www.objc.io) 80 | """ 81 | let attrStr = markdown.markdown() 82 | .environment(\.linkRewriter) { node, str in 83 | Group { 84 | str 85 | "Suffix" 86 | .textColor(.red) 87 | } 88 | } 89 | .run(context: &context) 90 | let expectation = """ 91 | Hello WorldSuffix 92 | """ 93 | XCTAssertEqual(attrStr.string, expectation) 94 | let atts = attrStr.attributes(at: 13, effectiveRange: nil) 95 | XCTAssertEqual(atts[.foregroundColor] as? NSColor, NSColor.red) 96 | XCTAssertEqual(atts[.link] as? URL, nil) 97 | } 98 | } 99 | 100 | struct MyRewriter: MarkupRewriter { 101 | var count = 0 102 | func visitLink(_ link: Link) -> Markup? { 103 | let children = [Text("x")] + link.inlineChildren.compactMap { $0 as? RecurringInlineMarkup } + [Text("y")] 104 | return Link(destination: link.destination, children) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/Tests/ReadmeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | 3 | import Foundation 4 | import AttributedStringBuilder 5 | 6 | @AttributedStringBuilder var sample1: some AttributedStringConvertible { 7 | "Hello" 8 | "World".modify { $0.textColor = .red } 9 | } 10 | 11 | @AttributedStringBuilder var sample2: some AttributedStringConvertible { 12 | Markdown(""" 13 | This is *Markdown* syntax. 14 | 15 | With \("inline".modify { $0.underlineStyle = .single }) nesting. 16 | """) 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Tests/Tests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import AttributedStringBuilder 4 | 5 | struct BackgroundGradient: View { 6 | @Environment(\.highlightColor) var highlightColor 7 | 8 | var body: some View { 9 | RoundedRectangle(cornerRadius: 2) 10 | .fill(LinearGradient(colors: [.green, highlightColor], startPoint: .topLeading, endPoint: .bottomTrailing)) 11 | } 12 | } 13 | 14 | @AttributedStringBuilder @MainActor 15 | var example: some AttributedStringConvertible { 16 | Markdown(""" 17 | 1. This is a nested list. 18 | 1. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. 19 | 1. Two 20 | 1. Three 21 | 1. And the second item 22 | """) 23 | Markdown("Hello *\("test".textColor(.systemRed)) world*") 24 | Group { 25 | "Hello, World!" 26 | .bold() 27 | .modify { $0.backgroundColor = .yellow } 28 | Footnote { 29 | Markdown(""" 30 | Here's the *contents* of a footnote. 31 | """) 32 | } 33 | ". " 34 | let someMore = "Some more text" 35 | .background { 36 | BackgroundGradient() 37 | } 38 | someMore 39 | someMore 40 | .transformSwiftUIEnvironment { $0.highlightColor = .red } 41 | 42 | }.joined(separator: "") 43 | let gradient = Embed { 44 | BackgroundGradient() 45 | .frame(width: 50, height: 50) 46 | } 47 | Group { 48 | gradient; gradient.transformSwiftUIEnvironment { $0.highlightColor = .red } 49 | }.joined(separator: " ") 50 | Array(repeating: 51 | """ 52 | This is some markdown with **strong** `code` text. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas tempus, tortor eu maximus gravida, ante diam fermentum magna, in gravida ex tellus ac purus. 53 | 54 | - One 55 | - Two 56 | - Three 57 | - Four 58 | - Five 59 | 60 | ``` 61 | some code 62 | ``` 63 | 64 | And a number list: 65 | 66 | 1. One 67 | 1. Two 68 | 1. Three 69 | 70 | Checklist: 71 | 72 | - [ ] Unchecked item 73 | - [x] Checked item 74 | 75 | Another *paragraph*. 76 | 77 | > A blockquote. 78 | """.markdown() as any AttributedStringConvertible, count: 2) 79 | Table(rows: [ 80 | .init(cells: [ 81 | .init(borderColor: .green, borderWidth: .init(right: 2), contents: "Table Testing"), 82 | .init(contents: Embed { 83 | Circle().fill(LinearGradient(colors: [.blue, .red], startPoint: .top, endPoint: .bottom)) 84 | .frame(width: 100, height: 100) 85 | } ) 86 | ]) 87 | ]) 88 | .modify { $0.size = 10 } 89 | 90 | String(UnicodeScalar(12)) // pagebreak 91 | 92 | Table(rows: [ 93 | .init(cells: [ 94 | .init(contents: "Here is the first cell\nwith a newline"), 95 | .init(contents: "And the second cell"), 96 | ]), 97 | .init(cells: [ 98 | .init(contents: "Third"), 99 | .init(contents: "And fourth"), 100 | ]) 101 | ]) 102 | 103 | // NSImage(systemSymbolName: "hand.wave", accessibilityDescription: nil)! 104 | Embed { 105 | HStack { 106 | Image(systemName: "hand.wave") 107 | .font(.largeTitle) 108 | Text("Hello from SwiftUI") 109 | Color.red.frame(width: 100, height: 50) 110 | } 111 | } 112 | } 113 | 114 | let sampleAttributes = Attributes(family: "Georgia", size: 16, textColor: .black, paragraphSpacing: 10) 115 | 116 | struct HighlightColor: SwiftUI.EnvironmentKey { 117 | static let defaultValue: Color = Color.blue 118 | } 119 | 120 | extension SwiftUI.EnvironmentValues { 121 | var highlightColor: Color { 122 | get { self[HighlightColor.self] } 123 | set { self[HighlightColor.self] = newValue } 124 | } 125 | } 126 | 127 | 128 | class Tests: XCTestCase { 129 | @MainActor 130 | func testPDF() { 131 | var context = Context(environment: .init(attributes: sampleAttributes)) 132 | let data = example 133 | .joined(separator: "\n") 134 | .run(context: &context) 135 | .fancyPDF() 136 | .data 137 | try! data.write(to: .desktopDirectory.appending(component: "out.pdf")) 138 | 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | - [ ] Integrate Github Actions test runner on Mac so we can generate the output PDF 2 | - [ ] Modify environment 3 | - [ ] Documentation 4 | - [ ] Add @Environment property wrapper 5 | --------------------------------------------------------------------------------