krz/org-swift

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

Sources/OrgSwiftUI/OrgInlineStyler.swift

v0.9.0
org-swift/Sources/OrgSwiftUI/OrgInlineStyler.swift history · blame · raw

94 lines · 3924 bytes

3 symbols in this file
 1import OrgSwift
 2import SwiftUI
 3
 4/// Turns a run of inline org objects into a styled `AttributedString`.
 5///
 6/// The parse and the attribute intents come from ``OrgAttributedStringRenderer``; this adds
 7/// only presentation. Everything is expressed in semantic `Font.TextStyle`s and semantic
 8/// colors, so the result scales with Dynamic Type and follows light/dark on its own.
 9struct OrgInlineStyler {
10    var renderer: OrgAttributedStringRenderer
11
12    init(options: OrgRenderOptions) {
13        renderer = OrgAttributedStringRenderer(options: options)
14    }
15
16    /// - Parameter style: the text style of the block this run sits in, so emphasis inside a
17    ///   heading bolds the heading's font rather than the body font.
18    /// - Parameters:
19    ///   - style: the text style of the block this run sits in, so emphasis inside a heading
20    ///     bolds the heading's font rather than the body font.
21    ///   - bold: forces every run bold, for headings.
22    func attributed(
23        _ objects: [OrgObject], _ notes: inout OrgFootnotes,
24        style: Font.TextStyle = .body, bold forceBold: Bool = false
25    ) -> AttributedString {
26        var string = renderer.inline(objects, &notes)
27
28        for range in string.runs.map(\.range) {
29            let intents = string[range].inlinePresentationIntent ?? []
30            let role = string[range][OrgRoleAttribute.self]
31
32            var textStyle = style
33            var monospaced = false
34            var bold = forceBold
35            var italic = false
36
37            if intents.contains(.stronglyEmphasized) { bold = true }
38            if intents.contains(.emphasized) { italic = true }
39            if intents.contains(.code) {
40                monospaced = true
41                string[range].backgroundColor = .secondary.opacity(0.12)
42            }
43
44            switch role {
45            case .underline:
46                string[range].underlineStyle = .single
47            case .strikeThrough:
48                string[range].strikethroughStyle = .single
49            case .superscript, .footnoteReference:
50                // No portable superscript attribute, so raise it and step the style down.
51                textStyle = Self.smaller(textStyle)
52                string[range].baselineOffset = 5
53                if role == .footnoteReference { string[range].foregroundColor = .accentColor }
54            case .timestamp:
55                string[range].foregroundColor = .secondary
56            case .image:
57                // An inline image inside a paragraph shows its alt text; the block-level
58                // `.figure` case is where an image is actually loaded.
59                italic = true
60                string[range].foregroundColor = .secondary
61            case nil:
62                break
63            }
64
65            if string[range][OrgMathAttribute.self] != nil {
66                string[range][OrgMathStyleAttribute.self] = textStyle
67            }
68
69            // Always set the font, never only on modified runs: a run with no emphasis
70            // would otherwise inherit the surrounding view's font and a heading, table cell
71            // or footnote would render at body size.
72            var font = Font.system(textStyle, design: monospaced ? .monospaced : .default)
73            if bold { font = font.bold() }
74            if italic { font = font.italic() }
75            string[range].font = font
76        }
77        return string
78    }
79
80    /// One step down the semantic scale, for superscripts and footnote markers.
81    private static func smaller(_ style: Font.TextStyle) -> Font.TextStyle {
82        switch style {
83        case .largeTitle: .title
84        case .title: .title2
85        case .title2: .title3
86        case .title3: .headline
87        case .headline, .body: .footnote
88        case .callout: .caption
89        case .subheadline: .caption
90        case .footnote, .caption, .caption2: .caption2
91        @unknown default: .caption
92        }
93    }
94}