Files
Puranjay Savar Mattas 6e1ffb96fd chore: vendor swift-markdown-engine as plain tracked source
Convert from git submodule to plain vendored copy. No push access to
upstream nodes-app/swift-markdown-engine meant our local fix commit
(05c1720) was stranded and unreachable from any remote on fresh
clones/CI. Vendoring as plain files folds it into normal repo history
instead.
2026-08-20 13:26:13 +01:00

315 lines
13 KiB
Swift

//
// SwiftMathBridge.swift
// MarkdownEngineLatex
//
// Ready-made LatexRenderer conformance backed by SwiftMath.
//
import AppKit
import Foundation
import CryptoKit
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()
/// On-disk mirror of the render cache. The in-memory cache dies with the
/// process, so the ~450ms cold render of a formula-heavy note used to recur on
/// the FIRST open of every session. Persisting keeps it cold only on the very
/// first render ever (per formula); every later launch loads the PNG from disk
/// (~0.3ms) instead of typesetting (~2ms). Lives in the app's Caches dir, which
/// the OS may purge under pressure — a purge just re-renders, so it is safe.
private lazy var diskCacheDir: URL? = {
guard let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
else { return nil }
let dir = base.appendingPathComponent("MarkdownEngineLatex", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}()
/// Reused across renders: `MTMathUILabel()` adds an errorLabel subview on every
/// init, so a fresh label per formula doubles the per-formula label overhead.
/// render() is main-thread-only (it reads NSApp), so a single shared label is safe.
private lazy var reusableLabel = MTMathUILabel()
/// - 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()
// The disk mirror is keyed by appearance/theme/font, so a flip already
// maps to different keys; but an explicit clearCache() means "forget
// everything", so drop the disk mirror too.
if let dir = diskCacheDir {
try? FileManager.default.removeItem(at: dir)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
}
// 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()
// Disk mirror: a hit here skips the ~2ms typeset for a ~0.3ms PNG load.
if let entry = loadFromDisk(key: key) {
cacheLock.lock()
cache[key] = entry
cacheLock.unlock()
return LatexRenderResult(image: entry.image, size: entry.size,
baselineOffset: entry.baselineOffset)
}
guard let entry = renderLatex(normalizedLatex, fontSize: fontSize, textColor: textColor) else {
return nil
}
cacheLock.lock()
cache[key] = entry
cacheLock.unlock()
saveToDisk(key: key, entry: entry)
return LatexRenderResult(
image: entry.image,
size: entry.size,
baselineOffset: entry.baselineOffset
)
}
// MARK: - Disk cache
/// Stable filename for a cache key: SHA-256 of the fingerprinting fields, hex.
private func diskFilename(for key: CacheKey) -> String {
let composite = "\(key.latex)|\(key.fontSize)|\(key.isDarkMode)|\(key.lightColorRGB)|\(key.darkColorRGB)"
let digest = SHA256.hash(data: Data(composite.utf8))
return digest.map { String(format: "%02x", $0) }.joined() + ".mathcache"
}
/// Persisted form: PNG pixels + the point-size and baseline the layout needs
/// (PNG carries pixels only, not the point size or baseline, so they are stored
/// alongside). Reconstructed image is byte-identical in appearance (PNG lossless).
private func loadFromDisk(key: CacheKey) -> CacheEntry? {
guard let dir = diskCacheDir else { return nil }
let url = dir.appendingPathComponent(diskFilename(for: key))
guard let data = try? Data(contentsOf: url),
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
let png = plist["png"] as? Data,
let w = plist["w"] as? Double, let h = plist["h"] as? Double,
let baseline = plist["b"] as? Double,
let rep = NSBitmapImageRep(data: png) else {
return nil
}
let size = CGSize(width: w, height: h)
rep.size = size
let image = NSImage(size: size)
image.addRepresentation(rep)
return CacheEntry(image: image, size: size, baselineOffset: CGFloat(baseline))
}
private func saveToDisk(key: CacheKey, entry: CacheEntry) {
guard let dir = diskCacheDir,
let rep = entry.image.representations.first as? NSBitmapImageRep,
let png = rep.representation(using: .png, properties: [:]) else { return }
let plist: [String: Any] = [
"png": png, "w": Double(entry.size.width), "h": Double(entry.size.height),
"b": Double(entry.baselineOffset)
]
guard let data = try? PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0)
else { return }
try? data.write(to: dir.appendingPathComponent(diskFilename(for: key)), options: .atomic)
}
// 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? {
// Reused instance (see `reusableLabel`); every property is set below so no
// stale state carries between formulas.
let mathLabel = reusableLabel
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 canvasHeight = exactHeight + paddingBottom
// `displayList.width` is the advance width, which excludes the right-side ink
// overhang of slanted glyphs (V, Y, P, F, …) — cropping to it clips them.
// Render with right slack, then crop to the measured ink edge.
let rightSlack = ceil(fontSize)
let probeWidth = ceil(exactWidth) + rightSlack
guard let probeRep = renderLabelToRep(mathLabel, size: CGSize(width: probeWidth, height: canvasHeight)),
let probeCG = probeRep.cgImage else {
return nil
}
let inkRight = Self.inkRightEdge(probeCG, widthInPoints: probeWidth) ?? exactWidth
let finalWidth = max(ceil(exactWidth), ceil(inkRight))
let finalSize = CGSize(width: finalWidth, height: canvasHeight)
// Crop to the measured width (full height kept); points→pixels via the rep,
// so this is correct at any backing scale.
let pxPerPoint = CGFloat(probeCG.width) / probeWidth
let cropPx = min(probeCG.width, Int((finalWidth * pxPerPoint).rounded()))
guard cropPx > 0,
let croppedCG = probeCG.cropping(to: CGRect(x: 0, y: 0, width: cropPx, height: probeCG.height)) else {
return nil
}
let finalRep = NSBitmapImageRep(cgImage: croppedCG)
finalRep.size = finalSize
let image = NSImage(size: finalSize)
image.addRepresentation(finalRep)
return CacheEntry(
image: image,
size: finalSize,
baselineOffset: displayList.descent
)
}
private func renderLabelToRep(_ label: MTMathUILabel, size: CGSize) -> NSBitmapImageRep? {
// `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()
guard let rep = label.bitmapImageRepForCachingDisplay(in: label.bounds) else { return nil }
label.cacheDisplay(in: label.bounds, to: rep)
return rep
}
/// Right-most x (in points) containing non-transparent ink, or `nil` if empty —
/// lets us crop a formula to its true ink width instead of the advance width.
private static func inkRightEdge(_ image: CGImage, widthInPoints: CGFloat) -> CGFloat? {
let w = image.width
let h = image.height
guard w > 0, h > 0, widthInPoints > 0 else { return nil }
let bytesPerRow = w * 4
var data = [UInt8](repeating: 0, count: bytesPerRow * h)
guard let cs = CGColorSpace(name: CGColorSpace.sRGB),
let ctx = CGContext(
data: &data, width: w, height: h, bitsPerComponent: 8,
bytesPerRow: bytesPerRow, space: cs,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
return nil
}
ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))
// Scan each row from the right, stopping once we pass the running max.
var maxX = -1
for y in 0..<h {
let row = y * bytesPerRow
var x = w - 1
while x > maxX {
if data[row + x * 4 + 3] > 10 { maxX = x; break }
x -= 1
}
}
guard maxX >= 0 else { return nil }
// +1: pixel `maxX` spans [maxX, maxX+1). Convert to points.
return (CGFloat(maxX) + 1) * widthInPoints / CGFloat(w)
}
}