Implement Math view
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import Foundation
|
import SwiftUI
|
||||||
|
|
||||||
extension Math {
|
extension Math {
|
||||||
public struct Font: Hashable, Sendable {
|
public struct Font: Hashable, Sendable {
|
||||||
@@ -33,3 +33,13 @@ extension Math.Font.Name {
|
|||||||
public static let garamond: Self = "Garamond-Math"
|
public static let garamond: Self = "Garamond-Math"
|
||||||
public static let leteSans: Self = "LeteSansMath"
|
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 localTextColor: CGColor?
|
||||||
var localBackgroundColor: CGColor?
|
var localBackgroundColor: CGColor?
|
||||||
|
|
||||||
func bounds() -> CGRect {
|
var bounds: CGRect {
|
||||||
CGRect(x: position.x, y: position.y - descent, width: width, height: ascent + descent)
|
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.translateBy(x: 0, y: size.height)
|
||||||
context.scaleBy(x: 1, y: -1)
|
context.scaleBy(x: 1, y: -1)
|
||||||
|
context.translateBy(x: 0, y: displayNode.descent)
|
||||||
|
|
||||||
let foregroundColor = foregroundColor.resolve(in: environment).cgColor
|
let foregroundColor = foregroundColor.resolve(in: environment).cgColor
|
||||||
|
|
||||||
|
|||||||
@@ -1793,6 +1793,10 @@ extension Math {
|
|||||||
currentLine.addAttribute(
|
currentLine.addAttribute(
|
||||||
kCTFontAttributeName as NSAttributedString.Key, value: styleFont.ctFont as Any,
|
kCTFontAttributeName as NSAttributedString.Key, value: styleFont.ctFont as Any,
|
||||||
range: NSMakeRange(0, currentLine.length))
|
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),
|
/*assert(currentLineIndexRange.length == numCodePoints(currentLine.string),
|
||||||
"The length of the current line: %@ does not match the length of the range (%d, %d)",
|
"The length of the current line: %@ does not match the length of the range (%d, %d)",
|
||||||
currentLine, currentLineIndexRange.location, currentLineIndexRange.length);*/
|
currentLine, currentLineIndexRange.location, currentLineIndexRange.length);*/
|
||||||
@@ -2410,6 +2414,10 @@ extension Math {
|
|||||||
line.addAttribute(
|
line.addAttribute(
|
||||||
kCTFontAttributeName as NSAttributedString.Key, value: styleFont.ctFont,
|
kCTFontAttributeName as NSAttributedString.Key, value: styleFont.ctFont,
|
||||||
range: NSMakeRange(0, line.length))
|
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 attributedString = line.copy() as! NSAttributedString
|
||||||
let ctLine = CTLineCreateWithAttributedString(attributedString)
|
let ctLine = CTLineCreateWithAttributedString(attributedString)
|
||||||
let displayAtom = DisplayTextRun(
|
let displayAtom = DisplayTextRun(
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import Foundation
|
|||||||
|
|
||||||
extension Math {
|
extension Math {
|
||||||
final class FontRegistry: Sendable {
|
final class FontRegistry: Sendable {
|
||||||
static let shared = FontRegistry()
|
|
||||||
|
|
||||||
private struct Cache {
|
private struct Cache {
|
||||||
var graphicsFonts: [Font.Name: CGFont] = [:]
|
var graphicsFonts: [Font.Name: CGFont] = [:]
|
||||||
var tables: [Font.Name: FontTable] = [:]
|
var tables: [Font.Name: FontTable] = [:]
|
||||||
let fonts = NSCache<KeyBox<Font>, CTFont>()
|
let fonts = NSCache<KeyBox<Font>, CTFont>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static let shared = FontRegistry()
|
||||||
|
|
||||||
private let cache = ReadWriteLockIsolated<Cache>(Cache())
|
private let cache = ReadWriteLockIsolated<Cache>(Cache())
|
||||||
|
|
||||||
func graphicsFont(named name: Font.Name) -> CGFont? {
|
func graphicsFont(named name: Font.Name) -> CGFont? {
|
||||||
|
|||||||
@@ -1,14 +1,185 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
public struct Math: View {
|
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 {
|
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 {
|
extension Math {
|
||||||
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