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

html library org-mode swift

Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift

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

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}