Implement Math view

This commit is contained in:
Guille Gonzalez
2026-01-04 15:33:48 +01:00
parent 6e1a38ede7
commit baea9de415
9 changed files with 356 additions and 8 deletions

View File

@@ -1,4 +1,4 @@
import Foundation
import SwiftUI
extension Math {
public struct Font: Hashable, Sendable {
@@ -33,3 +33,13 @@ extension Math.Font.Name {
public static let garamond: Self = "Garamond-Math"
public static let leteSans: Self = "LeteSansMath"
}
extension View {
public func mathFont(_ font: Math.Font) -> some View {
environment(\.mathFont, font)
}
}
extension EnvironmentValues {
@Entry var mathFont = Math.Font(name: .latinModern, size: 20)
}

View File

@@ -13,7 +13,7 @@ extension Math {
var localTextColor: CGColor?
var localBackgroundColor: CGColor?
func bounds() -> CGRect {
var bounds: CGRect {
CGRect(x: position.x, y: position.y - descent, width: width, height: ascent + descent)
}
}

View File

@@ -0,0 +1,118 @@
import Foundation
extension Math {
final class DisplayProvider: Sendable {
private struct Cache {
struct Key: Hashable {
let latex: String
let font: Font
let style: TypesettingStyle
let proposedWidth: CGFloat
}
let atomList = NSCache<NSString, AtomList>()
let displayNode = NSCache<KeyBox<Key>, DisplayNode>()
}
static let shared = DisplayProvider()
private let cache = ReadWriteLockIsolated<Cache>(Cache())
func sizeThatFits(
proposedWidth width: CGFloat,
latex: String,
font: Font,
style: TypesettingStyle
) -> CGSize {
display(
for: latex,
font: font,
style: style,
proposedWidth: width
)?.bounds.size ?? .zero
}
func display(
for latex: String,
font: Font,
style: TypesettingStyle,
proposedWidth: CGFloat
) -> DisplayNode? {
cache.withValue { cache in
let roundedWidth = proposedWidth.halfPointRounded()
let key = KeyBox(
Cache.Key(
latex: latex,
font: font,
style: style,
proposedWidth: roundedWidth
)
)
if let displayNode = cache.displayNode.object(forKey: key) {
return displayNode
}
guard
let atomList = atomList(for: latex, cache: &cache),
let displayNode = Typesetter.createLineForMathList(
atomList,
font: .init(font: font),
style: .init(style),
maxWidth: roundedWidth
)
else {
return nil
}
cache.displayNode.setObject(displayNode, forKey: key)
if displayNode.width != roundedWidth {
// Cache the measured width to avoid a miss between layout and draw passes
let secondaryKey = KeyBox(
Cache.Key(
latex: latex,
font: font,
style: style,
proposedWidth: displayNode.width.halfPointRounded()
)
)
cache.displayNode.setObject(displayNode, forKey: secondaryKey)
}
return displayNode
}
}
private func atomList(for latex: String, cache: inout Cache) -> AtomList? {
if let atomList = cache.atomList.object(forKey: latex as NSString) {
return atomList
}
guard let atomList = Parser.build(fromString: latex) else {
return nil
}
cache.atomList.setObject(atomList, forKey: latex as NSString)
return atomList
}
}
}
extension Math.Style.Level {
fileprivate init(_ typesettingStyle: Math.TypesettingStyle) {
switch typesettingStyle {
case .display:
self = .display
case .text:
self = .text
}
}
}
extension CGFloat {
fileprivate func halfPointRounded() -> CGFloat {
guard self > 0 else { return 0 }
return (self * 2).rounded() / 2
}
}

View File

@@ -6,6 +6,7 @@ extension GraphicsContext {
context.translateBy(x: 0, y: size.height)
context.scaleBy(x: 1, y: -1)
context.translateBy(x: 0, y: displayNode.descent)
let foregroundColor = foregroundColor.resolve(in: environment).cgColor

View File

@@ -1793,6 +1793,10 @@ extension Math {
currentLine.addAttribute(
kCTFontAttributeName as NSAttributedString.Key, value: styleFont.ctFont as Any,
range: NSMakeRange(0, currentLine.length))
currentLine.addAttribute(
NSAttributedString.Key(kCTForegroundColorFromContextAttributeName as String),
value: true,
range: NSMakeRange(0, currentLine.length))
/*assert(currentLineIndexRange.length == numCodePoints(currentLine.string),
"The length of the current line: %@ does not match the length of the range (%d, %d)",
currentLine, currentLineIndexRange.location, currentLineIndexRange.length);*/
@@ -2410,6 +2414,10 @@ extension Math {
line.addAttribute(
kCTFontAttributeName as NSAttributedString.Key, value: styleFont.ctFont,
range: NSMakeRange(0, line.length))
line.addAttribute(
NSAttributedString.Key(kCTForegroundColorFromContextAttributeName as String),
value: true,
range: NSMakeRange(0, line.length))
let attributedString = line.copy() as! NSAttributedString
let ctLine = CTLineCreateWithAttributedString(attributedString)
let displayAtom = DisplayTextRun(

View File

@@ -4,14 +4,14 @@ import Foundation
extension Math {
final class FontRegistry: Sendable {
static let shared = FontRegistry()
private struct Cache {
var graphicsFonts: [Font.Name: CGFont] = [:]
var tables: [Font.Name: FontTable] = [:]
let fonts = NSCache<KeyBox<Font>, CTFont>()
}
static let shared = FontRegistry()
private let cache = ReadWriteLockIsolated<Cache>(Cache())
func graphicsFont(named name: Font.Name) -> CGFont? {

View File

@@ -1,14 +1,185 @@
import SwiftUI
public struct Math: View {
public init() {
@Environment(\.mathFont) private var font
@Environment(\.mathTypesettingStyle) private var typesettingStyle
@Environment(\.mathRenderingMode) private var renderingMode
private let latex: String
public init(_ latex: String) {
self.latex = latex
}
public var body: some View {
Text("TODO: implement")
Layout(latex: latex, font: font, style: typesettingStyle) {
Canvas { context, size in
guard
let displayNode = DisplayProvider.shared.display(
for: latex,
font: font,
style: typesettingStyle,
proposedWidth: size.width
)
else {
return
}
switch renderingMode {
case .monochrome:
// Monochrome rendering with foreground style
context.draw(displayNode, size: size, with: .foreground)
case .multicolor(let base):
// Multicolor rendering with base color for uncolored elements
context.draw(displayNode, size: size, foregroundColor: base)
}
}
}
}
}
#Preview {
Math()
extension Math {
@_spi(Textual)
public struct TypographicBounds: Sendable {
@_spi(Textual)
public var origin: CGPoint
@_spi(Textual)
public var width: CGFloat
@_spi(Textual)
public var ascent: CGFloat
@_spi(Textual)
public var descent: CGFloat
@_spi(Textual)
public var size: CGSize {
.init(width: self.width, height: self.ascent + self.descent)
}
static let zero = TypographicBounds(origin: .zero, width: 0, ascent: 0, descent: 0)
}
@_spi(Textual)
public func typographicBounds(
fitting proposal: ProposedViewSize,
font: Font,
style: TypesettingStyle
) -> TypographicBounds {
if let width = proposal.width, width <= 0 {
return .zero
}
return DisplayProvider.shared
.display(
for: self.latex,
font: font,
style: style,
proposedWidth: proposal.width ?? 0
)
.map {
TypographicBounds(
origin: $0.position,
width: $0.width,
ascent: $0.ascent,
descent: $0.descent
)
} ?? .zero
}
}
extension Math {
private struct Layout: SwiftUI.Layout {
let latex: String
let font: Font
let style: TypesettingStyle
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
if let width = proposal.width, width <= 0 {
return .zero
}
return DisplayProvider.shared.sizeThatFits(
proposedWidth: proposal.width ?? 0,
latex: latex,
font: font,
style: style
)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
if let view = subviews.first {
view.place(at: bounds.origin, proposal: .init(bounds.size))
}
}
}
}
#Preview("Display Style") {
Math("\\frac{1}{2}+\\sqrt{2}+\\sum_{i=1}^{n}x_i")
.mathFont(Math.Font(name: .latinModern, size: 24))
.foregroundStyle(
.linearGradient(
colors: [.red, .blue],
startPoint: .top,
endPoint: .bottom
)
)
.padding()
}
#Preview("Text Style") {
Math("\\int_0^1 x^2\\,dx = \\frac{1}{3}")
.mathTypesettingStyle(.text)
.mathFont(Math.Font(name: .libertinus, size: 20))
.padding()
}
#Preview("Large Operators") {
Math("\\lim_{n\\to\\infty}\\sum_{k=1}^{n}\\frac{1}{k^2}=\\frac{\\pi^2}{6}")
.mathTypesettingStyle(.display)
.mathFont(Math.Font(name: .xits, size: 22))
.padding()
}
#Preview("Matrix") {
Math("A=\\begin{pmatrix}1&2\\\\3&4\\end{pmatrix}")
.mathTypesettingStyle(.display)
.mathFont(Math.Font(name: .asana, size: 22))
.padding()
}
#Preview("Cases") {
Math("\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}")
.mathTypesettingStyle(.display)
.mathFont(Math.Font(name: .termes, size: 22))
.padding()
}
#Preview("Accents And Scripts") {
Math("\\hat{x}+\\bar{y}+\\vec{z}+a_{i}^{2}")
.mathTypesettingStyle(.text)
.mathFont(Math.Font(name: .euler, size: 22))
.padding()
}
#Preview("Multicolor") {
Math("\\color{#cc0000}{a}+\\color{#00aa00}{b}+\\color{#0000cc}{c}")
.mathTypesettingStyle(.text)
.mathRenderingMode(.multicolor)
.mathFont(Math.Font(name: .latinModern, size: 22))
.padding()
}
#Preview("Multicolor 2") {
Math("\\textcolor{#ff8800}{\\int_0^1 x^2\\,dx}=\\textcolor{#0088ff}{\\frac{1}{3}}")
.mathRenderingMode(.multicolor)
.mathFont(Math.Font(name: .libertinus, size: 20))
.padding()
}

View File

@@ -0,0 +1,22 @@
import SwiftUI
extension Math {
public enum RenderingMode: Sendable {
case monochrome
case multicolor(base: SwiftUI.Color)
static var multicolor: Self {
.multicolor(base: .primary)
}
}
}
extension View {
public func mathRenderingMode(_ mathRenderingMode: Math.RenderingMode) -> some View {
environment(\.mathRenderingMode, mathRenderingMode)
}
}
extension EnvironmentValues {
@Entry var mathRenderingMode: Math.RenderingMode = .monochrome
}

View File

@@ -0,0 +1,18 @@
import SwiftUI
extension Math {
public enum TypesettingStyle: Sendable {
case display
case text
}
}
extension View {
public func mathTypesettingStyle(_ typesettingStyle: Math.TypesettingStyle) -> some View {
environment(\.mathTypesettingStyle, typesettingStyle)
}
}
extension EnvironmentValues {
@Entry var mathTypesettingStyle: Math.TypesettingStyle = .display
}