Sources/OrgSwiftUI/OrgLayoutBuilder.swift
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, ¬es) }
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, ¬es, 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, ¬es, 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, ¬es, 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, ¬es))]
71
72 case .list(let list):
73 return [.list(listLayout(list, ¬es))]
74
75 case .table(let table):
76 return [.table(tableLayout(table, ¬es))]
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, ¬es) })]
86
87 case .centerBlock(let children):
88 return [.center(children.flatMap { block($0, ¬es) })]
89
90 case .verseBlock(let lines):
91 return [.verse(lines.map { text($0, ¬es) })]
92
93 case .specialBlock(let name, let children):
94 return [.special(name: name, blocks: children.flatMap { block($0, ¬es) })]
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, ¬es)]
103
104 case .captioned(_, let caption, let content):
105 return [.captioned(
106 caption: caption.map { text($0, ¬es, style: .footnote) },
107 blocks: block(content, ¬es)
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, ¬es, 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, ¬es)
201 styled.font = .body.bold()
202 return styled
203 },
204 blocks: item.content.flatMap { block($0, ¬es) }
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, ¬es, 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}