import OrgSwift import SwiftUI /// Walks an ``OrgDocument`` into an ``OrgLayout``. /// /// The third renderer over the shared tree, alongside HTML and `AttributedString`. It /// resolves URLs through the same ``OrgURLResolver`` and numbers footnotes through the same /// ``OrgFootnotes``, so a link that works in the HTML output works here and note 1 is note 1 /// in both. @MainActor struct OrgLayoutBuilder { var options: OrgRenderOptions var styler: OrgCodeStyler var keywords: OrgKeywordStyle var textTransform: ((inout AttributedString) -> Void)? = nil private var inline: OrgInlineStyler { OrgInlineStyler(options: options) } private var resolver: OrgURLResolver { OrgURLResolver(options: options) } func layout(_ document: OrgDocument) -> OrgLayout { var notes = OrgFootnotes(document: document) var layout = OrgLayout() layout.blocks = document.elements.flatMap { block($0, ¬es) } // Numbering is complete only after the whole body is walked, so the notes section is // built last — exactly as the HTML renderer does it. layout.footnotes = notes.notes.map { OrgFootnote(number: $0.number, text: text($0.content, ¬es, style: .footnote)) } return layout } private func text( _ objects: [OrgObject], _ notes: inout OrgFootnotes, style: Font.TextStyle = .body, bold: Bool = false ) -> AttributedString { var string = inline.attributed(objects, ¬es, style: style, bold: bold) textTransform?(&string) return string } /// One element to zero or more blocks. Zero for a footnote definition (it moves to the /// notes section) and for an export block aimed at another backend. private func block(_ element: OrgElement, _ notes: inout OrgFootnotes) -> [OrgBlock] { switch element { case .heading(let heading): var title = AttributedString() if let todo = heading.todo { var keyword = AttributedString(todo + " ") keyword.foregroundColor = keywords.color(for: todo) title += keyword } if let priority = heading.priority { var cookie = AttributedString("[#\(priority)] ") cookie.foregroundColor = .secondary title += cookie } let level = min(6, max(1, heading.level + options.headingLevelOffset)) title += text(heading.title, ¬es, style: Self.headingStyle(level), bold: true) for tag in heading.tags { var chip = AttributedString(" \(tag)") chip.foregroundColor = .secondary chip.font = .system(.caption, design: .monospaced) title += chip } return [.heading(text: title, level: level)] case .paragraph(let objects): if let badges = badgeRow(objects) { return [.badges(badges)] } return [.paragraph(text(objects, ¬es))] case .list(let list): return [.list(listLayout(list, ¬es))] case .table(let table): return [.table(tableLayout(table, ¬es))] case .srcBlock(let language, let code): return [.code(styled(code: code, language: language))] case .exampleBlock(let raw): return [.code(styled(code: raw, language: nil))] case .quoteBlock(let children): return [.quote(children.flatMap { block($0, ¬es) })] case .centerBlock(let children): return [.center(children.flatMap { block($0, ¬es) })] case .verseBlock(let lines): return [.verse(lines.map { text($0, ¬es) })] case .specialBlock(let name, let children): return [.special(name: name, blocks: children.flatMap { block($0, ¬es) })] case .exportBlock(let backend, let raw): // HTML passes through verbatim in the HTML renderer; natively there is nothing to // pass it to, so it shows as the literal block it is rather than vanishing. return backend == "html" ? [.code(styled(code: raw, language: nil))] : [] case .figure(let figure): return [imageBlock(figure, ¬es)] case .captioned(_, let caption, let content): return [.captioned( caption: caption.map { text($0, ¬es, style: .footnote) }, blocks: block(content, ¬es) )] case .horizontalRule: return [.rule] case .footnoteDefinition: return [] // collected into the notes section } } /// A paragraph holding nothing but images and image links is the badge row a README /// opens with. It renders as a row of images at their own size rather than as prose — /// the inline styler can only show an image's alt text, and a figure block would blow a /// 95×20 badge up to the column width. /// /// Anything else in the paragraph, a word of prose included, means it is prose. private func badgeRow(_ objects: [OrgObject]) -> [OrgBadge]? { var badges: [OrgBadge] = [] for object in objects { switch object { case .text(let string) where string.trimmingCharacters(in: .whitespaces).isEmpty: continue case .image(let figure): guard let badge = badge(figure, link: nil) else { return nil } badges.append(badge) case .link(let link): guard link.description?.count == 1, case .image(let figure) = link.description?.first else { return nil } // A badge whose link will not resolve is still a badge; it just is not // tappable. let href = resolver.href(for: link.target).flatMap { URL(string: $0) } guard let badge = badge(figure, link: href) else { return nil } badges.append(badge) default: return nil } } return badges.isEmpty ? nil : badges } private func badge(_ figure: OrgFigure, link: URL?) -> OrgBadge? { guard let string = resolver.imageSource(figure.source), let url = URL(string: string), url.scheme != nil else { return nil } return OrgBadge(source: url, alt: figure.alt ?? "", link: link) } /// An image whose source will not resolve to a safe absolute URL degrades to its alt /// text, the same trade the HTML renderer makes rather than pointing at something unsafe. /// /// The absolute requirement is the one place this diverges from HTML, which may emit a /// relative `src` for a document base URL to resolve. A view has no base, so a relative /// source is unloadable and showing the text beats showing a broken image. private func imageBlock(_ figure: OrgFigure, _ notes: inout OrgFootnotes) -> OrgBlock { let caption = figure.caption.map { text($0, ¬es, style: .footnote) } guard let string = resolver.imageSource(figure.source), let url = URL(string: string), url.scheme != nil else { return .paragraph(AttributedString(figure.alt ?? figure.source)) } let alt = figure.alt ?? figure.caption.map { OrgParser.plain($0) } ?? "" return .image(source: url, alt: alt, caption: caption) } private func styled(code: String, language: String?) -> AttributedString { if let highlighted = styler.highlighted(code: code, language: language) { return highlighted } var plain = AttributedString(code) plain.font = .system(.footnote, design: .monospaced) return plain } private func listLayout(_ list: OrgList, _ notes: inout OrgFootnotes) -> OrgListLayout { var items: [OrgListLayout.Item] = [] for item in list.items { items.append(OrgListLayout.Item( checkbox: item.checkbox, term: item.term.map { term in var styled = text(term, ¬es) styled.font = .body.bold() return styled }, blocks: item.content.flatMap { block($0, ¬es) } )) } return OrgListLayout(kind: list.kind, items: items) } private func tableLayout(_ table: OrgTable, _ notes: inout OrgFootnotes) -> OrgTableLayout { var rows: [OrgTableLayout.Row] = [] for row in table.rows { guard case .cells(let cells) = row else { continue } rows.append(OrgTableLayout.Row(cells: cells.map { OrgTableLayout.Cell(text: text($0, ¬es, style: .subheadline)) })) } return OrgTableLayout( rows: rows, headerRowCount: table.headerRowCount, alignments: table.alignments ) } private static func headingStyle(_ level: Int) -> Font.TextStyle { switch level { case 1: .title case 2: .title2 case 3: .title3 case 4: .headline default: .subheadline } } }