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 {
15 15
16 /// - Parameter style: the text style of the block this run sits in, so emphasis inside a 16 /// - Parameter style: the text style of the block this run sits in, so emphasis inside a
17 /// heading bolds the heading's font rather than the body font. 17 /// 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.
18 func attributed( 22 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
20 ) -> AttributedString { 25 ) -> AttributedString {
21 var string = renderer.inline(objects, &notes) 26 var string = renderer.inline(objects, &notes)
22 27
@@ -26,7 +31,7 @@ struct OrgInlineStyler {
26 31
27 var textStyle = style 32 var textStyle = style
28 var monospaced = false 33 var monospaced = false
29 var bold = false 34 var bold = forceBold
30 var italic = false 35 var italic = false
31 36
32 if intents.contains(.stronglyEmphasized) { bold = true } 37 if intents.contains(.stronglyEmphasized) { bold = true }
@@ -57,12 +62,13 @@ struct OrgInlineStyler {
57 break 62 break
58 } 63 }
59 64
60 if bold || italic || monospaced || textStyle != style { 65 // Always set the font, never only on modified runs: a run with no emphasis
61 var font = Font.system(textStyle, design: monospaced ? .monospaced : .default) 66 // would otherwise inherit the surrounding view's font and a heading, table cell
62 if bold { font = font.bold() } 67 // or footnote would render at body size.
63 if italic { font = font.italic() } 68 var font = Font.system(textStyle, design: monospaced ? .monospaced : .default)
64 string[range].font = font 69 if bold { font = font.bold() }
65 } 70 if italic { font = font.italic() }
71 string[range].font = font
66 } 72 }
67 return string 73 return string
68 } 74 }
Sources/OrgSwiftUI/OrgLayoutBuilder.swift +4 −3
@@ -28,9 +28,10 @@ struct OrgLayoutBuilder {
28 } 28 }
29 29
30 private func text( 30 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
32 ) -> AttributedString { 33 ) -> AttributedString {
33 inline.attributed(objects, &notes, style: style) 34 inline.attributed(objects, &notes, style: style, bold: bold)
34 } 35 }
35 36
36 /// One element to zero or more blocks. Zero for a footnote definition (it moves to the 37 /// One element to zero or more blocks. Zero for a footnote definition (it moves to the
@@ -50,7 +51,7 @@ struct OrgLayoutBuilder {
50 title += cookie 51 title += cookie
51 } 52 }
52 let level = min(6, max(1, heading.level + options.headingLevelOffset)) 53 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)
54 for tag in heading.tags { 55 for tag in heading.tags {
55 var chip = AttributedString(" \(tag)") 56 var chip = AttributedString(" \(tag)")
56 chip.foregroundColor = .secondary 57 chip.foregroundColor = .secondary
Sources/OrgSwiftUI/OrgView.swift +13 −4
@@ -9,14 +9,16 @@ import SwiftUI
9/// ``` 9/// ```
10/// 10///
11/// The parse, footnote numbering, URL resolution and syntax highlighting run once off 11/// 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 12/// `body`, in a task keyed to the source *and the color scheme*, so scrolling and Dynamic
13/// re-derive them. Text is selectable, links open through the environment's `openURL`, and 13/// Type changes do not re-derive them — but switching to dark mode does, because a
14/// everything scales with Dynamic Type. 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.
15public struct OrgView: View { 16public struct OrgView: View {
16 private let source: String 17 private let source: String
17 private let options: OrgRenderOptions 18 private let options: OrgRenderOptions
18 private let styler: OrgCodeStyler 19 private let styler: OrgCodeStyler
19 20
21 @Environment(\.colorScheme) private var colorScheme
20 @State private var layout = OrgLayout() 22 @State private var layout = OrgLayout()
21 23
22 public init( 24 public init(
@@ -38,13 +40,20 @@ public struct OrgView: View {
38 } 40 }
39 .frame(maxWidth: .infinity, alignment: .leading) 41 .frame(maxWidth: .infinity, alignment: .leading)
40 .textSelection(.enabled) 42 .textSelection(.enabled)
41 .task(id: source) { 43 .task(id: LayoutKey(source: source, colorScheme: colorScheme)) {
42 layout = OrgLayoutBuilder(options: options, styler: styler) 44 layout = OrgLayoutBuilder(options: options, styler: styler)
43 .layout(OrgParser.parse(source)) 45 .layout(OrgParser.parse(source))
44 } 46 }
45 } 47 }
46} 48}
47 49
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
48/// The notes section org's exporter puts at the end of a document. 57/// The notes section org's exporter puts at the end of a document.
49struct OrgFootnotesView: View { 58struct OrgFootnotesView: View {
50 let footnotes: [OrgFootnote] 59 let footnotes: [OrgFootnote]
Tests/OrgSwiftUITests/OrgLayoutTests.swift +22
@@ -179,6 +179,28 @@ struct OrgLayoutTests {
179 #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty) 179 #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty)
180 } 180 }
181 181
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
182 /// Emphasis nests, and the styler turns intents into fonts rather than dropping them. 204 /// Emphasis nests, and the styler turns intents into fonts rather than dropping them.
183 @Test 205 @Test
184 func inlineStylingCarriesNestedEmphasis() { 206 func inlineStylingCarriesNestedEmphasis() {