Implement Math view
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
118
Sources/SwiftUIMath/Internal/Display/DisplayProvider.swift
Normal file
118
Sources/SwiftUIMath/Internal/Display/DisplayProvider.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
22
Sources/SwiftUIMath/RenderingMode.swift
Normal file
22
Sources/SwiftUIMath/RenderingMode.swift
Normal 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
|
||||
}
|
||||
18
Sources/SwiftUIMath/TypesettingStyle.swift
Normal file
18
Sources/SwiftUIMath/TypesettingStyle.swift
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user