diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-18 23:53:25 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-19 17:56:23 +0200 |
| commit | 49b0f4a3fb0064313d16782839865b763ef760ce (patch) | |
| tree | a58439f1433de9ff66dc0be28f025afe56e79cfe | |
| parent | cb91a73bfc845d74c3e4d1115bce5dfed10d456d (diff) | |
Support Math rendering1.2.0
| -rw-r--r-- | Package.resolved | 2 | ||||
| -rw-r--r-- | Package.swift | 11 | ||||
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | Sources/NorgUI/Math/MathBlockView.swift | 34 | ||||
| -rw-r--r-- | Sources/NorgUI/Math/NorgInlineText.swift | 52 | ||||
| -rw-r--r-- | Sources/NorgUI/Math/NorgMathRenderer.swift | 46 | ||||
| -rw-r--r-- | Sources/NorgUI/Math/SwiftMathRenderer.swift | 60 | ||||
| -rw-r--r-- | Sources/NorgUI/Theme/NorgTheme.swift | 17 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/Blocks/ListItemView.swift | 2 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/Blocks/ParagraphView.swift | 4 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/Blocks/QuoteView.swift | 2 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/Blocks/RangedTagView.swift | 8 | ||||
| -rw-r--r-- | Tests/NorgUITests/NorgThemeTests.swift | 1 |
13 files changed, 239 insertions, 8 deletions
diff --git a/Package.resolved b/Package.resolved index b1d1107..e6e33b6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0960b440770a659133d512990dee327d70182aa746ec78aa8d8cd2117579657e", + "originHash" : "7bd0391634e33feefca615d1da2d18f1bceb04323639859efdb4fe6638ab3aec", "pins" : [ { "identity" : "norgkit", diff --git a/Package.swift b/Package.swift index dc42e93..f316060 100644 --- a/Package.swift +++ b/Package.swift @@ -18,9 +18,15 @@ let package = Package( targets: ["NorgUI"] ), ], + traits: [ + .trait( + name: "SwiftMathSupport", + description: "Render inline and block math using SwiftMath." + ), + ], dependencies: [ - // NorgKit provides the parser and model value types. .package(url: "https://git.sr.ht/~rbdr/norgkit", from: "1.0.0"), + .package(url: "https://github.com/mgriebling/SwiftMath", from: "1.7.0"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -29,6 +35,9 @@ let package = Package( name: "NorgUI", dependencies: [ .product(name: "NorgKit", package: "norgkit"), + .product( + name: "SwiftMath", package: "SwiftMath", + condition: .when(traits: ["SwiftMathSupport"])), ], resources: [ .process("Resources"), @@ -77,6 +77,14 @@ NorgTaskView( ) ``` +### Rendering Math + +NorgUI will not render math by default, but provides a trait `SwiftMathSupport` +that adds this capability and includes `SwiftMath` as a dependency. + +You can also roll your own math renderer by implementing the NorgMathRenderer +protocol and passing it to the `NorgTheme`'s `mathRenderer` property. + ## Theming Fonts and colors can be changed by creating a `NorgTheme` and using the diff --git a/Sources/NorgUI/Math/MathBlockView.swift b/Sources/NorgUI/Math/MathBlockView.swift new file mode 100644 index 0000000..c29fdb6 --- /dev/null +++ b/Sources/NorgUI/Math/MathBlockView.swift @@ -0,0 +1,34 @@ +import SwiftUI + +/// Renders a `@math` block asynchronously with a fallback to `VerbatimBlock`. +struct MathBlockView: View { + @Environment(\.norgTheme) private var theme + + let latex: String + + @State private var rendered: NorgMathImage? + + var body: some View { + content + .task(id: renderKey) { + guard let renderer = theme.mathRenderer else { return } + rendered = await renderer.render( + latex: latex, mode: .block, fontSize: theme.mathFontSize, color: nil) + } + } + + @ViewBuilder + private var content: some View { + if let rendered { + rendered.swiftUIImage + .frame(width: rendered.size.width, height: rendered.size.height, alignment: .leading) + .padding(theme.verbatimPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.codeBackground, in: RoundedRectangle(cornerRadius: 8)) + } else { + VerbatimBlock(label: nil, content: latex) + } + } + + private var renderKey: String { "\(latex)@\(theme.mathFontSize)" } +} diff --git a/Sources/NorgUI/Math/NorgInlineText.swift b/Sources/NorgUI/Math/NorgInlineText.swift new file mode 100644 index 0000000..0d607f5 --- /dev/null +++ b/Sources/NorgUI/Math/NorgInlineText.swift @@ -0,0 +1,52 @@ +import NorgKit +import SwiftUI + +/// Renders a run of inline spans as a single `Text`, splicing in rendered +/// images for `.math` spans when a ``NorgMathRenderer`` is configured. +struct NorgInlineText: View { + @Environment(\.norgTheme) private var theme + + let spans: [InlineSpan] + var baseFont: Font = .body + + @State private var images: [Int: NorgMathImage] = [:] + + var body: some View { + text + .task(id: renderKey) { await renderMath() } + } + + private var text: Text { + var result = Text(verbatim: "") + for (index, span) in spans.enumerated() { + if span.styles.contains(.math), let image = images[index] { + result = result + Text(image.swiftUIImage).baselineOffset(-image.descent) + } else { + result = result + Text(AttributedString(norg: [span], baseFont: baseFont, theme: theme)) + } + } + return result + } + + private var mathSpans: [(offset: Int, element: InlineSpan)] { + spans.enumerated().filter { $0.element.styles.contains(.math) }.map { ($0.offset, $0.element) } + } + + private var renderKey: String { + mathSpans.map { "\($0.offset):\($0.element.text)" }.joined(separator: "|") + + "@\(theme.mathFontSize)" + } + + @MainActor + private func renderMath() async { + guard let renderer = theme.mathRenderer, !mathSpans.isEmpty else { return } + for (index, span) in mathSpans where images[index] == nil { + if let image = await renderer.render( + latex: span.text, mode: .inline, fontSize: theme.mathFontSize, color: nil) + { + images[index] = image + } + await Task.yield() + } + } +} diff --git a/Sources/NorgUI/Math/NorgMathRenderer.swift b/Sources/NorgUI/Math/NorgMathRenderer.swift new file mode 100644 index 0000000..4fcf60f --- /dev/null +++ b/Sources/NorgUI/Math/NorgMathRenderer.swift @@ -0,0 +1,46 @@ +import SwiftUI + +#if canImport(UIKit) + import UIKit + public typealias PlatformImage = UIImage +#elseif canImport(AppKit) + import AppKit + public typealias PlatformImage = NSImage +#endif + +public enum NorgMathMode: Sendable { + case inline + case block +} + +public struct NorgMathImage { + public let image: PlatformImage + public let size: CGSize + public let descent: CGFloat + + public init(image: PlatformImage, size: CGSize, descent: CGFloat) { + self.image = image + self.size = size + self.descent = descent + } + + public var swiftUIImage: Image { + #if canImport(UIKit) + Image(uiImage: image) + #elseif canImport(AppKit) + Image(nsImage: image) + #endif + } +} + +/// Renders LaTeX-style math into an image for display. +public protocol NorgMathRenderer: Sendable { + /// Render `latex` at the given point size and colour. + /// + /// - Returns: the rendered image, or `nil` if the input could not be + /// rendered (the caller then keeps its text fallback). + @MainActor + func render( + latex: String, mode: NorgMathMode, fontSize: CGFloat, color: Color? + ) async -> NorgMathImage? +} diff --git a/Sources/NorgUI/Math/SwiftMathRenderer.swift b/Sources/NorgUI/Math/SwiftMathRenderer.swift new file mode 100644 index 0000000..6012a44 --- /dev/null +++ b/Sources/NorgUI/Math/SwiftMathRenderer.swift @@ -0,0 +1,60 @@ +#if SwiftMathSupport + import SwiftMath + import SwiftUI + + /// ``NorgMathRenderer`` backed by SwiftMath. Enabled by the + /// `SwiftMathSupport` package trait. + struct SwiftMathRenderer: NorgMathRenderer { + @MainActor + func render( + latex: String, mode: NorgMathMode, fontSize: CGFloat, color: Color? + ) async -> NorgMathImage? { + let label = MTMathUILabel() + label.latex = latex + label.labelMode = mode == .block ? .display : .text + label.fontSize = mode == .block ? fontSize * 1.4 : fontSize + if let color { + #if canImport(UIKit) + label.textColor = UIColor(color) + #elseif canImport(AppKit) + label.textColor = NSColor(color) + #endif + } + + let size = label.intrinsicContentSize + guard size.width > 0, size.height > 0, !latex.isEmpty else { return nil } + guard let image = rasterize(label, size: size) else { return nil } + + // SwiftMath does not expose the layout descent publicly, so approximate + // it by centring the formula on the text line. Tune if inline math sits + // too high or low against surrounding text. + let descent = max(0, (size.height - fontSize) / 2) + return NorgMathImage(image: image, size: size, descent: descent) + } + + @MainActor + private func rasterize(_ label: MTMathUILabel, size: CGSize) -> PlatformImage? { + label.frame = CGRect(origin: .zero, size: size) + #if canImport(UIKit) + label.layoutIfNeeded() + let format = UIGraphicsImageRendererFormat() + format.scale = UIScreen.main.scale + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: size, format: format) + return renderer.image { context in + let cg = context.cgContext + cg.translateBy(x: 0, y: size.height) + cg.scaleBy(x: 1, y: -1) + label.layer.render(in: cg) + } + #elseif canImport(AppKit) + label.layoutSubtreeIfNeeded() + guard let rep = label.bitmapImageRepForCachingDisplay(in: label.bounds) else { return nil } + label.cacheDisplay(in: label.bounds, to: rep) + let image = NSImage(size: size) + image.addRepresentation(rep) + return image + #endif + } + } +#endif diff --git a/Sources/NorgUI/Theme/NorgTheme.swift b/Sources/NorgUI/Theme/NorgTheme.swift index c788812..fa8ab25 100644 --- a/Sources/NorgUI/Theme/NorgTheme.swift +++ b/Sources/NorgUI/Theme/NorgTheme.swift @@ -40,6 +40,12 @@ public struct NorgTheme: Sendable { public var blockSpacing: CGFloat /// Padding applied above a heading. public var headingTopPadding: CGFloat + /// Point size used when rendering math. Inline math is rendered at this size + /// and display blocks slightly larger. + public var mathFontSize: CGFloat + /// Renderer used for inline and block math. When `nil`, math is shown as + /// monospaced text. See ``NorgMathRenderer``. + public var mathRenderer: (any NorgMathRenderer)? /// Tint for a task's status symbol. public var statusTint: @Sendable (TaskStatus) -> Color /// SF Symbol name for a task's status symbol. @@ -68,6 +74,8 @@ public struct NorgTheme: Sendable { verbatimPadding: CGFloat = 10, blockSpacing: CGFloat = 10, headingTopPadding: CGFloat = 4, + mathFontSize: CGFloat = 17, + mathRenderer: (any NorgMathRenderer)? = nil, statusTint: @escaping @Sendable (TaskStatus) -> Color, statusSymbol: @escaping @Sendable (TaskStatus) -> String, statusLabel: @escaping @Sendable (TaskStatus) -> LocalizedStringResource = { $0.displayName } @@ -90,6 +98,8 @@ public struct NorgTheme: Sendable { self.verbatimPadding = verbatimPadding self.blockSpacing = blockSpacing self.headingTopPadding = headingTopPadding + self.mathFontSize = mathFontSize + self.mathRenderer = mathRenderer self.statusTint = statusTint self.statusSymbol = statusSymbol self.statusLabel = statusLabel @@ -120,6 +130,7 @@ public struct NorgTheme: Sendable { codeBackground: AnyShapeStyle(.quaternary), codeFont: .system(.callout, design: .monospaced), indentWidth: 18, + mathRenderer: defaultMathRenderer, statusTint: { status in switch status { case .done: return .green @@ -141,4 +152,10 @@ public struct NorgTheme: Sendable { } } ) + + #if SwiftMathSupport + static let defaultMathRenderer: (any NorgMathRenderer)? = SwiftMathRenderer() + #else + static let defaultMathRenderer: (any NorgMathRenderer)? = nil + #endif } diff --git a/Sources/NorgUI/Views/Blocks/ListItemView.swift b/Sources/NorgUI/Views/Blocks/ListItemView.swift index 581974b..ffff2eb 100644 --- a/Sources/NorgUI/Views/Blocks/ListItemView.swift +++ b/Sources/NorgUI/Views/Blocks/ListItemView.swift @@ -20,7 +20,7 @@ struct ListItemView: View { Text(marker) .foregroundStyle(.secondary) .monospacedDigit() - Text(AttributedString(norg: content, theme: theme)) + NorgInlineText(spans: content) } } } diff --git a/Sources/NorgUI/Views/Blocks/ParagraphView.swift b/Sources/NorgUI/Views/Blocks/ParagraphView.swift index 2ad0dd5..d117ccc 100644 --- a/Sources/NorgUI/Views/Blocks/ParagraphView.swift +++ b/Sources/NorgUI/Views/Blocks/ParagraphView.swift @@ -2,11 +2,9 @@ import NorgKit import SwiftUI struct ParagraphView: View { - @Environment(\.norgTheme) private var theme - let content: [InlineSpan] var body: some View { - Text(AttributedString(norg: content, theme: theme)) + NorgInlineText(spans: content) } } diff --git a/Sources/NorgUI/Views/Blocks/QuoteView.swift b/Sources/NorgUI/Views/Blocks/QuoteView.swift index 0347d78..f5a6ec8 100644 --- a/Sources/NorgUI/Views/Blocks/QuoteView.swift +++ b/Sources/NorgUI/Views/Blocks/QuoteView.swift @@ -19,7 +19,7 @@ struct QuoteView: View { status: status, content: content, font: theme.body, line: line, onSetStatus: onSetStatus ) } else { - Text(AttributedString(norg: content, theme: theme)) + NorgInlineText(spans: content) .foregroundStyle(theme.quoteColor) .italic() } diff --git a/Sources/NorgUI/Views/Blocks/RangedTagView.swift b/Sources/NorgUI/Views/Blocks/RangedTagView.swift index 1cdf1d9..04ec4d1 100644 --- a/Sources/NorgUI/Views/Blocks/RangedTagView.swift +++ b/Sources/NorgUI/Views/Blocks/RangedTagView.swift @@ -1,10 +1,16 @@ import SwiftUI struct RangedTagView: View { + @Environment(\.norgTheme) private var theme + let name: [String] let content: String var body: some View { - VerbatimBlock(label: name.joined(separator: "."), content: content) + if name == ["math"], theme.mathRenderer != nil { + MathBlockView(latex: content) + } else { + VerbatimBlock(label: name.joined(separator: "."), content: content) + } } } diff --git a/Tests/NorgUITests/NorgThemeTests.swift b/Tests/NorgUITests/NorgThemeTests.swift index a621d90..acb9c6f 100644 --- a/Tests/NorgUITests/NorgThemeTests.swift +++ b/Tests/NorgUITests/NorgThemeTests.swift @@ -20,6 +20,7 @@ import Testing #expect(theme.superscriptOffset == 5) #expect(theme.subscriptOffset == -3) #expect(theme.indentWidth == 18) + #expect(theme.mathFontSize == 17) #expect(theme.linkColor == .accentColor) #expect(theme.verbatimColor == .pink) } |