Files
i7t5--edmund/Sources/EdmundCore/Rendering/EditorTextView+MathRendering.swift
T
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

185 lines
9.4 KiB
Swift

import AppKit
import SwiftMath
/// A rendered equation: the image plus its typesetting descent, which we need to
/// sit the math on the surrounding text's baseline.
private final class MathRender {
let image: NSImage
let descent: CGFloat
init(image: NSImage, descent: CGFloat) {
self.image = image
self.descent = descent
}
}
/// Rendered math is cached so we don't re-typeset on every keystroke or
/// recompose. The key encodes everything that affects the pixels/metrics:
/// latex, display vs inline, font size, and the resolved text color.
// NSCache is internally thread-safe; `nonisolated(unsafe)` opts it out of the
// Swift 6 Sendable check (in practice it's only touched on the main actor).
nonisolated(unsafe) private let mathRenderCache = NSCache<NSString, MathRender>()
extension EditorTextView {
/// Renders a LaTeX string to a `FragmentOverlay` sized to `fontSize` and
/// aligned to the text baseline, or `nil` if SwiftMath can't parse it (the
/// caller then shows the raw source instead).
func mathOverlay(latex: String, display: Bool, fontSize: CGFloat) -> FragmentOverlay? {
// Resolve the (dynamic) text color against this view's appearance so the
// math renders in the right shade for light/dark — and so the cache key
// differs between the two.
var color = foregroundColor
effectiveAppearance.performAsCurrentDrawingAppearance {
color = self.foregroundColor.usingColorSpace(.deviceRGB) ?? self.foregroundColor
}
let tag = String(format: "%.1f,%.3f,%.3f,%.3f,%.3f", fontSize,
color.redComponent, color.greenComponent,
color.blueComponent, color.alphaComponent)
let key = "\(display ? "D" : "I")|\(tag)|\(latex)" as NSString
let render: MathRender
if let cached = mathRenderCache.object(forKey: key) {
render = cached
} else {
let mode: MTMathUILabelMode = display ? .display : .text
let math = MTMathImage(latex: latex, fontSize: fontSize, textColor: color, labelMode: mode)
// SwiftMath sizes the image to the exact typographic ascent+descent,
// which crops a glyph's ink overshoot below the baseline — the bottom
// of a lone `x`/`c` sits flush on the image edge and renders clipped.
// A small content inset gives the rasterizer room so the full glyph is
// drawn; it's folded into the descent below so alignment is unchanged.
let insetPad: CGFloat = 2
math.contentInsets = MTEdgeInsets(top: insetPad, left: 0, bottom: insetPad, right: 0)
let (error, image) = math.asImage()
guard error == nil, let image else { return nil }
// Typeset once more via a label to read ascent/descent, then compute
// the baseline's distance from the image bottom the way SwiftMath's
// asImage does — including its `height < fontSize/2` clamp, which
// re-centers small glyphs (a lone x/c/n). Ignoring the clamp left
// those a pixel below the surrounding text baseline.
let label = MTMathUILabel()
label.latex = latex
label.fontSize = fontSize
label.labelMode = mode
label.layout()
let asc = label.displayList?.ascent ?? 0
let desc = label.displayList?.descent ?? 0
let clamped = max(asc + desc, fontSize / 2)
let descent = (asc + desc - clamped) / 2 + desc + insetPad
render = MathRender(image: image, descent: descent)
mathRenderCache.setObject(render, forKey: key)
}
var width = render.image.size.width
var height = render.image.size.height
var descent = render.descent
// Interim until SwiftMath line-wrapping ships: if the equation is wider
// than the text area, scale it down to fit (otherwise leave it natural
// size). The baseline descent scales with it.
let maxWidth = availableContentWidth
if maxWidth > 0, width > maxWidth {
let scale = maxWidth / width
width *= scale
height *= scale
descent *= scale
}
// The rendered image's baseline sits exactly one device pixel below the
// surrounding text baseline (measured constant across font sizes — it's a
// fixed rasterization offset, not a size-dependent rounding). Lift the
// image by one device pixel so the math rests on the text baseline. Done
// here, not in the cached descent, so it tracks the window's scale if it
// moves between a Retina and a non-Retina display.
let backingScale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2
descent -= 1 / backingScale
// Drop the image so its baseline (descent above the image bottom) lands
// on the text baseline.
return FragmentOverlay(image: render.image,
bounds: CGRect(x: 0, y: -descent, width: width, height: height))
}
/// The usable text width for one line — the text container minus its line
/// fragment padding on both sides. Used to cap over-wide equations (and
/// over-wide images).
var availableContentWidth: CGFloat {
guard let container = textContainer else { return 0 }
return container.containerSize.width - 2 * container.lineFragmentPadding
}
// MARK: - Raw LaTeX Source (shown when the cursor is inside the math)
/// Colors raw LaTeX source: operators/commands (`_`, `^`, `\sum`, `\cdot`,
/// i.e. a backslash followed by letters) in the theme's math-operator color,
/// and numbers in the math-number color. Other characters keep their color.
func colorMathSource(_ result: NSMutableAttributedString, range: NSRange) {
guard range.length > 0, range.upperBound <= result.length else { return }
let ns = result.string as NSString
let opColor = theme.mathOperatorColor
let numColor = theme.mathNumberColor
let backslash: unichar = 0x5C, underscore: unichar = 0x5F, caret: unichar = 0x5E
func isAlpha(_ c: unichar) -> Bool { (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A) }
func isDigit(_ c: unichar) -> Bool { c >= 0x30 && c <= 0x39 }
var i = range.location
let end = range.upperBound
while i < end {
let c = ns.character(at: i)
if c == backslash {
// Command: backslash + following letters (\sum, \cdot). A
// backslash before a non-letter (\,, \{) colors just the pair.
var j = i + 1
while j < end, isAlpha(ns.character(at: j)) { j += 1 }
let cmdEnd = j > i + 1 ? j : min(i + 2, end)
result.addAttribute(.foregroundColor, value: opColor,
range: NSRange(location: i, length: cmdEnd - i))
i = cmdEnd
} else if c == underscore || c == caret {
result.addAttribute(.foregroundColor, value: opColor,
range: NSRange(location: i, length: 1))
i += 1
} else if isDigit(c) {
var j = i + 1
while j < end, isDigit(ns.character(at: j)) { j += 1 }
result.addAttribute(.foregroundColor, value: numColor,
range: NSRange(location: i, length: j - i))
i = j
} else {
i += 1
}
}
}
/// Centered paragraph style for display math. The vertical padding is applied
/// only to the image's (first) line — a multi-line `$$…$$` block is
/// several paragraphs in the text storage (its hidden inner lines), so
/// padding every paragraph would multiply into a huge gap.
///
/// `imageAscent`/`imageDescent` reserve the equation's height on that line:
/// the line's own characters are hidden (near-zero), so without it the line
/// collapses. They're reserved separately, not as one combined height,
/// because of how TextKit 2 grows a line to meet `minimumLineHeight`: with
/// the hidden anchor's near-zero natural metrics, it adds ~all of the extra
/// height as ascent and pins the baseline at the box's bottom edge (measured
/// empirically — a tall multi-row image split ~54/46 ascent/descent left a
/// matching-sized gap above the equation and an overlap with the next
/// paragraph below, since `minimumLineHeight = full height` reserved that
/// height entirely above the baseline). Reserving only `imageAscent` in
/// `minimumLineHeight` sits the image's top flush with the box's top instead
/// of leaving a surplus gap, and folding `imageDescent` into the *following*
/// spacing (rather than the line's own height) gives the part of the image
/// that hangs below the baseline somewhere to go before the next paragraph.
func displayMathParagraphStyle(padded: Bool, imageAscent: CGFloat = 0,
imageDescent: CGFloat = 0) -> NSParagraphStyle {
let ps = NSMutableParagraphStyle()
ps.alignment = .center
ps.lineSpacing = 0
let pad = padded ? bodyFont.pointSize * 0.9 : 0
ps.paragraphSpacingBefore = pad
ps.paragraphSpacing = pad + imageDescent
ps.minimumLineHeight = imageAscent
return ps
}
}