Sources/OrgSwiftUI/OrgLayoutBuilder.swift
178 lines · 7282 bytes
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
15 private var inline: OrgInlineStyler { OrgInlineStyler(options: options) }
16 private var resolver: OrgURLResolver { OrgURLResolver(options: options) }
17
18 func layout(_ document: OrgDocument) -> OrgLayout {
19 var notes = OrgFootnotes(document: document)
20 var layout = OrgLayout()
21 layout.blocks = document.elements.flatMap { block($0, ¬es) }
22 // Numbering is complete only after the whole body is walked, so the notes section is
23 // built last — exactly as the HTML renderer does it.
24 layout.footnotes = notes.notes.map {
25 OrgFootnote(number: $0.number, text: text($0.content, ¬es, style: .footnote))
26 }
27 return layout
28 }
29
30 private func text(
31 _ objects: [OrgObject], _ notes: inout OrgFootnotes,
32 style: Font.TextStyle = .body, bold: Bool = false
33 ) -> AttributedString {
34 inline.attributed(objects, ¬es, style: style, bold: bold)
35 }
36
37 /// One element to zero or more blocks. Zero for a footnote definition (it moves to the
38 /// notes section) and for an export block aimed at another backend.
39 private func block(_ element: OrgElement, _ notes: inout OrgFootnotes) -> [OrgBlock] {
40 switch element {
41 case .heading(let heading):
42 var title = AttributedString()
43 if let todo = heading.todo {
44 var keyword = AttributedString(todo + " ")
45 keyword.foregroundColor = todo == "DONE" ? .green : .orange
46 title += keyword
47 }
48 if let priority = heading.priority {
49 var cookie = AttributedString("[#\(priority)] ")
50 cookie.foregroundColor = .secondary
51 title += cookie
52 }
53 let level = min(6, max(1, heading.level + options.headingLevelOffset))
54 title += text(heading.title, ¬es, style: Self.headingStyle(level), bold: true)
55 for tag in heading.tags {
56 var chip = AttributedString(" \(tag)")
57 chip.foregroundColor = .secondary
58 chip.font = .system(.caption, design: .monospaced)
59 title += chip
60 }
61 return [.heading(text: title, level: level)]
62
63 case .paragraph(let objects):
64 return [.paragraph(text(objects, ¬es))]
65
66 case .list(let list):
67 return [.list(listLayout(list, ¬es))]
68
69 case .table(let table):
70 return [.table(tableLayout(table, ¬es))]
71
72 case .srcBlock(let language, let code):
73 return [.code(styled(code: code, language: language))]
74
75 case .exampleBlock(let raw):
76 return [.code(styled(code: raw, language: nil))]
77
78 case .quoteBlock(let children):
79 return [.quote(children.flatMap { block($0, ¬es) })]
80
81 case .centerBlock(let children):
82 return [.center(children.flatMap { block($0, ¬es) })]
83
84 case .verseBlock(let lines):
85 return [.verse(lines.map { text($0, ¬es) })]
86
87 case .specialBlock(let name, let children):
88 return [.special(name: name, blocks: children.flatMap { block($0, ¬es) })]
89
90 case .exportBlock(let backend, let raw):
91 // HTML passes through verbatim in the HTML renderer; natively there is nothing to
92 // pass it to, so it shows as the literal block it is rather than vanishing.
93 return backend == "html" ? [.code(styled(code: raw, language: nil))] : []
94
95 case .figure(let figure):
96 return [imageBlock(figure, ¬es)]
97
98 case .captioned(_, let caption, let content):
99 return [.captioned(
100 caption: caption.map { text($0, ¬es, style: .footnote) },
101 blocks: block(content, ¬es)
102 )]
103
104 case .horizontalRule:
105 return [.rule]
106
107 case .footnoteDefinition:
108 return [] // collected into the notes section
109 }
110 }
111
112 /// An image whose source will not resolve to a safe absolute URL degrades to its alt
113 /// text, the same trade the HTML renderer makes rather than pointing at something unsafe.
114 ///
115 /// The absolute requirement is the one place this diverges from HTML, which may emit a
116 /// relative `src` for a document base URL to resolve. A view has no base, so a relative
117 /// source is unloadable and showing the text beats showing a broken image.
118 private func imageBlock(_ figure: OrgFigure, _ notes: inout OrgFootnotes) -> OrgBlock {
119 let caption = figure.caption.map { text($0, ¬es, style: .footnote) }
120 guard let string = resolver.imageSource(figure.source),
121 let url = URL(string: string), url.scheme != nil else {
122 return .paragraph(AttributedString(figure.alt ?? figure.source))
123 }
124 let alt = figure.alt ?? figure.caption.map { OrgParser.plain($0) } ?? ""
125 return .image(source: url, alt: alt, caption: caption)
126 }
127
128 private func styled(code: String, language: String?) -> AttributedString {
129 if let highlighted = styler.highlighted(code: code, language: language) {
130 return highlighted
131 }
132 var plain = AttributedString(code)
133 plain.font = .system(.footnote, design: .monospaced)
134 return plain
135 }
136
137 private func listLayout(_ list: OrgList, _ notes: inout OrgFootnotes) -> OrgListLayout {
138 var items: [OrgListLayout.Item] = []
139 for item in list.items {
140 items.append(OrgListLayout.Item(
141 checkbox: item.checkbox,
142 term: item.term.map { term in
143 var styled = text(term, ¬es)
144 styled.font = .body.bold()
145 return styled
146 },
147 paragraphs: item.content.map { text($0, ¬es) },
148 sublist: item.sublist.map { listLayout($0, ¬es) }
149 ))
150 }
151 return OrgListLayout(kind: list.kind, items: items)
152 }
153
154 private func tableLayout(_ table: OrgTable, _ notes: inout OrgFootnotes) -> OrgTableLayout {
155 var rows: [OrgTableLayout.Row] = []
156 for row in table.rows {
157 guard case .cells(let cells) = row else { continue }
158 rows.append(OrgTableLayout.Row(cells: cells.map {
159 OrgTableLayout.Cell(text: text($0, ¬es, style: .subheadline))
160 }))
161 }
162 return OrgTableLayout(
163 rows: rows,
164 headerRowCount: table.headerRowCount,
165 alignments: table.alignments
166 )
167 }
168
169 private static func headingStyle(_ level: Int) -> Font.TextStyle {
170 switch level {
171 case 1: .title
172 case 2: .title2
173 case 3: .title3
174 case 4: .headline
175 default: .subheadline
176 }
177 }
178}