Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift
159 lines · 6302 bytes
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 .timestamp(let stamp):
62 var part = AttributedString(stamp.displayValue)
63 part.orgRole = .timestamp
64 result += part
65
66 case .footnoteRef(let label, let inlineNote):
67 var part = AttributedString("[\(notes.number(for: label, inline: inlineNote))]")
68 part.orgRole = .footnoteReference
69 result += part
70
71 case .image(let figure):
72 var part = AttributedString(figure.alt ?? figure.source)
73 part.orgRole = .image
74 result += part
75
76 case .link(let link):
77 var part = link.description.map { inline($0, ¬es) }
78 ?? AttributedString(resolver.displayValue(link.target))
79 if let href = resolver.href(for: link.target), let url = URL(string: href) {
80 part.link = url
81 }
82 result += part
83 }
84 }
85 return result
86 }
87
88 // MARK: - Nesting
89
90 /// Add an intent to every run, keeping intents the children already carry. Assigning
91 /// over the whole range instead would flatten `*bold /italic/*` to bold alone.
92 private func applying(
93 _ intent: InlinePresentationIntent, to string: AttributedString
94 ) -> AttributedString {
95 var result = string
96 for range in result.runs.map(\.range) {
97 result[range].inlinePresentationIntent =
98 (result[range].inlinePresentationIntent ?? []).union(intent)
99 }
100 return result
101 }
102
103 /// Mark runs that carry no role yet. A role is a single slot, so the innermost — the more
104 /// specific — wins; org nests two role-bearing markups (`_+both+_`) rarely enough that a
105 /// set is not worth the public API.
106 private func applying(_ role: OrgRole, to string: AttributedString) -> AttributedString {
107 var result = string
108 for range in result.runs.map(\.range) where result[range][OrgRoleAttribute.self] == nil {
109 result[range][OrgRoleAttribute.self] = role
110 }
111 return result
112 }
113
114 /// Flatten a whole document to attributed paragraphs — a convenience for callers that
115 /// want text without building block views (a share sheet, a plain-text export).
116 public func paragraphs(_ document: OrgDocument) -> [AttributedString] {
117 document.elements.compactMap { element in
118 switch element {
119 case .paragraph(let objects): return inline(objects)
120 case .heading(let heading): return inline(heading.title)
121 default: return nil
122 }
123 }
124 }
125
126}
127
128// MARK: - Custom attribute
129
130/// Org roles that `AttributedString` has no standard attribute for. A UI layer reads these to
131/// decide presentation (raise a superscript, tint a timestamp, make a footnote ref tappable)
132/// without the renderer needing to know about fonts or colors.
133public enum OrgRole: String, Sendable, Codable {
134 case superscript
135 case timestamp
136 case footnoteReference
137 case image
138 case underline
139 case strikeThrough
140}
141
142public enum OrgRoleAttribute: AttributedStringKey {
143 public typealias Value = OrgRole
144 public static let name = "orgRole"
145}
146
147public extension AttributeScopes {
148 struct OrgAttributes: AttributeScope {
149 public let orgRole: OrgRoleAttribute
150 }
151 var org: OrgAttributes.Type { OrgAttributes.self }
152}
153
154public extension AttributedString {
155 var orgRole: OrgRole? {
156 get { self[OrgRoleAttribute.self] }
157 set { self[OrgRoleAttribute.self] = newValue }
158 }
159}