krz/org-swift

A dependency-free Swift library that renders org-mode to sanitized HTML. html library org-mode swift

Commit cc7163b03d

cc7163b03da17c930c5a02bb8fcefc2d1d61a328

parent: 9cd7a260bd

Unsigned

cmc <hello@cleberg.net> · 2026-08-31 17:32 UTC

Keep nested inline emphasis in the native renderer

Assigning inlinePresentationIntent over a whole range overwrote whatever
the children carried, so `*bold /italic/*` rendered bold only while HTML
nested both. Intents now merge per run. Roles use a single slot, so the
innermost wins rather than the outermost stomping it.

Layout: unified · split

Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift +32 −18
@@ -34,38 +34,26 @@ public struct OrgAttributedStringRenderer: Sendable {
3434 result += AttributedString(text)
3535
3636 case .bold(let children):
37 var part = inline(children, &notes)
38 part.inlinePresentationIntent = .stronglyEmphasized
39 result += part
37 result += applying(.stronglyEmphasized, to: inline(children, &notes))
4038
4139 case .italic(let children):
42 var part = inline(children, &notes)
43 part.inlinePresentationIntent = .emphasized
44 result += part
40 result += applying(.emphasized, to: inline(children, &notes))
4541
4642 // Underline and strikethrough have no Foundation-portable attribute (the
4743 // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they
4844 // travel as roles the UI layer applies.
4945 case .underline(let children):
50 var part = inline(children, &notes)
51 part.orgRole = .underline
52 result += part
46 result += applying(.underline, to: inline(children, &notes))
5347
5448 case .strikeThrough(let children):
55 var part = inline(children, &notes)
56 part.orgRole = .strikeThrough
57 result += part
49 result += applying(.strikeThrough, to: inline(children, &notes))
5850
5951 case .verbatim(let text), .code(let text):
60 var part = AttributedString(text)
61 part.inlinePresentationIntent = .code
62 result += part
52 result += applying(.code, to: AttributedString(text))
6353
6454 case .superscript(let children):
6555 // No portable superscript attribute; mark it so a UI layer can raise it.
66 var part = inline(children, &notes)
67 part.orgRole = .superscript
68 result += part
56 result += applying(.superscript, to: inline(children, &notes))
6957
7058 case .lineBreak:
7159 result += AttributedString("\n")
@@ -97,6 +85,32 @@ public struct OrgAttributedStringRenderer: Sendable {
9785 return result
9886 }
9987
88 // MARK: - Nesting
89
90 /// Add an intent to every run, keeping intents the children already carry. Assigning
91 /// over the whole range instead would flatten `*bold /italic/*` to bold alone.
92 private func applying(
93 _ intent: InlinePresentationIntent, to string: AttributedString
94 ) -> AttributedString {
95 var result = string
96 for range in result.runs.map(\.range) {
97 result[range].inlinePresentationIntent =
98 (result[range].inlinePresentationIntent ?? []).union(intent)
99 }
100 return result
101 }
102
103 /// Mark runs that carry no role yet. A role is a single slot, so the innermost — the more
104 /// specific — wins; org nests two role-bearing markups (`_+both+_`) rarely enough that a
105 /// set is not worth the public API.
106 private func applying(_ role: OrgRole, to string: AttributedString) -> AttributedString {
107 var result = string
108 for range in result.runs.map(\.range) where result[range][OrgRoleAttribute.self] == nil {
109 result[range][OrgRoleAttribute.self] = role
110 }
111 return result
112 }
113
100114 /// Flatten a whole document to attributed paragraphs — a convenience for callers that
101115 /// want text without building block views (a share sheet, a plain-text export).
102116 public func paragraphs(_ document: OrgDocument) -> [AttributedString] {
Tests/OrgSwiftTests/OrgTreeTests.swift +19
@@ -165,6 +165,25 @@ struct ASTRendererTests {
165165 #expect(notes.notes.map(\.label) == ["b", "a"])
166166 }
167167
168 /// Emphasis nests in org, and the HTML renderer nests it (`<strong>b <em>i</em></strong>`).
169 /// Assigning the intent over the whole range instead of merging would flatten the inner
170 /// one away.
171 @Test
172 func nestedEmphasisKeepsBothIntents() {
173 let attributed = OrgAttributedStringRenderer().inline(
174 OrgParser.parseInline("*bold /italic/ tail*")
175 )
176 func intent(_ text: String) -> InlinePresentationIntent? {
177 attributed.runs.first { String(attributed[$0.range].characters).contains(text) }?
178 .inlinePresentationIntent
179 }
180 #expect(intent("bold")?.contains(.stronglyEmphasized) == true)
181 #expect(intent("italic")?.contains(.stronglyEmphasized) == true)
182 #expect(intent("italic")?.contains(.emphasized) == true)
183 // The outer run is bold only.
184 #expect(intent("bold")?.contains(.emphasized) == false)
185 }
186
168187 @Test
169188 func attributedStringCarriesRolesForNonStandardIntents() {
170189 let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>")