krz/org-swift

A dependency-free Swift library that renders org-mode to sanitized HTML. html library org-mode swift

Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift

v0.8.0
org-swift/Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift history · blame · raw

172 lines · 6766 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, &notes)
 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, &notes))
 38
 39            case .italic(let children):
 40                result += applying(.emphasized, to: inline(children, &notes))
 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, &notes))
 47
 48            case .strikeThrough(let children):
 49                result += applying(.strikeThrough, to: inline(children, &notes))
 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, &notes))
 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, &notes) }
 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}