Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ let package = Package(
.testTarget(
name: "MarkdownEngineTests",
dependencies: ["MarkdownEngine"]
),
.testTarget(
name: "MarkdownEngineLatexTests",
dependencies: ["MarkdownEngine", "MarkdownEngineLatex"]
)
]
)
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ 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).
internal cache keyed by (latex, render mode, font size, appearance, theme color).

```swift
import MarkdownEngineLatex
Expand Down
37 changes: 35 additions & 2 deletions Sources/MarkdownEngine/Services/MarkdownEditorServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,45 @@ public struct PlainTextSyntaxHighlighter: SyntaxHighlighter {

// MARK: - LaTeX

/// Renders LaTeX formulas to images for inline display.
/// The typesetting mode of a LaTeX formula.
public enum LatexRenderMode: Hashable, Sendable {
case inline
case display
}

/// Renders LaTeX formulas to images.
///
/// Implement `render(latex:mode:fontSize:theme:)`. The mode-less overload is
/// the pre-mode signature, kept so existing conformers still compile; a
/// renderer only needs to implement one of the two.
public protocol LatexRenderer: Sendable {
/// Render `latex` at the requested font size, optionally tinted by `theme`.
/// Render `latex` without an explicit mode.
///
/// The pre-mode entry point. Conformers written against the mode-aware
/// method don't need to implement this.
/// - Returns: A rendered result, or `nil` if the renderer cannot produce
/// an image (unsupported syntax, missing dependency, …).
func render(latex: String, fontSize: CGFloat, theme: MarkdownEditorTheme) -> LatexRenderResult?

/// Render `latex` using the mode implied by its Markdown delimiters:
/// `.display` for `$$ … $$`, `.inline` for `$ … $`.
///
/// This is the only overload the engine calls.
func render(latex: String, mode: LatexRenderMode, fontSize: CGFloat, theme: MarkdownEditorTheme) -> LatexRenderResult?
}

public extension LatexRenderer {
/// Mode-unaware renderers keep receiving the same delimiter-free LaTeX.
func render(latex: String, mode: LatexRenderMode, fontSize: CGFloat, theme: MarkdownEditorTheme) -> LatexRenderResult? {
render(latex: latex, fontSize: fontSize, theme: theme)
}

/// Bottoms out the forwarding chain so a mode-aware conformer isn't forced
/// to write an unused mode-less method. A renderer implementing neither
/// overload renders nothing, the documented "cannot produce an image" case.
func render(latex: String, fontSize: CGFloat, theme: MarkdownEditorTheme) -> LatexRenderResult? {
nil
}
}

/// Output of a LaTeX render call.
Expand Down
14 changes: 12 additions & 2 deletions Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ extension MarkdownStyler {
if isActive {
appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme)
} else if !latexContent.isEmpty,
let entry = ctx.services.latex.render(latex: latexContent, fontSize: latexFontSize, theme: ctx.configuration.theme) {
let entry = ctx.services.latex.render(
latex: latexContent,
mode: .display,
fontSize: latexFontSize,
theme: ctx.configuration.theme
) {
_ = appendRenderedStandaloneBlock(
for: token,
rawContent: rawLatexContent,
Expand Down Expand Up @@ -111,7 +116,12 @@ extension MarkdownStyler {
renderTheme.latexLightModeText = renderTheme.mutedText
renderTheme.latexDarkModeText = renderTheme.mutedText
}
if let entry = ctx.services.latex.render(latex: latexContent, fontSize: latexFontSize, theme: renderTheme) {
if let entry = ctx.services.latex.render(
latex: latexContent,
mode: .inline,
fontSize: latexFontSize,
theme: renderTheme
) {
let imageBounds = CGRect(x: 0, y: entry.baselineOffset, width: entry.size.width, height: entry.size.height)
let contentLength = token.contentRange.length

Expand Down
7 changes: 6 additions & 1 deletion Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,12 @@ extension MarkdownStyler {
.font: codeFont, .backgroundColor: codeBackgroundColor, .foregroundColor: theme.bodyText
]))
case .inlineLatex(let range, let content, _):
if let entry = latex.render(latex: ns.substring(with: content), fontSize: pointSize, theme: theme) {
if let entry = latex.render(
latex: ns.substring(with: content),
mode: .inline,
fontSize: pointSize,
theme: theme
) {
let attachment = NSTextAttachment()
attachment.image = entry.image
attachment.bounds = CGRect(x: 0, y: entry.baselineOffset,
Expand Down
33 changes: 28 additions & 5 deletions Sources/MarkdownEngineLatex/SwiftMathBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import MarkdownEngine
///
/// 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
/// (latex, mode, font size, appearance, theme color fingerprint) so repeated
/// renders are free.
///
/// Light/dark appearance is taken from the host editor's window
Expand All @@ -27,6 +27,7 @@ import MarkdownEngine
public final class SwiftMathBridge: LatexRenderer, @unchecked Sendable {
private struct CacheKey: Hashable {
let latex: String
let mode: LatexRenderMode
let fontSize: CGFloat
let isDarkMode: Bool
let lightColorRGB: UInt32
Expand Down Expand Up @@ -92,6 +93,16 @@ public final class SwiftMathBridge: LatexRenderer, @unchecked Sendable {
latex: String,
fontSize: CGFloat,
theme: MarkdownEditorTheme
) -> LatexRenderResult? {
// Preserve the historical mode-less behavior.
render(latex: latex, mode: .inline, fontSize: fontSize, theme: theme)
}

public func render(
latex: String,
mode: LatexRenderMode,
fontSize: CGFloat,
theme: MarkdownEditorTheme
) -> LatexRenderResult? {
let normalizedLatex = latex.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedLatex.isEmpty else { return nil }
Expand All @@ -101,6 +112,7 @@ public final class SwiftMathBridge: LatexRenderer, @unchecked Sendable {
let textColor = isDarkMode ? theme.latexDarkModeText : theme.latexLightModeText
let key = CacheKey(
latex: normalizedLatex,
mode: mode,
fontSize: fontSize,
isDarkMode: isDarkMode,
lightColorRGB: Self.colorFingerprint(theme.latexLightModeText),
Expand All @@ -127,7 +139,12 @@ public final class SwiftMathBridge: LatexRenderer, @unchecked Sendable {
baselineOffset: entry.baselineOffset)
}

guard let entry = renderLatex(normalizedLatex, fontSize: fontSize, textColor: textColor) else {
guard let entry = renderLatex(
normalizedLatex,
mode: mode,
fontSize: fontSize,
textColor: textColor
) else {
return nil
}

Expand All @@ -147,7 +164,8 @@ public final class SwiftMathBridge: LatexRenderer, @unchecked Sendable {

/// 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 mode = key.mode == .display ? "display" : "inline"
let composite = "\(key.latex)|\(mode)|\(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"
}
Expand Down Expand Up @@ -198,15 +216,20 @@ public final class SwiftMathBridge: LatexRenderer, @unchecked Sendable {
return (r << 16) | (g << 8) | b
}

private func renderLatex(_ latex: String, fontSize: CGFloat, textColor: NSColor) -> CacheEntry? {
private func renderLatex(
_ latex: String,
mode: LatexRenderMode,
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
mathLabel.labelMode = mode == .display ? .display : .text

// Latin Modern Math gives the cleanest LaTeX glyphs at typical sizes.
if let mathFont = MTFontManager().font(withName: "latinmodern-math", size: fontSize) {
Expand Down
51 changes: 51 additions & 0 deletions Tests/MarkdownEngineLatexTests/SwiftMathBridgeModeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// SwiftMathBridgeModeTests.swift
// MarkdownEngineLatexTests
//

import AppKit
import Testing
import MarkdownEngine
import MarkdownEngineLatex

@MainActor
@Suite("SwiftMath render modes")
struct SwiftMathBridgeModeTests {
@Test("Mode-less rendering keeps the historical text style")
func modeLessMatchesInline() throws {
_ = NSApplication.shared
let bridge = SwiftMathBridge()
let latex = #"\sum_{i=1}^{n} x_i"#
let implicit = try #require(bridge.render(latex: latex, fontSize: 20, theme: .default))
let inline = try #require(bridge.render(latex: latex, mode: .inline, fontSize: 20, theme: .default))

#expect(implicit.size == inline.size)
#expect(implicit.baselineOffset == inline.baselineOffset)
}

@Test("Display mode typesets limits above and below the operator")
func displayIsTallerThanInline() throws {
_ = NSApplication.shared
let bridge = SwiftMathBridge()
let latex = #"\sum_{i=1}^{n} x_i"#
let inline = try #require(bridge.render(latex: latex, mode: .inline, fontSize: 20, theme: .default))
let display = try #require(bridge.render(latex: latex, mode: .display, fontSize: 20, theme: .default))

// Limits move from beside the operator to above/below it: taller, narrower.
#expect(display.size.height > inline.size.height)
#expect(display.size.width < inline.size.width)
}

@Test("Mode participates in the cache key")
func modeSeparatesCacheEntries() throws {
_ = NSApplication.shared
let bridge = SwiftMathBridge()
let latex = #"\int_{0}^{1} f(x)\,dx"#
// Warm the cache in inline mode first; a mode-blind key would then serve
// the inline image back for the display request.
let inline = try #require(bridge.render(latex: latex, mode: .inline, fontSize: 20, theme: .default))
let display = try #require(bridge.render(latex: latex, mode: .display, fontSize: 20, theme: .default))

#expect(display.size != inline.size)
}
}
128 changes: 128 additions & 0 deletions Tests/MarkdownEngineTests/LatexRenderModeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//
// LatexRenderModeTests.swift
// MarkdownEngineTests
//

import AppKit
import Testing
@testable import MarkdownEngine

@MainActor
@Suite("LaTeX render modes")
struct LatexRenderModeTests {
private struct LegacyRenderer: LatexRenderer {
func render(
latex: String,
fontSize: CGFloat,
theme: MarkdownEditorTheme
) -> LatexRenderResult? {
guard latex == #"\sum_{i=1}^{n} x_i"# else { return nil }
return testLatexResult
}
}

private struct ModeCheckingRenderer: LatexRenderer {
let expectedLatex: String
let expectedMode: LatexRenderMode

func render(
latex: String,
fontSize: CGFloat,
theme: MarkdownEditorTheme
) -> LatexRenderResult? {
nil
}

func render(
latex: String,
mode: LatexRenderMode,
fontSize: CGFloat,
theme: MarkdownEditorTheme
) -> LatexRenderResult? {
guard latex == expectedLatex, mode == expectedMode else { return nil }
return testLatexResult
}
}

private static func configuration(latex: any LatexRenderer) -> MarkdownEditorConfiguration {
var configuration = MarkdownEditorConfiguration.default
configuration.services = MarkdownEditorServices(latex: latex)
return configuration
}

@Test("Existing renderers receive unchanged delimiter-free LaTeX")
func legacyRendererFallback() {
let renderer: any LatexRenderer = LegacyRenderer()
let rendered = renderer.render(
latex: #"\sum_{i=1}^{n} x_i"#,
mode: .display,
fontSize: 14,
theme: .default
)

#expect(rendered != nil)
}

@Test("Block and inline styling route their existing token mode")
func blockAndInlineRouting() {
_ = NSApplication.shared

let block = #"\sum_{i=1}^{n} x_i"#
let blockAttributes = MarkdownStyler.styleAttributes(
text: "$$\n\(block)\n$$",
fontName: "Helvetica",
fontSize: 14,
caretLocation: 0,
activeTokenIndices: [],
configuration: Self.configuration(
latex: ModeCheckingRenderer(expectedLatex: block, expectedMode: .display)
)
)
#expect(blockAttributes.contains { $0.attributes[.latexImage] != nil })

let inlineAttributes = MarkdownStyler.styleAttributes(
text: "before $x$ after",
fontName: "Helvetica",
fontSize: 14,
caretLocation: 0,
activeTokenIndices: [],
configuration: Self.configuration(
latex: ModeCheckingRenderer(expectedLatex: "x", expectedMode: .inline)
)
)
#expect(inlineAttributes.contains { $0.attributes[.latexImage] != nil })
}

@Test("Table math is inline")
func tableRouting() {
_ = NSApplication.shared
let configuration = Self.configuration(
latex: ModeCheckingRenderer(expectedLatex: "x", expectedMode: .inline)
)
let cell = MarkdownStyler.formattedCellString(
"$x$",
baseFont: .systemFont(ofSize: 14),
header: false,
theme: configuration.theme,
codeBackgroundColor: .clear,
latex: configuration.services.latex,
extensions: []
)

#expect(cell.string == "\u{FFFC}")
}

@Test("A renderer implementing neither overload renders nothing")
func emptyRendererProducesNoImage() {
struct EmptyRenderer: LatexRenderer {}

let renderer: any LatexRenderer = EmptyRenderer()
#expect(renderer.render(latex: "x", mode: .display, fontSize: 14, theme: .default) == nil)
#expect(renderer.render(latex: "x", fontSize: 14, theme: .default) == nil)
}
}

private var testLatexResult: LatexRenderResult {
let size = CGSize(width: 20, height: 10)
return LatexRenderResult(image: NSImage(size: size), size: size, baselineOffset: 2)
}