diff --git a/Sources/SwiftUIMath/Font.swift b/Sources/SwiftUIMath/Font.swift index cd17dc4..5144d46 100644 --- a/Sources/SwiftUIMath/Font.swift +++ b/Sources/SwiftUIMath/Font.swift @@ -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) +} diff --git a/Sources/SwiftUIMath/Internal/Display/DisplayNode.swift b/Sources/SwiftUIMath/Internal/Display/DisplayNode.swift index 34defbe..3e3fb3f 100644 --- a/Sources/SwiftUIMath/Internal/Display/DisplayNode.swift +++ b/Sources/SwiftUIMath/Internal/Display/DisplayNode.swift @@ -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) } } diff --git a/Sources/SwiftUIMath/Internal/Display/DisplayProvider.swift b/Sources/SwiftUIMath/Internal/Display/DisplayProvider.swift new file mode 100644 index 0000000..1c3efea --- /dev/null +++ b/Sources/SwiftUIMath/Internal/Display/DisplayProvider.swift @@ -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() + let displayNode = NSCache, DisplayNode>() + } + + static let shared = DisplayProvider() + + private let cache = ReadWriteLockIsolated(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 + } +} diff --git a/Sources/SwiftUIMath/Internal/Display/GraphicsContext+DisplayNode.swift b/Sources/SwiftUIMath/Internal/Display/GraphicsContext+DisplayNode.swift index 9f03534..6a4fcc9 100644 --- a/Sources/SwiftUIMath/Internal/Display/GraphicsContext+DisplayNode.swift +++ b/Sources/SwiftUIMath/Internal/Display/GraphicsContext+DisplayNode.swift @@ -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 diff --git a/Sources/SwiftUIMath/Internal/Display/Typesetter.swift b/Sources/SwiftUIMath/Internal/Display/Typesetter.swift index 2b28095..d87eec6 100644 --- a/Sources/SwiftUIMath/Internal/Display/Typesetter.swift +++ b/Sources/SwiftUIMath/Internal/Display/Typesetter.swift @@ -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( diff --git a/Sources/SwiftUIMath/Internal/Font/FontRegistry.swift b/Sources/SwiftUIMath/Internal/Font/FontRegistry.swift index c038b79..a6c66e6 100644 --- a/Sources/SwiftUIMath/Internal/Font/FontRegistry.swift +++ b/Sources/SwiftUIMath/Internal/Font/FontRegistry.swift @@ -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, CTFont>() } + static let shared = FontRegistry() + private let cache = ReadWriteLockIsolated(Cache()) func graphicsFont(named name: Font.Name) -> CGFont? { diff --git a/Sources/SwiftUIMath/Math.swift b/Sources/SwiftUIMath/Math.swift index f331b45..514b26e 100644 --- a/Sources/SwiftUIMath/Math.swift +++ b/Sources/SwiftUIMath/Math.swift @@ -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() } diff --git a/Sources/SwiftUIMath/RenderingMode.swift b/Sources/SwiftUIMath/RenderingMode.swift new file mode 100644 index 0000000..443c60d --- /dev/null +++ b/Sources/SwiftUIMath/RenderingMode.swift @@ -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 +} diff --git a/Sources/SwiftUIMath/TypesettingStyle.swift b/Sources/SwiftUIMath/TypesettingStyle.swift new file mode 100644 index 0000000..fb7bd83 --- /dev/null +++ b/Sources/SwiftUIMath/TypesettingStyle.swift @@ -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 +}