Sources/OrgSwiftUI/OrgInlineStyler.swift
94 lines · 3924 bytes
3 symbols in this file
1import OrgSwift
2import SwiftUI
3
4/// Turns a run of inline org objects into a styled `AttributedString`.
5///
6/// The parse and the attribute intents come from ``OrgAttributedStringRenderer``; this adds
7/// only presentation. Everything is expressed in semantic `Font.TextStyle`s and semantic
8/// colors, so the result scales with Dynamic Type and follows light/dark on its own.
9struct OrgInlineStyler {
10 var renderer: OrgAttributedStringRenderer
11
12 init(options: OrgRenderOptions) {
13 renderer = OrgAttributedStringRenderer(options: options)
14 }
15
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.
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.
22 func attributed(
23 _ objects: [OrgObject], _ notes: inout OrgFootnotes,
24 style: Font.TextStyle = .body, bold forceBold: Bool = false
25 ) -> AttributedString {
26 var string = renderer.inline(objects, ¬es)
27
28 for range in string.runs.map(\.range) {
29 let intents = string[range].inlinePresentationIntent ?? []
30 let role = string[range][OrgRoleAttribute.self]
31
32 var textStyle = style
33 var monospaced = false
34 var bold = forceBold
35 var italic = false
36
37 if intents.contains(.stronglyEmphasized) { bold = true }
38 if intents.contains(.emphasized) { italic = true }
39 if intents.contains(.code) {
40 monospaced = true
41 string[range].backgroundColor = .secondary.opacity(0.12)
42 }
43
44 switch role {
45 case .underline:
46 string[range].underlineStyle = .single
47 case .strikeThrough:
48 string[range].strikethroughStyle = .single
49 case .superscript, .footnoteReference:
50 // No portable superscript attribute, so raise it and step the style down.
51 textStyle = Self.smaller(textStyle)
52 string[range].baselineOffset = 5
53 if role == .footnoteReference { string[range].foregroundColor = .accentColor }
54 case .timestamp:
55 string[range].foregroundColor = .secondary
56 case .image:
57 // An inline image inside a paragraph shows its alt text; the block-level
58 // `.figure` case is where an image is actually loaded.
59 italic = true
60 string[range].foregroundColor = .secondary
61 case nil:
62 break
63 }
64
65 if string[range][OrgMathAttribute.self] != nil {
66 string[range][OrgMathStyleAttribute.self] = textStyle
67 }
68
69 // Always set the font, never only on modified runs: a run with no emphasis
70 // would otherwise inherit the surrounding view's font and a heading, table cell
71 // or footnote would render at body size.
72 var font = Font.system(textStyle, design: monospaced ? .monospaced : .default)
73 if bold { font = font.bold() }
74 if italic { font = font.italic() }
75 string[range].font = font
76 }
77 return string
78 }
79
80 /// One step down the semantic scale, for superscripts and footnote markers.
81 private static func smaller(_ style: Font.TextStyle) -> Font.TextStyle {
82 switch style {
83 case .largeTitle: .title
84 case .title: .title2
85 case .title2: .title3
86 case .title3: .headline
87 case .headline, .body: .footnote
88 case .callout: .caption
89 case .subheadline: .caption
90 case .footnote, .caption, .caption2: .caption2
91 @unknown default: .caption
92 }
93 }
94}