Commit cc7163b03d
Unsigned
Layout: unified · split
Sources/OrgSwift/AST/OrgAttributedStringRenderer.swift +32 −18
| @@ -34,38 +34,26 @@ public struct OrgAttributedStringRenderer: Sendable { | ||
| 34 | 34 | result += AttributedString(text) |
| 35 | 35 | |
| 36 | 36 | case .bold(let children): |
| 37 | var part = inline(children, ¬es) | |
| 38 | part.inlinePresentationIntent = .stronglyEmphasized | |
| 39 | result += part | |
| 37 | result += applying(.stronglyEmphasized, to: inline(children, ¬es)) | |
| 40 | 38 | |
| 41 | 39 | case .italic(let children): |
| 42 | var part = inline(children, ¬es) | |
| 43 | part.inlinePresentationIntent = .emphasized | |
| 44 | result += part | |
| 40 | result += applying(.emphasized, to: inline(children, ¬es)) | |
| 45 | 41 | |
| 46 | 42 | // Underline and strikethrough have no Foundation-portable attribute (the |
| 47 | 43 | // underlineStyle/strikethroughStyle keys live in the UIKit/AppKit scopes), so they |
| 48 | 44 | // travel as roles the UI layer applies. |
| 49 | 45 | case .underline(let children): |
| 50 | var part = inline(children, ¬es) | |
| 51 | part.orgRole = .underline | |
| 52 | result += part | |
| 46 | result += applying(.underline, to: inline(children, ¬es)) | |
| 53 | 47 | |
| 54 | 48 | case .strikeThrough(let children): |
| 55 | var part = inline(children, ¬es) | |
| 56 | part.orgRole = .strikeThrough | |
| 57 | result += part | |
| 49 | result += applying(.strikeThrough, to: inline(children, ¬es)) | |
| 58 | 50 | |
| 59 | 51 | 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)) | |
| 63 | 53 | |
| 64 | 54 | case .superscript(let children): |
| 65 | 55 | // No portable superscript attribute; mark it so a UI layer can raise it. |
| 66 | var part = inline(children, ¬es) | |
| 67 | part.orgRole = .superscript | |
| 68 | result += part | |
| 56 | result += applying(.superscript, to: inline(children, ¬es)) | |
| 69 | 57 | |
| 70 | 58 | case .lineBreak: |
| 71 | 59 | result += AttributedString("\n") |
| @@ -97,6 +85,32 @@ public struct OrgAttributedStringRenderer: Sendable { | ||
| 97 | 85 | return result |
| 98 | 86 | } |
| 99 | 87 | |
| 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 | ||
| 100 | 114 | /// Flatten a whole document to attributed paragraphs — a convenience for callers that |
| 101 | 115 | /// want text without building block views (a share sheet, a plain-text export). |
| 102 | 116 | public func paragraphs(_ document: OrgDocument) -> [AttributedString] { |
Tests/OrgSwiftTests/OrgTreeTests.swift +19
| @@ -165,6 +165,25 @@ struct ASTRendererTests { | ||
| 165 | 165 | #expect(notes.notes.map(\.label) == ["b", "a"]) |
| 166 | 166 | } |
| 167 | 167 | |
| 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 | ||
| 168 | 187 | @Test |
| 169 | 188 | func attributedStringCarriesRolesForNonStandardIntents() { |
| 170 | 189 | let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>") |