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.5.0
org-swift/Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift history · blame · raw

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, &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 .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, &notes) }
 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}