krz/org-swift

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

Sources/OrgSwiftUI/OrgLayoutBuilder.swift

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

234 lines · 9919 bytes

12 symbols in this file
  1import OrgSwift
  2import SwiftUI
  3
  4/// Walks an ``OrgDocument`` into an ``OrgLayout``.
  5///
  6/// The third renderer over the shared tree, alongside HTML and `AttributedString`. It
  7/// resolves URLs through the same ``OrgURLResolver`` and numbers footnotes through the same
  8/// ``OrgFootnotes``, so a link that works in the HTML output works here and note 1 is note 1
  9/// in both.
 10@MainActor
 11struct OrgLayoutBuilder {
 12    var options: OrgRenderOptions
 13    var styler: OrgCodeStyler
 14    var keywords: OrgKeywordStyle
 15    var textTransform: ((inout AttributedString) -> Void)? = nil
 16
 17    private var inline: OrgInlineStyler { OrgInlineStyler(options: options) }
 18    private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
 19
 20    func layout(_ document: OrgDocument) -> OrgLayout {
 21        var notes = OrgFootnotes(document: document)
 22        var layout = OrgLayout()
 23        layout.blocks = document.elements.flatMap { block($0, &notes) }
 24        // Numbering is complete only after the whole body is walked, so the notes section is
 25        // built last — exactly as the HTML renderer does it.
 26        layout.footnotes = notes.notes.map {
 27            OrgFootnote(number: $0.number, text: text($0.content, &notes, style: .footnote))
 28        }
 29        return layout
 30    }
 31
 32    private func text(
 33        _ objects: [OrgObject], _ notes: inout OrgFootnotes,
 34        style: Font.TextStyle = .body, bold: Bool = false
 35    ) -> AttributedString {
 36        var string = inline.attributed(objects, &notes, style: style, bold: bold)
 37        textTransform?(&string)
 38        return string
 39    }
 40
 41    /// One element to zero or more blocks. Zero for a footnote definition (it moves to the
 42    /// notes section) and for an export block aimed at another backend.
 43    private func block(_ element: OrgElement, _ notes: inout OrgFootnotes) -> [OrgBlock] {
 44        switch element {
 45        case .heading(let heading):
 46            var title = AttributedString()
 47            if let todo = heading.todo {
 48                var keyword = AttributedString(todo + " ")
 49                keyword.foregroundColor = keywords.color(for: todo)
 50                title += keyword
 51            }
 52            if let priority = heading.priority {
 53                var cookie = AttributedString("[#\(priority)] ")
 54                cookie.foregroundColor = .secondary
 55                title += cookie
 56            }
 57            let level = min(6, max(1, heading.level + options.headingLevelOffset))
 58            title += text(heading.title, &notes, style: Self.headingStyle(level), bold: true)
 59            for tag in heading.tags {
 60                var chip = AttributedString("  \(tag)")
 61                chip.foregroundColor = .secondary
 62                chip.font = .system(.caption, design: .monospaced)
 63                title += chip
 64            }
 65            return [.heading(text: title, level: level)]
 66
 67        case .paragraph(let objects):
 68            if let badges = badgeRow(objects) { return [.badges(badges)] }
 69            if let math = displayMath(objects) { return [.math(math)] }
 70            return [.paragraph(text(objects, &notes))]
 71
 72        case .list(let list):
 73            return [.list(listLayout(list, &notes))]
 74
 75        case .table(let table):
 76            return [.table(tableLayout(table, &notes))]
 77
 78        case .srcBlock(let language, let code):
 79            return [.code(styled(code: code, language: language))]
 80
 81        case .exampleBlock(let raw):
 82            return [.code(styled(code: raw, language: nil))]
 83
 84        case .quoteBlock(let children):
 85            return [.quote(children.flatMap { block($0, &notes) })]
 86
 87        case .centerBlock(let children):
 88            return [.center(children.flatMap { block($0, &notes) })]
 89
 90        case .verseBlock(let lines):
 91            return [.verse(lines.map { text($0, &notes) })]
 92
 93        case .specialBlock(let name, let children):
 94            return [.special(name: name, blocks: children.flatMap { block($0, &notes) })]
 95
 96        case .exportBlock(let backend, let raw):
 97            // HTML passes through verbatim in the HTML renderer; natively there is nothing to
 98            // pass it to, so it shows as the literal block it is rather than vanishing.
 99            return backend == "html" ? [.code(styled(code: raw, language: nil))] : []
100
101        case .figure(let figure):
102            return [imageBlock(figure, &notes)]
103
104        case .captioned(_, let caption, let content):
105            return [.captioned(
106                caption: caption.map { text($0, &notes, style: .footnote) },
107                blocks: block(content, &notes)
108            )]
109
110        case .horizontalRule:
111            return [.rule]
112
113        case .footnoteDefinition:
114            return []  // collected into the notes section
115
116        case .latexBlock(let raw):
117            return [.math(OrgMath(tex: raw, display: true, source: raw))]
118        }
119    }
120
121    /// The one display expression a paragraph holds, `\[…\]` on a line of its own being the
122    /// usual way to write a displayed equation.
123    private func displayMath(_ objects: [OrgObject]) -> OrgMath? {
124        let content = objects.filter {
125            if case .text(let text) = $0 { !text.trimmingCharacters(in: .whitespaces).isEmpty } else { true }
126        }
127        guard content.count == 1, case .math(let math) = content[0], math.display else { return nil }
128        return math
129    }
130
131    /// A paragraph holding nothing but images and image links is the badge row a README
132    /// opens with. It renders as a row of images at their own size rather than as prose —
133    /// the inline styler can only show an image's alt text, and a figure block would blow a
134    /// 95×20 badge up to the column width.
135    ///
136    /// Anything else in the paragraph, a word of prose included, means it is prose.
137    private func badgeRow(_ objects: [OrgObject]) -> [OrgBadge]? {
138        var badges: [OrgBadge] = []
139        for object in objects {
140            switch object {
141            case .text(let string) where string.trimmingCharacters(in: .whitespaces).isEmpty:
142                continue
143            case .image(let figure):
144                guard let badge = badge(figure, link: nil) else { return nil }
145                badges.append(badge)
146            case .link(let link):
147                guard link.description?.count == 1,
148                      case .image(let figure) = link.description?.first
149                else { return nil }
150                // A badge whose link will not resolve is still a badge; it just is not
151                // tappable.
152                let href = resolver.href(for: link.target).flatMap { URL(string: $0) }
153                guard let badge = badge(figure, link: href) else { return nil }
154                badges.append(badge)
155            default:
156                return nil
157            }
158        }
159        return badges.isEmpty ? nil : badges
160    }
161
162    private func badge(_ figure: OrgFigure, link: URL?) -> OrgBadge? {
163        guard let string = resolver.imageSource(figure.source),
164              let url = URL(string: string), url.scheme != nil
165        else { return nil }
166        return OrgBadge(source: url, alt: figure.alt ?? "", link: link)
167    }
168
169    /// An image whose source will not resolve to a safe absolute URL degrades to its alt
170    /// text, the same trade the HTML renderer makes rather than pointing at something unsafe.
171    ///
172    /// The absolute requirement is the one place this diverges from HTML, which may emit a
173    /// relative `src` for a document base URL to resolve. A view has no base, so a relative
174    /// source is unloadable and showing the text beats showing a broken image.
175    private func imageBlock(_ figure: OrgFigure, _ notes: inout OrgFootnotes) -> OrgBlock {
176        let caption = figure.caption.map { text($0, &notes, style: .footnote) }
177        guard let string = resolver.imageSource(figure.source),
178              let url = URL(string: string), url.scheme != nil else {
179            return .paragraph(AttributedString(figure.alt ?? figure.source))
180        }
181        let alt = figure.alt ?? figure.caption.map { OrgParser.plain($0) } ?? ""
182        return .image(source: url, alt: alt, caption: caption)
183    }
184
185    private func styled(code: String, language: String?) -> AttributedString {
186        if let highlighted = styler.highlighted(code: code, language: language) {
187            return highlighted
188        }
189        var plain = AttributedString(code)
190        plain.font = .system(.footnote, design: .monospaced)
191        return plain
192    }
193
194    private func listLayout(_ list: OrgList, _ notes: inout OrgFootnotes) -> OrgListLayout {
195        var items: [OrgListLayout.Item] = []
196        for item in list.items {
197            items.append(OrgListLayout.Item(
198                checkbox: item.checkbox,
199                term: item.term.map { term in
200                    var styled = text(term, &notes)
201                    styled.font = .body.bold()
202                    return styled
203                },
204                blocks: item.content.flatMap { block($0, &notes) }
205            ))
206        }
207        return OrgListLayout(kind: list.kind, items: items)
208    }
209
210    private func tableLayout(_ table: OrgTable, _ notes: inout OrgFootnotes) -> OrgTableLayout {
211        var rows: [OrgTableLayout.Row] = []
212        for row in table.rows {
213            guard case .cells(let cells) = row else { continue }
214            rows.append(OrgTableLayout.Row(cells: cells.map {
215                OrgTableLayout.Cell(text: text($0, &notes, style: .subheadline))
216            }))
217        }
218        return OrgTableLayout(
219            rows: rows,
220            headerRowCount: table.headerRowCount,
221            alignments: table.alignments
222        )
223    }
224
225    private static func headingStyle(_ level: Int) -> Font.TextStyle {
226        switch level {
227        case 1: .title
228        case 2: .title2
229        case 3: .title3
230        case 4: .headline
231        default: .subheadline
232        }
233    }
234}