diff --git a/Demo/MarkdownEngineDemo/ContentView.swift b/Demo/MarkdownEngineDemo/ContentView.swift index 408329b..564de9b 100644 --- a/Demo/MarkdownEngineDemo/ContentView.swift +++ b/Demo/MarkdownEngineDemo/ContentView.swift @@ -10,18 +10,9 @@ import MarkdownEngine struct ContentView: View { @State private var text: String = sampleMarkdown - @State private var isWikiLinkActive: Bool = false - @State private var pendingReplacement: InlineReplacementRequest? var body: some View { - NativeTextViewWrapper( - text: $text, - isWikiLinkActive: $isWikiLinkActive, - pendingInlineReplacement: $pendingReplacement, - configuration: .default, - fontName: "SF Pro", - documentId: "demo" - ) + NativeTextViewWrapper(text: $text) } } diff --git a/Package.swift b/Package.swift index f8e4c03..7bb0e68 100644 --- a/Package.swift +++ b/Package.swift @@ -7,14 +7,42 @@ import PackageDescription // conform to the engine's service protocols (`WikiLinkResolver`, // `EmbeddedImageProvider`, `SyntaxHighlighter`, `LatexRenderer`). The engine // itself has zero external dependencies. +// +// Users who want turnkey adapters for the two highest-friction protocols +// (code-block styling/highlighting, LaTeX rendering) can additionally +// depend on the `MarkdownEngineCodeBlocks` and/or `MarkdownEngineLatex` +// products, which ship pre-built bridges backed by HighlighterSwift and +// SwiftMath respectively. Both products are opt-in: the core +// `MarkdownEngine` library stays free of those transitive dependencies +// at link time. let package = Package( name: "MarkdownEngine", platforms: [.macOS(.v14)], products: [ - .library(name: "MarkdownEngine", targets: ["MarkdownEngine"]) + .library(name: "MarkdownEngine", targets: ["MarkdownEngine"]), + .library(name: "MarkdownEngineCodeBlocks", targets: ["MarkdownEngineCodeBlocks"]), + .library(name: "MarkdownEngineLatex", targets: ["MarkdownEngineLatex"]), + ], + dependencies: [ + .package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.0.0"), + .package(url: "https://github.com/mgriebling/SwiftMath", from: "1.7.0"), ], targets: [ .target(name: "MarkdownEngine"), + .target( + name: "MarkdownEngineCodeBlocks", + dependencies: [ + "MarkdownEngine", + .product(name: "Highlighter", package: "HighlighterSwift"), + ] + ), + .target( + name: "MarkdownEngineLatex", + dependencies: [ + "MarkdownEngine", + .product(name: "SwiftMath", package: "SwiftMath"), + ] + ), .testTarget( name: "MarkdownEngineTests", dependencies: ["MarkdownEngine"] diff --git a/README.md b/README.md index eb06111..b598801 100644 --- a/README.md +++ b/README.md @@ -40,26 +40,8 @@ When we started building **[Nodes](https://nodes-web.com/#/)** a minimal, beauti - **Drag-select autoscroll boost** for long documents - **Spelling & grammar** with code/LaTeX/wiki-link suppression -## Architecture - -The engine is built around four small service protocols you implement in -your app: - -| Protocol | What you supply | Suggested library | -|---|---|---| -| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | (your data model) | -| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | (your asset store) | -| `SyntaxHighlighter` | Highlight code blocks for a given language | [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) | -| `LatexRenderer` | Render a LaTeX string to an `NSImage` | [SwiftMath](https://github.com/mgriebling/SwiftMath) | - -All four ship with no-op default implementations so the editor renders -plain Markdown out of the box. Drop in real implementations as you need -them — the engine itself stays free of any of those transitive dependencies. - ## Installation -Add MarkdownEngine to your `Package.swift`: - ```swift dependencies: [ .package(url: "https://github.com/nodes-app/swift-markdown-engine", from: "0.1.0") @@ -67,13 +49,23 @@ dependencies: [ targets: [ .target( name: "YourApp", - dependencies: ["MarkdownEngine"] + dependencies: [ + .product(name: "MarkdownEngine", package: "swift-markdown-engine"), + ] ) ] ``` Or in Xcode: **File → Add Package Dependencies…** and paste the repo URL. +The package ships three library products — add only what you need: + +| Product | Use when | +|---|---| +| `MarkdownEngine` | You want the editor only. Zero external dependencies. | +| `MarkdownEngineCodeBlocks` | You want the full visual code-block experience — background fill, monospace font, and syntax highlighting — without writing your own bridge. Pulls in [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) transitively. See [Customization → Code Blocks](#code-blocks). | +| `MarkdownEngineLatex` | You want LaTeX formula rendering without writing your own bridge. Pulls in [SwiftMath](https://github.com/mgriebling/SwiftMath) transitively. See [Customization → LaTeX Rendering](#latex-rendering). | + ## Quick Start ```swift @@ -82,83 +74,118 @@ import MarkdownEngine struct EditorScreen: View { @State private var text: String = "# Hello, *world*" - @State private var isLinkActive: Bool = false - @State private var pendingReplacement: InlineReplacementRequest? var body: some View { - NativeTextViewWrapper( - text: $text, - isWikiLinkActive: $isLinkActive, - pendingInlineReplacement: $pendingReplacement, - configuration: .default, - fontName: "SF Pro", - documentId: "doc-1" - ) + NativeTextViewWrapper(text: $text) } } ``` -That's it. The default configuration ships with no-op services, so the -editor renders Markdown and accepts edits immediately. +That's it. See [Customization](#customization) below for syntax +highlighting, themes, wiki-link state, and more. -## Demo +> **Displaying multiple editors?** Pass a stable, unique +> `documentId: "your-doc-id"` so undo history and pending replacements +> stay scoped to each editor instance. -A runnable SwiftUI demo lives in [`Demo/`](Demo/MarkdownEngineDemo.xcodeproj). -Open [`Demo/MarkdownEngineDemo.xcodeproj`](Demo/MarkdownEngineDemo.xcodeproj) -in Xcode and hit **Run** — the demo references the package via a local -path, so Xcode resolves it on first open and any engine edit rebuilds -into the demo on the next run. +## Customization -> If you're seeing a "missing package product" error, it's almost always -> stale package cache from a previous Xcode session. Use **File → -> Packages → Reset Package Caches** once and rebuild. +### Service Protocols -## Customizing the Theme +The engine talks to your app through four service protocols, each with +a no-op default so you only implement what you actually need: -Every color the editor puts on screen is read from `MarkdownEditorTheme`: +| Protocol | What you supply | Ready-made bridge / suggested library | +|---|---|---| +| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | (your data model) | +| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | (your asset store) | +| `SyntaxHighlighter` | Highlight code blocks for a given language | **`HighlighterSwiftBridge`** ([recommended](#code-blocks)) — built on [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) | +| `LatexRenderer` | Render a LaTeX string to an `NSImage` | **`SwiftMathBridge`** ([recommended](#latex-rendering)) — built on [SwiftMath](https://github.com/mgriebling/SwiftMath) | + +Implement what you need and pass it through `MarkdownEditorServices`: ```swift -var theme = MarkdownEditorTheme.default -theme.bodyText = .labelColor -theme.headingMarker = .secondaryLabelColor -theme.findMatchHighlight = NSColor(named: "MyAccent")! +struct MyResolver: WikiLinkResolver { + func resolve(displayName: String, range: NSRange) -> WikiLinkResolution? { + myIndex[displayName].map { WikiLinkResolution(id: $0, exists: true) } + } +} -var configuration = MarkdownEditorConfiguration.default -configuration.theme = theme +configuration.services = MarkdownEditorServices( + wikiLinks: MyResolver() + // images, syntaxHighlighter, latex omitted → no-op defaults +) ``` -Defaults map to `NSColor` dynamic system colors so light / dark mode -switching keeps working without extra code. +Each protocol and its no-op default are documented in DocC. -## Wiring Up Services +### Code Blocks + +**Recommended path: depend on the `MarkdownEngineCodeBlocks` product +and use the bundled `HighlighterSwiftBridge`.** Rolling your own +`SyntaxHighlighter` has subtle footguns the bridge already handles — +line-height metrics across light/dark themes, appearance-change +observation, layout-pass timing, font name extraction from the theme, +and CSS-theme-derived background colors. Use the bundle unless you +specifically need a non-HighlighterSwift library. ```swift -struct MyResolver: WikiLinkResolver { - func resolve(displayName: String, range: NSRange) -> WikiLinkResolution? { - guard let id = myIndex[displayName] else { return nil } - return WikiLinkResolution(id: id, exists: true) - } -} +import MarkdownEngineCodeBlocks -struct MyImages: EmbeddedImageProvider { - func image(for ref: EmbeddedImageRequest) -> NSImage? { - myImageStore.load(name: ref.name) - } - func fingerprint() -> AnyHashable { myImageStore.version } -} +var configuration = MarkdownEditorConfiguration.default +configuration.services = MarkdownEditorServices( + syntaxHighlighter: HighlighterSwiftBridge() +) +``` + +The bridge auto-switches between `atom-one-light` and `atom-one-dark` +with system appearance. Different theme names or a pinned single theme +are configurable via init params — see DocC. + +Need a different highlighter library entirely? Implement +`SyntaxHighlighter` yourself (see [Service Protocols](#service-protocols) +above for the declaration) and reference the bundled bridge in +`Sources/MarkdownEngineCodeBlocks/` as a working example. + +### LaTeX Rendering -let services = MarkdownEditorServices( - wikiLinks: MyResolver(), - images: MyImages(), - syntaxHighlighter: MyHighlighter(), - latex: MyLatexRenderer() +**Recommended path: depend on the `MarkdownEngineLatex` product and use +the bundled `SwiftMathBridge`.** Hand-rolling a `LatexRenderer` has +real footguns the bridge already handles — appearance-aware text color, +zero-sized output guards (`lockFocus` crashes on 0×0 images), +window-vs-NSApp appearance distinction, single-letter padding, and an +internal cache keyed by (latex, font size, appearance, theme color). + +```swift +import MarkdownEngineLatex + +var configuration = MarkdownEditorConfiguration.default +configuration.services = MarkdownEditorServices( + latex: SwiftMathBridge() ) +``` + +The bridge uses the Latin Modern math font and tints formulas with +`MarkdownEditorTheme.latexLightModeText` / `latexDarkModeText`. Pass +`singleLetterPaddingBottom:` to override the engine's matching default. + +### Theming + +Every color the editor puts on screen reads from `MarkdownEditorTheme`: + +```swift +var theme = MarkdownEditorTheme.default +theme.bodyText = .labelColor +theme.findMatchHighlight = NSColor(named: "MyAccent")! var configuration = MarkdownEditorConfiguration.default -configuration.services = services +configuration.theme = theme ``` -## Tuning Behavior +Defaults map to `NSColor` dynamic system colors, so light/dark mode +keeps working without extra code. + +### Tuning `MarkdownEditorConfiguration` exposes every spacing / sizing / behavior knob the engine has, grouped by concern: @@ -168,9 +195,39 @@ var configuration = MarkdownEditorConfiguration.default configuration.codeBlock.fontSizeScale = 0.9 configuration.headings.fontMultipliers = [2.4, 1.8, 1.4, 1.1, 0.9, 0.75] configuration.overscroll.percent = 0.4 -configuration.lists.helpersEnabled = false // disable list editing helpers +configuration.lists.helpersEnabled = false +``` + +### Wiki-Links & Replacement State + +Two optional bindings on `NativeTextViewWrapper` let you observe +wiki-link state and push inline replacements programmatically. Pass +only what you need — each is independent and defaults to a no-op: + +```swift +NativeTextViewWrapper( + text: $text, + isWikiLinkActive: $isWikiLinkActive, + pendingInlineReplacement: $pendingReplacement +) ``` +- `isWikiLinkActive` — the wrapper sets this to `true` while the caret + sits inside a `[[Name]]` link, so you can present a contextual UI. +- `pendingInlineReplacement` — assign a non-nil value to push a + replacement (e.g. an autocomplete result); the engine consumes it + and clears the binding. + +## Demo + +A runnable SwiftUI demo lives in [`Demo/`](Demo/MarkdownEngineDemo.xcodeproj). +Open it in Xcode and hit **Run** — the demo references the package via +a local path, so any engine edit rebuilds into the demo on the next run. + +> If you're seeing a "missing package product" error, it's almost always +> stale package cache. Use **File → Packages → Reset Package Caches** +> once and rebuild. + ## Documentation Full API documentation is available via DocC: @@ -184,14 +241,10 @@ In Xcode: **Product → Build Documentation** (`⇧⌃⌘D`). Once the package is hosted on Swift Package Index, the docs will live at `https://swiftpackageindex.com/nodes-app/swift-markdown-engine/documentation`. -## Requirements - -- macOS 14 or later -- Swift 5.9 or later -- Xcode 15 or later -- macOS 15.1+ for Apple Writing Tools integration +## Requirements & Status -## Status +- macOS 14 or later (15.1+ for Apple Writing Tools integration) +- Swift 5.9 / Xcode 15 or later MarkdownEngine is currently **pre-1.0**. The public API may change between minor releases as it stabilizes. Production use is fine — pin a specific diff --git a/Sources/MarkdownEngine/TextView/CodeBlockButton.swift b/Sources/MarkdownEngine/TextView/CodeBlockButton.swift index 4f3959c..1e2c55e 100644 --- a/Sources/MarkdownEngine/TextView/CodeBlockButton.swift +++ b/Sources/MarkdownEngine/TextView/CodeBlockButton.swift @@ -42,11 +42,26 @@ public struct CodeBlockSelection: Identifiable, Sendable { public struct CodeBlockButton: View { /// The code block this button is attached to. public let selection: CodeBlockSelection + /// Vertical inset from the code block's top edge, in points. Positive + /// values push the button down into the code block. + public let topInset: CGFloat + /// Horizontal inset from the code block's trailing edge, in points. + /// Positive values keep the button inside the code block; negative + /// values let it overflow into a parent's gutter (the legacy Nodes + /// look). + public let trailingInset: CGFloat /// Closure invoked when the user clicks the button. public let onCopy: () -> Void - public init(selection: CodeBlockSelection, onCopy: @escaping () -> Void) { + public init( + selection: CodeBlockSelection, + topInset: CGFloat = 6, + trailingInset: CGFloat = 8, + onCopy: @escaping () -> Void + ) { self.selection = selection + self.topInset = topInset + self.trailingInset = trailingInset self.onCopy = onCopy } @@ -72,8 +87,8 @@ public struct CodeBlockButton: View { .cornerRadius(6) } .buttonStyle(.plain) - .padding(.top, 6) - .padding(.trailing, -25) + .padding(.top, topInset) + .padding(.trailing, trailingInset) } .position( x: selection.rect.midX, diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift index 34009a1..7f7165b 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift @@ -31,6 +31,12 @@ extension NativeTextViewCoordinator { let nsText = textView.string as NSString let scrollOffset = textView.enclosingScrollView?.contentView.bounds.origin ?? .zero + // One-shot full-document layout per document; fixes stale Y from TextKit 2's lazy layout without per-update cost. + if !didEnsureLayoutForCurrentDocument, let tlm = textView.textLayoutManager { + tlm.ensureLayout(for: tlm.documentRange) + didEnsureLayoutForCurrentDocument = true + } + let selections: [CodeBlockSelection] = cachedCodeBlockTokens.compactMap { originalIndex, token in guard !activeTokenIndices.contains(originalIndex) else { return nil } guard var boundingRect = textView.viewRect(forCharacterRange: token.range, using: layoutBridge) else { return nil } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index 7a2a339..51be957 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -39,6 +39,8 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var onInlineSelectionChange: ((InlineSelectionState?) -> Void)? var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? var didInitialFormatting: Bool = false + /// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document. + var didEnsureLayoutForCurrentDocument: Bool = false var lastSyncedText: String var isProgrammaticEdit: Bool = false var isWritingToolsActive: Bool = false diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index 23a2c9c..0f99802 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -44,7 +44,9 @@ public struct NativeTextViewWrapper: NSViewRepresentable { /// off this value via ``MarkdownEditorConfiguration``. public var fontSize: CGFloat /// Opaque document identifier. Changing this invalidates undo history - /// and resets per-document editor state. + /// and resets per-document editor state. Set a stable, unique value + /// per document when displaying multiple editors so pending + /// replacements and undo stay scoped to each editor. public var documentId: String /// When `false` the editor renders read-only with no caret. public var isEditable: Bool @@ -69,12 +71,12 @@ public struct NativeTextViewWrapper: NSViewRepresentable { public init( text: Binding, - isWikiLinkActive: Binding, - pendingInlineReplacement: Binding, - configuration: MarkdownEditorConfiguration, - fontName: String, + isWikiLinkActive: Binding = .constant(false), + pendingInlineReplacement: Binding = .constant(nil), + configuration: MarkdownEditorConfiguration = .default, + fontName: String = "SF Pro", fontSize: CGFloat = 16, - documentId: String, + documentId: String = "default", isEditable: Bool = true, onPasteImage: ((NSPasteboard) -> String?)? = nil, onLinkClick: ((String) -> Void)? = nil, @@ -184,11 +186,19 @@ public struct NativeTextViewWrapper: NSViewRepresentable { textView.recalcOverscroll(for: scrollView) scrollView.contentView.postsBoundsChangedNotifications = true + var lastObservedViewportWidth = scrollView.contentView.bounds.width NotificationCenter.default.addObserver(forName: NSView.frameDidChangeNotification, object: scrollView.contentView, queue: nil) { _ in - // Only react when the viewport itself resizes (window resize). - // Without this guard, TextKit-induced textView frame changes echo - // back here and re-trigger recalcOverscroll, causing a 149pt - // height oscillation after clicks. + // Refresh code-block overlays only on real viewport width changes, not on TextKit height-only echoes during typing. + let newWidth = scrollView.contentView.bounds.width + if abs(newWidth - lastObservedViewportWidth) > 0.5 { + lastObservedViewportWidth = newWidth + context.coordinator.didEnsureLayoutForCurrentDocument = false + context.coordinator.updateCodeBlockSelection(textView: textView) + } + // Only react with overscroll recalc when the viewport itself resizes + // (window resize). Without this guard, TextKit-induced textView frame + // changes echo back here and re-trigger recalcOverscroll, causing a + // 149pt height oscillation after clicks. guard abs(textView.frame.height - scrollView.contentView.bounds.height) > 1 else { return } textView.recalcOverscroll(for: scrollView) scrollView.clampToInsets() @@ -281,6 +291,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { context.coordinator.documentId = documentId textView.undoManager?.removeAllActions() context.coordinator.didInitialFormatting = false + context.coordinator.didEnsureLayoutForCurrentDocument = false context.coordinator.resetImageEmbedState() // Reset scroll to top of content so the previous file's scrollY // doesn't leak into a (potentially shorter) new file. diff --git a/Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift b/Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift new file mode 100644 index 0000000..c080cf5 --- /dev/null +++ b/Sources/MarkdownEngineCodeBlocks/HighlighterSwiftBridge.swift @@ -0,0 +1,176 @@ +// +// HighlighterSwiftBridge.swift +// MarkdownEngineCodeBlocks +// +// Ready-made SyntaxHighlighter conformance backed by HighlighterSwift. +// + +import AppKit +import Foundation +import Highlighter +import MarkdownEngine + +extension Notification.Name { + /// Posted by ``HighlighterSwiftBridge`` after the macOS appearance flips and themes are re-applied; the engine subscribes via ``SyntaxHighlighter/appearanceDidChangeNotification`` to invalidate cached attributes. + public static let markdownEngineHighlighterDidChangeAppearance = + Notification.Name("MarkdownEngineHighlighterDidChangeAppearance") +} + +/// Drop-in ``SyntaxHighlighter`` backed by HighlighterSwift. +/// +/// Defaults match the Nodes app's look: opaque light/dark code-block +/// backgrounds and an `SF Mono → Menlo → system monospace` font chain. +/// Override the init params if you'd rather adopt HighlighterSwift's +/// CSS-theme-driven background/font directly. +/// +/// When `autoSwitchAppearance` is `true`, the bridge observes +/// `AppleInterfaceThemeChangedNotification` and swaps `lightTheme` / +/// `darkTheme` accordingly, posting +/// ``Notification/Name/markdownEngineHighlighterDidChangeAppearance`` so +/// the engine can re-render code blocks. +public final class HighlighterSwiftBridge: SyntaxHighlighter, @unchecked Sendable { + private let highlighter: Highlighter? + private let lightTheme: String + private let darkTheme: String + private let autoSwitchAppearance: Bool + private let lightBackground: NSColor + private let darkBackground: NSColor + private let preferredFontNames: [String] + private var currentTheme: String = "" + + // HighlighterSwift's JavaScriptCore bridge is expensive — cache by (theme, language, code). + private let highlightCache = NSCache() + private let failedCache = NSCache() + private var unsupportedLanguages: Set = [] + + /// - Parameters: + /// - lightTheme: HighlighterSwift theme name applied in light mode. + /// - darkTheme: HighlighterSwift theme name applied in dark mode. + /// - autoSwitchAppearance: When `true`, observes the system appearance + /// and swaps themes automatically. Set to `false` to pin to `lightTheme`. + /// - lightBackground: Code-block background in light mode. Pass `nil` + /// to use HighlighterSwift's CSS-theme background instead. + /// - darkBackground: Code-block background in dark mode. Pass `nil` + /// to use HighlighterSwift's CSS-theme background instead. + /// - preferredFontNames: PostScript font names tried in order before + /// falling back to the system monospace font. + public init( + lightTheme: String = "atom-one-light", + darkTheme: String = "atom-one-dark", + autoSwitchAppearance: Bool = true, + lightBackground: NSColor? = NSColor(calibratedWhite: 0.95, alpha: 1.0), + darkBackground: NSColor? = NSColor(calibratedWhite: 0.13, alpha: 1.0), + preferredFontNames: [String] = ["SF Mono", "Menlo"] + ) { + self.highlighter = Highlighter() + self.lightTheme = lightTheme + self.darkTheme = darkTheme + self.autoSwitchAppearance = autoSwitchAppearance + self.lightBackground = lightBackground ?? .clear + self.darkBackground = darkBackground ?? .clear + self.preferredFontNames = preferredFontNames + highlightCache.countLimit = 256 + highlightCache.totalCostLimit = 2_000_000 + failedCache.countLimit = 256 + failedCache.totalCostLimit = 2_000_000 + applyAppearanceTheme() + + if autoSwitchAppearance { + DistributedNotificationCenter.default.addObserver( + forName: NSNotification.Name("AppleInterfaceThemeChangedNotification"), + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + self.applyAppearanceTheme() + NotificationCenter.default.post( + name: .markdownEngineHighlighterDidChangeAppearance, + object: nil + ) + } + } + } + + /// Drops the internal highlight cache. Call after manual theme changes the bridge can't observe. + public func clearCache() { + highlightCache.removeAllObjects() + failedCache.removeAllObjects() + } + + private func applyAppearanceTheme() { + guard let highlighter else { return } + let theme = isDarkAppearance() ? darkTheme : lightTheme + if currentTheme != theme { + currentTheme = theme + highlighter.setTheme(theme) + highlightCache.removeAllObjects() + failedCache.removeAllObjects() + } + } + + private func isDarkAppearance() -> Bool { + guard autoSwitchAppearance else { return false } + let appearance = NSApp.keyWindow?.effectiveAppearance ?? NSApp.effectiveAppearance + return appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + } + + // MARK: - SyntaxHighlighter + + public var appearanceDidChangeNotification: Notification.Name? { + autoSwitchAppearance ? .markdownEngineHighlighterDidChangeAppearance : nil + } + + public func codeFont(size: CGFloat) -> NSFont { + for name in preferredFontNames { + if let font = NSFont(name: name, size: size) { + return font + } + } + return .monospacedSystemFont(ofSize: size, weight: .regular) + } + + public func backgroundColor() -> NSColor { + isDarkAppearance() ? darkBackground : lightBackground + } + + public func highlight(code: String, language: String?) -> NSAttributedString? { + applyAppearanceTheme() + guard let highlighter else { return nil } + + let normalized = language?.lowercased().trimmingCharacters(in: .whitespaces) + let langKey = (normalized?.isEmpty == false) ? normalized! : "auto" + let cacheKey = "\(currentTheme)|\(langKey)|\(code)" as NSString + + if let cached = highlightCache.object(forKey: cacheKey) { + return cached + } + if failedCache.object(forKey: cacheKey) != nil { + return nil + } + + let explicit = normalized.flatMap { $0.isEmpty ? nil : $0 } + let skipExplicit = explicit.map { unsupportedLanguages.contains($0) } ?? false + + var highlighted: NSAttributedString? + if let lang = explicit, !skipExplicit { + highlighted = highlighter.highlight(code, as: lang) + if highlighted == nil { + // Unknown language — remember and fall back to auto-detect. + unsupportedLanguages.insert(lang) + highlighted = highlighter.highlight(code) + } + } else { + highlighted = highlighter.highlight(code) + } + + // Immutable copy so the cached entry can't be mutated by callers. + let result = highlighted.map { NSAttributedString(attributedString: $0) } + if let result { + highlightCache.setObject(result, forKey: cacheKey, cost: code.utf16.count) + failedCache.removeObject(forKey: cacheKey) + return result + } + failedCache.setObject(NSNumber(value: true), forKey: cacheKey, cost: code.utf16.count) + return nil + } +} diff --git a/Sources/MarkdownEngineLatex/SwiftMathBridge.swift b/Sources/MarkdownEngineLatex/SwiftMathBridge.swift new file mode 100644 index 0000000..4059cee --- /dev/null +++ b/Sources/MarkdownEngineLatex/SwiftMathBridge.swift @@ -0,0 +1,180 @@ +// +// SwiftMathBridge.swift +// MarkdownEngineLatex +// +// Ready-made LatexRenderer conformance backed by SwiftMath. +// + +import AppKit +import Foundation +import SwiftMath +import MarkdownEngine + +/// A drop-in ``LatexRenderer`` backed by [SwiftMath]. +/// +/// Renders both block (`$$ … $$`) and inline (`$ … $`) LaTeX strings into +/// `NSImage`s using the Latin Modern math font. Results are cached per +/// (latex, font size, appearance, theme color fingerprint) so repeated +/// renders are free. +/// +/// Light/dark appearance is taken from the host editor's window +/// effective appearance, not from `NSApp.effectiveAppearance`, so apps +/// that force a light window when the system is in dark mode still get +/// correctly-tinted formulas. +/// +/// [SwiftMath]: https://github.com/mgriebling/SwiftMath +public final class SwiftMathBridge: LatexRenderer, @unchecked Sendable { + private struct CacheKey: Hashable { + let latex: String + let fontSize: CGFloat + let isDarkMode: Bool + let lightColorRGB: UInt32 + let darkColorRGB: UInt32 + } + + private struct CacheEntry { + let image: NSImage + let size: CGSize + let baselineOffset: CGFloat + } + + private let singleLetterPaddingBottom: CGFloat + private var cache: [CacheKey: CacheEntry] = [:] + private let cacheLock = NSLock() + + /// - Parameter singleLetterPaddingBottom: Extra bottom padding (in + /// points) added to single-letter formulas to prevent visual + /// clipping; matches the engine's + /// ``MarkdownEditorConfiguration/blockLatex/singleLetterPaddingBottom`` + /// default. Override to match a customized configuration. + public init(singleLetterPaddingBottom: CGFloat = 1.0) { + self.singleLetterPaddingBottom = singleLetterPaddingBottom + } + + /// Clears the rendered-image cache. Call after appearance flips if + /// the host code doesn't re-render formulas automatically. + public func clearCache() { + cacheLock.lock() + cache.removeAll() + cacheLock.unlock() + } + + // MARK: - LatexRenderer + + public func render( + latex: String, + fontSize: CGFloat, + theme: MarkdownEditorTheme + ) -> LatexRenderResult? { + let normalizedLatex = latex.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedLatex.isEmpty else { return nil } + + let appearance = NSApp.keyWindow?.effectiveAppearance ?? NSApp.effectiveAppearance + let isDarkMode = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + let textColor = isDarkMode ? theme.latexDarkModeText : theme.latexLightModeText + let key = CacheKey( + latex: normalizedLatex, + fontSize: fontSize, + isDarkMode: isDarkMode, + lightColorRGB: Self.colorFingerprint(theme.latexLightModeText), + darkColorRGB: Self.colorFingerprint(theme.latexDarkModeText) + ) + + cacheLock.lock() + if let cached = cache[key] { + cacheLock.unlock() + return LatexRenderResult( + image: cached.image, + size: cached.size, + baselineOffset: cached.baselineOffset + ) + } + cacheLock.unlock() + + guard let entry = renderLatex(normalizedLatex, fontSize: fontSize, textColor: textColor) else { + return nil + } + + cacheLock.lock() + cache[key] = entry + cacheLock.unlock() + + return LatexRenderResult( + image: entry.image, + size: entry.size, + baselineOffset: entry.baselineOffset + ) + } + + // MARK: - Private + + /// Fold an `NSColor` to a 24-bit fingerprint that's good enough to + /// bust the cache when the theme changes the LaTeX text color. + private static func colorFingerprint(_ color: NSColor) -> UInt32 { + guard let rgb = color.usingColorSpace(.deviceRGB) else { return 0 } + let r = UInt32(max(0, min(255, Int(rgb.redComponent * 255)))) + let g = UInt32(max(0, min(255, Int(rgb.greenComponent * 255)))) + let b = UInt32(max(0, min(255, Int(rgb.blueComponent * 255)))) + return (r << 16) | (g << 8) | b + } + + private func renderLatex(_ latex: String, fontSize: CGFloat, textColor: NSColor) -> CacheEntry? { + let mathLabel = MTMathUILabel() + mathLabel.latex = latex + mathLabel.fontSize = fontSize + mathLabel.textColor = textColor + mathLabel.textAlignment = .left + mathLabel.labelMode = .text + + // Latin Modern Math gives the cleanest LaTeX glyphs at typical sizes. + if let mathFont = MTFontManager().font(withName: "latinmodern-math", size: fontSize) { + mathLabel.font = mathFont + } + + mathLabel.layoutSubtreeIfNeeded() + + guard let displayList = mathLabel.displayList else { return nil } + + // SwiftMath skips unsupported glyphs (e.g. emoji/raw Greek), which can yield + // zero-sized output. Bail instead of trying to render a 0x0 image — lockFocus + // (used internally by NSImage drawing) crashes on zero dimensions. + let exactWidth = displayList.width + let exactHeight = displayList.ascent + displayList.descent + guard exactWidth > 0, exactHeight > 0 else { return nil } + + let isSimpleSingleLetter = latex.range(of: #"^[A-Za-z]{1,3}$"#, options: .regularExpression) != nil + let paddingBottom: CGFloat = isSimpleSingleLetter ? singleLetterPaddingBottom : 0 + + let frameSize = CGSize( + width: ceil(exactWidth), + height: exactHeight + paddingBottom + ) + mathLabel.frame = CGRect(origin: .zero, size: frameSize) + + guard let image = renderLabelToImage(mathLabel, size: frameSize) else { + return nil + } + + return CacheEntry( + image: image, + size: frameSize, + baselineOffset: displayList.descent + ) + } + + private func renderLabelToImage(_ label: MTMathUILabel, size: CGSize) -> NSImage? { + // `bitmapImageRepForCachingDisplay` + `cacheDisplay(in:to:)` is the + // documented way to snapshot an NSView that isn't in a window. Setting + // `wantsLayer = true` and `layer.render(in:)` snapshots the (empty) + // backing layer instead of triggering MTMathUILabel's `draw(_:)`. + label.frame = CGRect(origin: .zero, size: size) + label.layoutSubtreeIfNeeded() + + let image = NSImage(size: size) + if let rep = label.bitmapImageRepForCachingDisplay(in: label.bounds) { + label.cacheDisplay(in: label.bounds, to: rep) + image.addRepresentation(rep) + } + return image + } +}