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 /Sources/NorgUI/Math | |
| parent | cb91a73bfc845d74c3e4d1115bce5dfed10d456d (diff) | |
Support Math rendering1.2.0
Diffstat (limited to 'Sources/NorgUI/Math')
| -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 |
4 files changed, 192 insertions, 0 deletions
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 |