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 | 16 | /// - Parameter style: the text style of the block this run sits in, so emphasis inside a |
| 17 | 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 | 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 | 25 | ) -> AttributedString { |
| 21 | 26 | var string = renderer.inline(objects, ¬es) |
| 22 | 27 | |
| @@ -26,7 +31,7 @@ struct OrgInlineStyler { |
| 26 | 31 | |
| 27 | 32 | var textStyle = style |
| 28 | 33 | var monospaced = false |
| 29 | | var bold = false |
| 34 | var bold = forceBold |
| 30 | 35 | var italic = false |
| 31 | 36 | |
| 32 | 37 | if intents.contains(.stronglyEmphasized) { bold = true } |
| @@ -57,12 +62,13 @@ struct OrgInlineStyler { |
| 57 | 62 | break |
| 58 | 63 | } |
| 59 | 64 | |
| 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 |
| 66 | 72 | } |
| 67 | 73 | return string |
| 68 | 74 | } |
Sources/OrgSwiftUI/OrgLayoutBuilder.swift
+4 −3
| @@ -28,9 +28,10 @@ struct OrgLayoutBuilder { |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | 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 | 33 | ) -> AttributedString { |
| 33 | | inline.attributed(objects, ¬es, style: style) |
| 34 | inline.attributed(objects, ¬es, style: style, bold: bold) |
| 34 | 35 | } |
| 35 | 36 | |
| 36 | 37 | /// One element to zero or more blocks. Zero for a footnote definition (it moves to the |
| @@ -50,7 +51,7 @@ struct OrgLayoutBuilder { |
| 50 | 51 | title += cookie |
| 51 | 52 | } |
| 52 | 53 | let level = min(6, max(1, heading.level + options.headingLevelOffset)) |
| 53 | | title += text(heading.title, ¬es, style: Self.headingStyle(level)) |
| 54 | title += text(heading.title, ¬es, style: Self.headingStyle(level), bold: true) |
| 54 | 55 | for tag in heading.tags { |
| 55 | 56 | var chip = AttributedString(" \(tag)") |
| 56 | 57 | chip.foregroundColor = .secondary |
Sources/OrgSwiftUI/OrgView.swift
+13 −4
| @@ -9,14 +9,16 @@ import SwiftUI |
| 9 | 9 | /// ``` |
| 10 | 10 | /// |
| 11 | 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 |
| 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. |
| 15 | 16 | public struct OrgView: View { |
| 16 | 17 | private let source: String |
| 17 | 18 | private let options: OrgRenderOptions |
| 18 | 19 | private let styler: OrgCodeStyler |
| 19 | 20 | |
| 21 | @Environment(\.colorScheme) private var colorScheme |
| 20 | 22 | @State private var layout = OrgLayout() |
| 21 | 23 | |
| 22 | 24 | public init( |
| @@ -38,13 +40,20 @@ public struct OrgView: View { |
| 38 | 40 | } |
| 39 | 41 | .frame(maxWidth: .infinity, alignment: .leading) |
| 40 | 42 | .textSelection(.enabled) |
| 41 | | .task(id: source) { |
| 43 | .task(id: LayoutKey(source: source, colorScheme: colorScheme)) { |
| 42 | 44 | layout = OrgLayoutBuilder(options: options, styler: styler) |
| 43 | 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. |
| 52 | private struct LayoutKey: Equatable { |
| 53 | var source: String |
| 54 | var colorScheme: ColorScheme |
| 55 | } |
| 56 | |
| 48 | 57 | /// The notes section org's exporter puts at the end of a document. |
| 49 | 58 | struct OrgFootnotesView: View { |
| 50 | 59 | let footnotes: [OrgFootnote] |
Tests/OrgSwiftUITests/OrgLayoutTests.swift
+22
| @@ -179,6 +179,28 @@ struct OrgLayoutTests { |
| 179 | 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 | 204 | /// Emphasis nests, and the styler turns intents into fonts rather than dropping them. |
| 183 | 205 | @Test |
| 184 | 206 | func inlineStylingCarriesNestedEmphasis() { |