Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift
144 lines · 5157 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
11 public init() {}
12
13 /// Render one run of inline objects, carrying intents a UI layer can style.
14 public func inline(_ objects: [OrgObject]) -> AttributedString {
15 var result = AttributedString()
16 for object in objects {
17 switch object {
18 case .text(let text):
19 result += AttributedString(text)
20
21 case .bold(let children):
22 var part = inline(children)
23 part.inlinePresentationIntent = .stronglyEmphasized
24 result += part
25
26 case .italic(let children):
27 var part = inline(children)
28 part.inlinePresentationIntent = .emphasized
29 result += part
30
31 // Underline and strikethrough have no Foundation-portable attribute (the
32 // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they
33 // travel as roles the UI layer applies.
34 case .underline(let children):
35 var part = inline(children)
36 part.orgRole = .underline
37 result += part
38
39 case .strikeThrough(let children):
40 var part = inline(children)
41 part.orgRole = .strikeThrough
42 result += part
43
44 case .verbatim(let text), .code(let text):
45 var part = AttributedString(text)
46 part.inlinePresentationIntent = .code
47 result += part
48
49 case .superscript(let children):
50 // No portable superscript attribute; mark it so a UI layer can raise it.
51 var part = inline(children)
52 part.orgRole = .superscript
53 result += part
54
55 case .lineBreak:
56 result += AttributedString("\n")
57
58 case .timestamp(let stamp):
59 var part = AttributedString(stamp.displayValue)
60 part.orgRole = .timestamp
61 result += part
62
63 case .footnoteRef(let label, _):
64 var part = AttributedString("[\(label)]")
65 part.orgRole = .footnoteReference
66 result += part
67
68 case .image(let figure):
69 var part = AttributedString(figure.alt ?? figure.source)
70 part.orgRole = .image
71 result += part
72
73 case .link(let link):
74 var part = link.description.map { inline($0) } ?? AttributedString(displayValue(link.target))
75 if let url = URL(string: hrefValue(link.target)) {
76 part.link = url
77 }
78 result += part
79 }
80 }
81 return result
82 }
83
84 /// Flatten a whole document to attributed paragraphs — a convenience for callers that
85 /// want text without building block views (a share sheet, a plain-text export).
86 public func paragraphs(_ document: OrgDocument) -> [AttributedString] {
87 document.elements.compactMap { element in
88 switch element {
89 case .paragraph(let objects): return inline(objects)
90 case .heading(let heading): return inline(heading.title)
91 default: return nil
92 }
93 }
94 }
95
96 private func hrefValue(_ target: OrgLinkTarget) -> String {
97 switch target {
98 case .external(let url): return url
99 case .file(let path): return path
100 case .id(let identifier): return "#\(identifier)"
101 }
102 }
103
104 private func displayValue(_ target: OrgLinkTarget) -> String {
105 switch target {
106 case .external(let url): return url
107 case .file(let path): return path
108 case .id(let identifier): return identifier
109 }
110 }
111}
112
113// MARK: - Custom attribute
114
115/// Org roles that `AttributedString` has no standard attribute for. A UI layer reads these to
116/// decide presentation (raise a superscript, tint a timestamp, make a footnote ref tappable)
117/// without the renderer needing to know about fonts or colors.
118public enum OrgRole: String, Sendable, Codable {
119 case superscript
120 case timestamp
121 case footnoteReference
122 case image
123 case underline
124 case strikeThrough
125}
126
127public enum OrgRoleAttribute: AttributedStringKey {
128 public typealias Value = OrgRole
129 public static let name = "orgRole"
130}
131
132public extension AttributeScopes {
133 struct OrgAttributes: AttributeScope {
134 public let orgRole: OrgRoleAttribute
135 }
136 var org: OrgAttributes.Type { OrgAttributes.self }
137}
138
139public extension AttributedString {
140 var orgRole: OrgRole? {
141 get { self[OrgRoleAttribute.self] }
142 set { self[OrgRoleAttribute.self] = newValue }
143 }
144}