diff options
Diffstat (limited to 'Sources/NorgUI')
| -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 |
9 files changed, 219 insertions, 6 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 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) + } } } |