From 6051966b1e9b92f5fc670e7cd97fa0a34c695f5f 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 block LaTeX with SwiftMath display mode The parser already distinguishes block and inline formulas, but that information was discarded before rendering. Carry the mode through the renderer boundary so block formulas use display typesetting, while legacy renderers continue to receive the existing call. Include the mode in SwiftMath cache keys so an inline result cannot be reused for the same formula in display mode. --- CHANGELOG.md | 4 + Package.swift | 4 + .../Services/MarkdownEditorServices.swift | 27 +++- .../Styling/MarkdownStyler+Latex.swift | 14 ++- .../Styling/MarkdownStyler+Tables.swift | 7 +- .../MarkdownEngineLatex/SwiftMathBridge.swift | 33 ++++- .../SwiftMathBridgeModeTests.swift | 25 ++++ .../LatexRenderModeTests.swift | 119 ++++++++++++++++++ 8 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 Tests/MarkdownEngineLatexTests/SwiftMathBridgeModeTests.swift create mode 100644 Tests/MarkdownEngineTests/LatexRenderModeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 58fbc83f..891c433e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 highlight and collided with the number drawn over it. The marker's caret-crossing restyle signal went with the reveal. +### Fixed +- Block LaTeX formulas now use display typesetting, so large-operator limits + and fractions render correctly. + ### Performance - Scoped restyles inside a contiguous list parse and style only intersecting items instead of rebuilding the whole list block. Marker, indentation, 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/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift b/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift index 704d34b5..fb2aa97c 100644 --- a/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift +++ b/Sources/MarkdownEngine/Services/MarkdownEditorServices.swift @@ -154,12 +154,35 @@ 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. +/// +/// The mode-aware overload lets renderers distinguish `$ … $` from `$$ … $$`. +/// Existing renderers remain source-compatible because its default +/// implementation forwards to the original overload. public protocol LatexRenderer: Sendable { - /// Render `latex` at the requested font size, optionally tinted by `theme`. + /// Render `latex` without an explicit mode. /// - 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) + } } /// 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 19c4f0ba..e9050c40 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownStyler+Tables.swift @@ -471,7 +471,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..6d8c1228 --- /dev/null +++ b/Tests/MarkdownEngineLatexTests/SwiftMathBridgeModeTests.swift @@ -0,0 +1,25 @@ +// +// SwiftMathBridgeModeTests.swift +// MarkdownEngineLatexTests +// + +import AppKit +import Testing +import MarkdownEngine +import MarkdownEngineLatex + +@MainActor +@Suite("SwiftMath render modes") +struct SwiftMathBridgeModeTests { + @Test("Display mode typesets block formulas separately from inline cache entries") + func displayMode() throws { + _ = NSApplication.shared + let bridge = SwiftMathBridge() + let latex = #"\sum_{i=1}^{n} x_i"# + let inline = try #require(bridge.render(latex: latex, fontSize: 20, theme: .default)) + let display = try #require(bridge.render(latex: latex, mode: .display, fontSize: 20, theme: .default)) + + #expect(display.size.height > inline.size.height) + #expect(display.size.width < inline.size.width) + } +} diff --git a/Tests/MarkdownEngineTests/LatexRenderModeTests.swift b/Tests/MarkdownEngineTests/LatexRenderModeTests.swift new file mode 100644 index 00000000..98e9a900 --- /dev/null +++ b/Tests/MarkdownEngineTests/LatexRenderModeTests.swift @@ -0,0 +1,119 @@ +// +// 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}") + } +} + +private var testLatexResult: LatexRenderResult { + let size = CGSize(width: 20, height: 10) + return LatexRenderResult(image: NSImage(size: size), size: size, baselineOffset: 2) +}