Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift
172 lines · 6766 bytes
11 symbols in this file
1import Foundation
2
3/// Renders inline org content to `AttributedString` — the native counterpart to the HTML
4/// renderer, walking the same tree.
5///
6/// This is the prototype's argument: a second output format is a walk over the parsed tree,
7/// not a second parser. It stays Foundation-only (no SwiftUI), so it is usable anywhere; a
8/// SwiftUI block renderer would sit on top, using this for the inline runs inside each block.
9public struct OrgAttributedStringRenderer: Sendable {
10 /// Resolution options, so repository-relative links resolve exactly as they do in HTML.
11 public var options: OrgRenderOptions
12
13 public init(options: OrgRenderOptions = .init()) {
14 self.options = options
15 }
16
17 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
18
19 /// Render one run of inline objects, carrying intents a UI layer can style.
20 ///
21 /// Footnote numbering is document-wide, so a caller rendering a whole document threads
22 /// its own ``OrgFootnotes`` through ``inline(_:_:)``; this overload numbers within the
23 /// run it is given.
24 public func inline(_ objects: [OrgObject]) -> AttributedString {
25 var notes = OrgFootnotes(document: OrgDocument())
26 return inline(objects, ¬es)
27 }
28
29 package func inline(_ objects: [OrgObject], _ notes: inout OrgFootnotes) -> AttributedString {
30 var result = AttributedString()
31 for object in objects {
32 switch object {
33 case .text(let text):
34 result += AttributedString(text)
35
36 case .bold(let children):
37 result += applying(.stronglyEmphasized, to: inline(children, ¬es))
38
39 case .italic(let children):
40 result += applying(.emphasized, to: inline(children, ¬es))
41
42 // Underline and strikethrough have no Foundation-portable attribute (the
43 // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they
44 // travel as roles the UI layer applies.
45 case .underline(let children):
46 result += applying(.underline, to: inline(children, ¬es))
47
48 case .strikeThrough(let children):
49 result += applying(.strikeThrough, to: inline(children, ¬es))
50
51 case .verbatim(let text), .code(let text):
52 result += applying(.code, to: AttributedString(text))
53
54 case .superscript(let children):
55 // No portable superscript attribute; mark it so a UI layer can raise it.
56 result += applying(.superscript, to: inline(children, ¬es))
57
58 case .lineBreak:
59 result += AttributedString("\n")
60
61 case .math(let math):
62 var part = AttributedString(math.source)
63 part[OrgMathAttribute.self] = math
64 result += part
65
66 case .timestamp(let stamp):
67 var part = AttributedString(stamp.displayValue)
68 part.orgRole = .timestamp
69 result += part
70
71 case .footnoteRef(let label, let inlineNote):
72 var part = AttributedString("[\(notes.number(for: label, inline: inlineNote))]")
73 part.orgRole = .footnoteReference
74 result += part
75
76 case .image(let figure):
77 var part = AttributedString(figure.alt ?? figure.source)
78 part.orgRole = .image
79 result += part
80
81 case .link(let link):
82 var part = link.description.map { inline($0, ¬es) }
83 ?? AttributedString(resolver.displayValue(link.target))
84 if let href = resolver.href(for: link.target), let url = URL(string: href) {
85 part.link = url
86 }
87 result += part
88 }
89 }
90 return result
91 }
92
93 // MARK: - Nesting
94
95 /// Add an intent to every run, keeping intents the children already carry. Assigning
96 /// over the whole range instead would flatten `*bold /italic/*` to bold alone.
97 private func applying(
98 _ intent: InlinePresentationIntent, to string: AttributedString
99 ) -> AttributedString {
100 var result = string
101 for range in result.runs.map(\.range) {
102 result[range].inlinePresentationIntent =
103 (result[range].inlinePresentationIntent ?? []).union(intent)
104 }
105 return result
106 }
107
108 /// Mark runs that carry no role yet. A role is a single slot, so the innermost — the more
109 /// specific — wins; org nests two role-bearing markups (`_+both+_`) rarely enough that a
110 /// set is not worth the public API.
111 private func applying(_ role: OrgRole, to string: AttributedString) -> AttributedString {
112 var result = string
113 for range in result.runs.map(\.range) where result[range][OrgRoleAttribute.self] == nil {
114 result[range][OrgRoleAttribute.self] = role
115 }
116 return result
117 }
118
119 /// Flatten a whole document to attributed paragraphs — a convenience for callers that
120 /// want text without building block views (a share sheet, a plain-text export).
121 public func paragraphs(_ document: OrgDocument) -> [AttributedString] {
122 document.elements.compactMap { element in
123 switch element {
124 case .paragraph(let objects): return inline(objects)
125 case .heading(let heading): return inline(heading.title)
126 default: return nil
127 }
128 }
129 }
130
131}
132
133// MARK: - Custom attribute
134
135/// Org roles that `AttributedString` has no standard attribute for. A UI layer reads these to
136/// decide presentation (raise a superscript, tint a timestamp, make a footnote ref tappable)
137/// without the renderer needing to know about fonts or colors.
138public enum OrgRole: String, Sendable, Codable {
139 case superscript
140 case timestamp
141 case footnoteReference
142 case image
143 case underline
144 case strikeThrough
145}
146
147public enum OrgRoleAttribute: AttributedStringKey {
148 public typealias Value = OrgRole
149 public static let name = "orgRole"
150}
151
152/// The expression a run's text is the source of. A UI layer with a typesetter draws it in
153/// place of the text.
154public enum OrgMathAttribute: AttributedStringKey {
155 public typealias Value = OrgMath
156 public static let name = "orgMath"
157}
158
159public extension AttributeScopes {
160 struct OrgAttributes: AttributeScope {
161 public let orgRole: OrgRoleAttribute
162 public let orgMath: OrgMathAttribute
163 }
164 var org: OrgAttributes.Type { OrgAttributes.self }
165}
166
167public extension AttributedString {
168 var orgRole: OrgRole? {
169 get { self[OrgRoleAttribute.self] }
170 set { self[OrgRoleAttribute.self] = newValue }
171 }
172}