From 439cc8112d2c689fa845ce883f5ad402e1414bbc Mon Sep 17 00:00:00 2001 From: Jeff <140712389+manemajef@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:44:48 +0300 Subject: [PATCH] Render LaTeX with inline and display modes --- Package.swift | 4 + README.md | 2 +- .../Services/MarkdownEditorServices.swift | 37 ++++- .../Styling/MarkdownStyler+Latex.swift | 14 +- .../Styling/MarkdownStyler+Tables.swift | 7 +- .../MarkdownEngineLatex/SwiftMathBridge.swift | 33 ++++- .../SwiftMathBridgeModeTests.swift | 51 +++++++ .../LatexRenderModeTests.swift | 128 ++++++++++++++++++ 8 files changed, 265 insertions(+), 11 deletions(-) create mode 100644 Tests/MarkdownEngineLatexTests/SwiftMathBridgeModeTests.swift create mode 100644 Tests/MarkdownEngineTests/LatexRenderModeTests.swift diff --git a/Package.swift b/Package.swift index 7bb0e681..b7007c58 100644 --- a/Package.swift +++ b/Package.swift @@ -46,6 +46,10 @@ let package = Package( .testTarget( name: "MarkdownEngineTests", dependencies: ["MarkdownEngine"] + ), + .testTarget( + name: "MarkdownEngineLatexTests", + dependencies: ["MarkdownEngine", "MarkdownEngineLatex"] ) ] ) diff --git a/README.md b/README.md index c58de939..2892fe49 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift b/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift index 704d34b5..67168073 100644 --- a/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift +++ b/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift @@ -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. diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift index da41d8cf..3b47f31e 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Latex.swift @@ -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, @@ -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 diff --git a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift index 1f123530..4f5b33e9 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -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, diff --git a/Sources/MarkdownEngineLatex/SwiftMathBridge.swift b/Sources/MarkdownEngineLatex/SwiftMathBridge.swift index 27c92db9..f424ec44 100644 --- a/Sources/MarkdownEngineLatex/SwiftMathBridge.swift +++ b/Sources/MarkdownEngineLatex/SwiftMathBridge.swift @@ -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 @@ -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 @@ -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 } @@ -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), @@ -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 } @@ -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" } @@ -198,7 +216,12 @@ 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 @@ -206,7 +229,7 @@ public final class SwiftMathBridge: LatexRenderer, @unchecked Sendable { 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) { diff --git a/Tests/MarkdownEngineLatexTests/SwiftMathBridgeModeTests.swift b/Tests/MarkdownEngineLatexTests/SwiftMathBridgeModeTests.swift new file mode 100644 index 00000000..5badc4a5 --- /dev/null +++ b/Tests/MarkdownEngineLatexTests/SwiftMathBridgeModeTests.swift @@ -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) + } +} diff --git a/Tests/MarkdownEngineTests/LatexRenderModeTests.swift b/Tests/MarkdownEngineTests/LatexRenderModeTests.swift new file mode 100644 index 00000000..6757a20b --- /dev/null +++ b/Tests/MarkdownEngineTests/LatexRenderModeTests.swift @@ -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) +}