Fix two rendering bugs found integrating OrgSwiftUI !3

merged merged by cmc on 2026-08-31 18:22 UTC · krz/org-swift:native-render-fixes into main

4 files changed, +53 −15

Layout: unified · split

Sources/OrgSwiftUI/OrgInlineStyler.swift +14 −8
@@ -15,8 +15,13 @@ struct OrgInlineStyler {
1515
1616 /// - Parameter style: the text style of the block this run sits in, so emphasis inside a
1717 /// 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.
1822 func attributed(
19 _ objects: [OrgObject], _ notes: inout OrgFootnotes, style: Font.TextStyle = .body
23 _ objects: [OrgObject], _ notes: inout OrgFootnotes,
24 style: Font.TextStyle = .body, bold forceBold: Bool = false
2025 ) -> AttributedString {
2126 var string = renderer.inline(objects, &notes)
2227
@@ -26,7 +31,7 @@ struct OrgInlineStyler {
2631
2732 var textStyle = style
2833 var monospaced = false
29 var bold = false
34 var bold = forceBold
3035 var italic = false
3136
3237 if intents.contains(.stronglyEmphasized) { bold = true }
@@ -57,12 +62,13 @@ struct OrgInlineStyler {
5762 break
5863 }
5964
60 if bold || italic || monospaced || textStyle != style {
61 var font = Font.system(textStyle, design: monospaced ? .monospaced : .default)
62 if bold { font = font.bold() }
63 if italic { font = font.italic() }
64 string[range].font = font
65 }
65 // Always set the font, never only on modified runs: a run with no emphasis
66 // would otherwise inherit the surrounding view's font and a heading, table cell
67 // or footnote would render at body size.
68 var font = Font.system(textStyle, design: monospaced ? .monospaced : .default)
69 if bold { font = font.bold() }
70 if italic { font = font.italic() }
71 string[range].font = font
6672 }
6773 return string
6874 }
Sources/OrgSwiftUI/OrgLayoutBuilder.swift +4 −3
@@ -28,9 +28,10 @@ struct OrgLayoutBuilder {
2828 }
2929
3030 private func text(
31 _ objects: [OrgObject], _ notes: inout OrgFootnotes, style: Font.TextStyle = .body
31 _ objects: [OrgObject], _ notes: inout OrgFootnotes,
32 style: Font.TextStyle = .body, bold: Bool = false
3233 ) -> AttributedString {
33 inline.attributed(objects, &notes, style: style)
34 inline.attributed(objects, &notes, style: style, bold: bold)
3435 }
3536
3637 /// One element to zero or more blocks. Zero for a footnote definition (it moves to the
@@ -50,7 +51,7 @@ struct OrgLayoutBuilder {
5051 title += cookie
5152 }
5253 let level = min(6, max(1, heading.level + options.headingLevelOffset))
53 title += text(heading.title, &notes, style: Self.headingStyle(level))
54 title += text(heading.title, &notes, style: Self.headingStyle(level), bold: true)
5455 for tag in heading.tags {
5556 var chip = AttributedString(" \(tag)")
5657 chip.foregroundColor = .secondary
Sources/OrgSwiftUI/OrgView.swift +13 −4
@@ -9,14 +9,16 @@ import SwiftUI
99/// ```
1010///
1111/// The parse, footnote numbering, URL resolution and syntax highlighting run once off
12/// `body`, in a task keyed to the source, so scrolling and Dynamic Type changes do not
13/// re-derive them. Text is selectable, links open through the environment's `openURL`, and
14/// everything scales with Dynamic Type.
12/// `body`, in a task keyed to the source *and the color scheme*, so scrolling and Dynamic
13/// Type changes do not re-derive them — but switching to dark mode does, because a
14/// ``OrgCodeStyler`` bakes its theme's colors into the text it returns. Text is selectable,
15/// links open through the environment's `openURL`, and everything scales with Dynamic Type.
1516public struct OrgView: View {
1617 private let source: String
1718 private let options: OrgRenderOptions
1819 private let styler: OrgCodeStyler
1920
21 @Environment(\.colorScheme) private var colorScheme
2022 @State private var layout = OrgLayout()
2123
2224 public init(
@@ -38,13 +40,20 @@ public struct OrgView: View {
3840 }
3941 .frame(maxWidth: .infinity, alignment: .leading)
4042 .textSelection(.enabled)
41 .task(id: source) {
43 .task(id: LayoutKey(source: source, colorScheme: colorScheme)) {
4244 layout = OrgLayoutBuilder(options: options, styler: styler)
4345 .layout(OrgParser.parse(source))
4446 }
4547 }
4648}
4749
50/// What the prepared layout depends on. Highlighted code carries baked-in colors, so the
51/// scheme belongs in the key alongside the source.
52private struct LayoutKey: Equatable {
53 var source: String
54 var colorScheme: ColorScheme
55}
56
4857/// The notes section org's exporter puts at the end of a document.
4958struct OrgFootnotesView: View {
5059 let footnotes: [OrgFootnote]
Tests/OrgSwiftUITests/OrgLayoutTests.swift +22
@@ -179,6 +179,28 @@ struct OrgLayoutTests {
179179 #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty)
180180 }
181181
182 /// Every run carries a font, not only the emphasised ones. A plain heading run with no
183 /// font would inherit the surrounding view's and render at body size.
184 @Test
185 func everyRunCarriesItsBlockFont() {
186 guard case .heading(let title, _) = layout("* Plain heading").blocks.first else {
187 Issue.record("expected a heading"); return
188 }
189 #expect(title.runs.allSatisfy { $0.font != nil })
190 #expect(title.runs.contains { $0.font == Font.system(.title, design: .default).bold() })
191
192 // Same for the blocks that render at a non-body style.
193 guard case .table(let table) = layout("| a |\n|---|\n| b |").blocks.first else {
194 Issue.record("expected a table"); return
195 }
196 #expect(table.rows.allSatisfy { $0.cells.allSatisfy { cell in
197 cell.text.runs.allSatisfy { $0.font != nil }
198 } })
199
200 let notes = layout("Claim[fn:a]\n\n[fn:a] note").footnotes
201 #expect(notes.first?.text.runs.allSatisfy { $0.font != nil } == true)
202 }
203
182204 /// Emphasis nests, and the styler turns intents into fonts rather than dropping them.
183205 @Test
184206 func inlineStylingCarriesNestedEmphasis() {