From fbfc1d0ecf44ef3fc8605eb3e26858320a92bb7f Mon Sep 17 00:00:00 2001 From: Guille Gonzalez Date: Wed, 31 Dec 2025 17:22:20 +0100 Subject: [PATCH] Add font infrastructure --- Sources/SwiftUIMath/Font.swift | 35 ++ .../Internal/Font/FontMetrics.swift | 415 ++++++++++++++++++ .../Internal/Font/FontRegistry.swift | 114 +++++ .../Internal/Font/PlatformFont.swift | 35 ++ Sources/SwiftUIMath/Internal/Font/Table.swift | 39 ++ .../SwiftUIMath/Internal/Helpers/KeyBox.swift | 22 + .../Internal/Helpers/Platform.swift | 2 - .../Internal/Helpers/Unicode.swift | 2 - Sources/SwiftUIMath/Math.swift | 14 + 9 files changed, 674 insertions(+), 4 deletions(-) create mode 100644 Sources/SwiftUIMath/Font.swift create mode 100644 Sources/SwiftUIMath/Internal/Font/FontMetrics.swift create mode 100644 Sources/SwiftUIMath/Internal/Font/FontRegistry.swift create mode 100644 Sources/SwiftUIMath/Internal/Font/PlatformFont.swift create mode 100644 Sources/SwiftUIMath/Internal/Font/Table.swift create mode 100644 Sources/SwiftUIMath/Internal/Helpers/KeyBox.swift create mode 100644 Sources/SwiftUIMath/Math.swift diff --git a/Sources/SwiftUIMath/Font.swift b/Sources/SwiftUIMath/Font.swift new file mode 100644 index 0000000..cd17dc4 --- /dev/null +++ b/Sources/SwiftUIMath/Font.swift @@ -0,0 +1,35 @@ +import Foundation + +extension Math { + public struct Font: Hashable, Sendable { + public struct Name: Hashable, Sendable, RawRepresentable, ExpressibleByStringLiteral { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: StringLiteralType) { + self.rawValue = value + } + } + + public let name: Name + public let size: CGFloat + } +} + +extension Math.Font.Name { + public static let latinModern: Self = "latinmodern-math" + public static let kpMathLight: Self = "KpMath-Light" + public static let kpMathSans: Self = "KpMath-Sans" + public static let xits: Self = "xits-math" + public static let termes: Self = "texgyretermes-math" + public static let asana: Self = "Asana-Math" + public static let euler: Self = "Euler-Math" + public static let fira: Self = "FiraMath-Regular" + public static let notoSans: Self = "NotoSansMath-Regular" + public static let libertinus: Self = "LibertinusMath-Regular" + public static let garamond: Self = "Garamond-Math" + public static let leteSans: Self = "LeteSansMath" +} diff --git a/Sources/SwiftUIMath/Internal/Font/FontMetrics.swift b/Sources/SwiftUIMath/Internal/Font/FontMetrics.swift new file mode 100644 index 0000000..149bf6c --- /dev/null +++ b/Sources/SwiftUIMath/Internal/Font/FontMetrics.swift @@ -0,0 +1,415 @@ +@preconcurrency import CoreGraphics +@preconcurrency import CoreText +import Foundation + +extension Math { + struct FontMetrics: Sendable { + struct GlyphPart: Sendable { + let glyph: CGGlyph + let fullAdvance: CGFloat + let startConnectorLength: CGFloat + let endConnectorLength: CGFloat + let isExtender: Bool + } + + var mathUnit: CGFloat { + font.size / 18 + } + + private let font: Font + private let unitsPerEm: UInt + private let table: Table + + init(font: Font, unitsPerEm: UInt, table: Table) { + self.font = font + self.unitsPerEm = unitsPerEm + self.table = table + } + + func verticalVariants(forGlyph glyph: CGGlyph) -> [CGGlyph] { + guard + let graphicsFont = FontRegistry.shared.graphicsFont(named: font.name), + let glyphName = graphicsFont.name(for: glyph) as String?, + let variantGlyphs = table.vVariants[glyphName] + else { + return [glyph] + } + + return variantGlyphs.map { + graphicsFont.getGlyphWithGlyphName(name: $0 as CFString) + } + } + + func horizontalVariants(forGlyph glyph: CGGlyph) -> [CGGlyph] { + guard + let graphicsFont = FontRegistry.shared.graphicsFont(named: font.name), + let glyphName = graphicsFont.name(for: glyph) as String?, + let variantGlyphs = table.hVariants[glyphName] + else { + return [glyph] + } + + return variantGlyphs.map { + graphicsFont.getGlyphWithGlyphName(name: $0 as CFString) + } + } + + func largerGlyph(forGlyph glyph: CGGlyph) -> CGGlyph { + guard + let graphicsFont = FontRegistry.shared.graphicsFont(named: font.name), + let glyphName = graphicsFont.name(for: glyph) as String?, + let variantGlyphs = table.vVariants[glyphName] + else { + return glyph + } + + return + variantGlyphs + .first { $0 != glyphName } + .map { + graphicsFont.getGlyphWithGlyphName(name: $0 as CFString) + } ?? glyph + } + + func italicCorrection(forGlyph glyph: CGGlyph) -> CGFloat { + guard + let graphicsFont = FontRegistry.shared.graphicsFont(named: font.name), + let glyphName = graphicsFont.name(for: glyph) as String?, + let value = table.italic[glyphName] + else { + return 0 + } + + return unitsToPoints(value) + } + + func topAccentAdjustment(forGlyph glyph: CGGlyph) -> CGFloat { + guard + let graphicsFont = FontRegistry.shared.graphicsFont(named: font.name), + let glyphName = graphicsFont.name(for: glyph) as String?, + let value = table.accents[glyphName] + else { + // If no top accent is defined then it is the center of the advance width + return advance(forGlyph: glyph).width / 2 + } + + return unitsToPoints(value) + } + + func verticalAssembly(forGlyph glyph: CGGlyph) -> [GlyphPart] { + guard + let graphicsFont = FontRegistry.shared.graphicsFont(named: font.name), + let glyphName = graphicsFont.name(for: glyph) as String?, + let assembly = table.vAssembly[glyphName] + else { + return [] + } + + return assembly.parts.map { part in + GlyphPart( + glyph: graphicsFont.getGlyphWithGlyphName(name: part.glyph as CFString), + fullAdvance: unitsToPoints(part.advance), + startConnectorLength: unitsToPoints(part.startConnector), + endConnectorLength: unitsToPoints(part.endConnector), + isExtender: part.extender + ) + } + } + } +} + +// MARK: - Fractions + +extension Math.FontMetrics { + var fractionNumeratorDisplayStyleShiftUp: CGFloat { + constant(named: "FractionNumeratorDisplayStyleShiftUp") + } + + var fractionNumeratorShiftUp: CGFloat { + constant(named: "FractionNumeratorShiftUp") + } + + var fractionDenominatorDisplayStyleShiftDown: CGFloat { + constant(named: "FractionDenominatorDisplayStyleShiftDown") + } + + var fractionDenominatorShiftDown: CGFloat { + constant(named: "FractionDenominatorShiftDown") + } + + var fractionNumeratorDisplayStyleGapMin: CGFloat { + constant(named: "FractionNumDisplayStyleGapMin") + } + + var fractionNumeratorGapMin: CGFloat { + constant(named: "FractionNumeratorGapMin") + } + + var fractionDenominatorDisplayStyleGapMin: CGFloat { + constant(named: "FractionDenomDisplayStyleGapMin") + } + + var fractionDenominatorGapMin: CGFloat { + constant(named: "FractionDenominatorGapMin") + } + + var fractionRuleThickness: CGFloat { + constant(named: "FractionRuleThickness") + } + + var fractionDelimiterSize: CGFloat { + 1.01 * font.size + } + + var fractionDelimiterDisplayStyleSize: CGFloat { + 2.39 * font.size + } +} + +// MARK: - Stacks + +extension Math.FontMetrics { + var skewedFractionHorizonalGap: CGFloat { + constant(named: "SkewedFractionHorizontalGap") + } + + var skewedFractionVerticalGap: CGFloat { + constant(named: "SkewedFractionVerticalGap") + } + + var stackTopDisplayStyleShiftUp: CGFloat { + constant(named: "StackTopDisplayStyleShiftUp") + } + + var stackTopShiftUp: CGFloat { + constant(named: "StackTopShiftUp") + } + + var stackDisplayStyleGapMin: CGFloat { + constant(named: "StackDisplayStyleGapMin") + } + + var stackGapMin: CGFloat { + constant(named: "StackGapMin") + } + + var stackBottomDisplayStyleShiftDown: CGFloat { + constant(named: "StackBottomDisplayStyleShiftDown") + } + + var stackBottomShiftDown: CGFloat { + constant(named: "StackBottomShiftDown") + } +} + +// MARK: - Superscripts / Subscripts + +extension Math.FontMetrics { + var superscriptShiftUp: CGFloat { + constant(named: "SuperscriptShiftUp") + } + + var superscriptShiftUpCramped: CGFloat { + constant(named: "SuperscriptShiftUpCramped") + } + + var subscriptShiftDown: CGFloat { + constant(named: "SubscriptShiftDown") + } + + var superscriptBaselineDropMax: CGFloat { + constant(named: "SuperscriptBaselineDropMax") + } + + var subscriptBaselineDropMin: CGFloat { + constant(named: "SubscriptBaselineDropMin") + } + + var superscriptBottomMin: CGFloat { + constant(named: "SuperscriptBottomMin") + } + + var subscriptTopMax: CGFloat { + constant(named: "SubscriptTopMax") + } + + var subSuperscriptGapMin: CGFloat { + constant(named: "SubSuperscriptGapMin") + } + + var superscriptBottomMaxWithSubscript: CGFloat { + constant(named: "SuperscriptBottomMaxWithSubscript") + } + + var spaceAfterScript: CGFloat { + constant(named: "SpaceAfterScript") + } +} + +// MARK: - Radicals + +extension Math.FontMetrics { + var radicalExtraAscender: CGFloat { + constant(named: "RadicalExtraAscender") + } + + var radicalRuleThickness: CGFloat { + constant(named: "RadicalRuleThickness") + } + + var radicalDisplayStyleVerticalGap: CGFloat { + constant(named: "RadicalDisplayStyleVerticalGap") + } + + var radicalVerticalGap: CGFloat { + constant(named: "RadicalVerticalGap") + } + + var radicalKernBeforeDegree: CGFloat { + constant(named: "RadicalKernBeforeDegree") + } + + var radicalKernAfterDegree: CGFloat { + constant(named: "RadicalKernAfterDegree") + } + + var radicalDegreeBottomRaisePercent: CGFloat { + constantPercent(named: "RadicalDegreeBottomRaisePercent") + } +} + +// MARK: - Limits + +extension Math.FontMetrics { + var upperLimitBaselineRiseMin: CGFloat { + constant(named: "UpperLimitBaselineRiseMin") + } + + var upperLimitGapMin: CGFloat { + constant(named: "UpperLimitGapMin") + } + + var lowerLimitGapMin: CGFloat { + constant(named: "LowerLimitGapMin") + } + + var lowerLimitBaselineDropMin: CGFloat { + constant(named: "LowerLimitBaselineDropMin") + } + + var limitExtraAscenderDescender: CGFloat { + 0 + } +} + +// MARK: - Underline + +extension Math.FontMetrics { + var underbarVerticalGap: CGFloat { + constant(named: "UnderbarVerticalGap") + } + + var underbarRuleThickness: CGFloat { + constant(named: "UnderbarRuleThickness") + } + + var underbarExtraDescender: CGFloat { + constant(named: "UnderbarExtraDescender") + } +} + +// MARK: - Overline + +extension Math.FontMetrics { + var overbarVerticalGap: CGFloat { + constant(named: "OverbarVerticalGap") + } + + var overbarRuleThickness: CGFloat { + constant(named: "OverbarRuleThickness") + } + + var overbarExtraAscender: CGFloat { + constant(named: "OverbarExtraAscender") + } +} + +// MARK: - Constants + +extension Math.FontMetrics { + var axisHeight: CGFloat { + constant(named: "AxisHeight") + } + + var scriptScaleDown: CGFloat { + constantPercent(named: "ScriptPercentScaleDown") + } + + var scriptScriptScaleDown: CGFloat { + constantPercent(named: "ScriptScriptPercentScaleDown") + } + + var mathLeading: CGFloat { + constant(named: "MathLeading") + } + + var delimitedSubFormulaMinHeight: CGFloat { + constant(named: "DelimitedSubFormulaMinHeight") + } +} + +// MARK: - Accent + +extension Math.FontMetrics { + var accentBaseHeight: CGFloat { + constant(named: "AccentBaseHeight") + } + + var flattenedAccentBaseHeight: CGFloat { + constant(named: "FlattenedAccentBaseHeight") + } +} + +// MARK: - Glyph Construction + +extension Math.FontMetrics { + var minConnectorOverlap: CGFloat { + constant(named: "MinConnectorOverlap") + } +} + +// MARK: - Private + +extension Math.FontMetrics { + private func constant(named name: String) -> CGFloat { + guard let value = table.constants[name] else { + return .zero + } + return unitsToPoints(value) + } + + private func constantPercent(named name: String) -> CGFloat { + guard let value = table.constants[name] else { + return .zero + } + return CGFloat(value) / 100 + } + + private func unitsToPoints(_ units: Int) -> CGFloat { + CGFloat(units) * font.size / CGFloat(unitsPerEm) + } + + private func advance(forGlyph glyph: CGGlyph) -> CGSize { + guard + let font = Math.FontRegistry.shared.font(named: font.name, size: font.size) + else { + return .zero + } + + var glyph = glyph + var advance = CGSize.zero + + CTFontGetAdvancesForGlyphs(font, .horizontal, &glyph, &advance, 1) + return advance + } +} diff --git a/Sources/SwiftUIMath/Internal/Font/FontRegistry.swift b/Sources/SwiftUIMath/Internal/Font/FontRegistry.swift new file mode 100644 index 0000000..758da31 --- /dev/null +++ b/Sources/SwiftUIMath/Internal/Font/FontRegistry.swift @@ -0,0 +1,114 @@ +@preconcurrency import CoreGraphics +@preconcurrency import CoreText +import Foundation + +extension Math { + final class FontRegistry: Sendable { + static let shared = FontRegistry() + + private struct Cache { + var graphicsFonts: [Font.Name: CGFont] = [:] + var tables: [Font.Name: Table] = [:] + let fonts = NSCache, CTFont>() + } + + private let cache = ReadWriteLockIsolated(Cache()) + + func graphicsFont(named name: Font.Name) -> CGFont? { + cache.withValue { cache in + if let graphicsFont = cache.graphicsFonts[name] { + return graphicsFont + } + + guard let (graphicsFont, _) = registerGraphicsFont(named: name, cache: &cache) else { + return nil + } + + return graphicsFont + } + } + + func table(named name: Font.Name) -> Table? { + cache.withValue { cache in + if let table = cache.tables[name] { + return table + } + + guard let (_, table) = registerGraphicsFont(named: name, cache: &cache) else { + return nil + } + + return table + } + } + + func font(named name: Font.Name, size: CGFloat) -> CTFont? { + cache.withValue { cache in + let key = KeyBox(Font(name: name, size: size)) + + if let font = cache.fonts.object(forKey: key) { + return font + } + + guard + let graphicsFont = cache.graphicsFonts[name] + ?? registerGraphicsFont(named: name, cache: &cache)?.0 + else { + return nil + } + + let font = CTFontCreateWithGraphicsFont(graphicsFont, size, nil, nil) + cache.fonts.setObject(font, forKey: key) + + return font + } + } + + private func registerGraphicsFont( + named name: Font.Name, + cache: inout Cache + ) -> (CGFont, Table)? { + guard let graphicsFont = CGFont.named(name), let table = Table.named(name) else { + return nil + } + + guard CTFontManagerRegisterGraphicsFont(graphicsFont, nil) else { + return nil + } + + cache.graphicsFonts[name] = graphicsFont + cache.tables[name] = table + + return (graphicsFont, table) + } + } +} + +extension CGFont { + fileprivate static func named(_ name: Math.Font.Name) -> CGFont? { + guard + let bundleURL = Bundle.module.url(forResource: "mathFonts", withExtension: "bundle"), + let url = Bundle(url: bundleURL)?.url(forResource: name.rawValue, withExtension: "otf"), + let data = try? Data(contentsOf: url), + let dataProvider = CGDataProvider(data: data as CFData) + else { + return nil + } + + return CGFont(dataProvider) + } +} + +extension Math.Table { + fileprivate static func named(_ name: Math.Font.Name) -> Math.Table? { + guard + let bundleURL = Bundle.module.url(forResource: "mathFonts", withExtension: "bundle"), + let url = Bundle(url: bundleURL)?.url(forResource: name.rawValue, withExtension: "plist"), + let data = try? Data(contentsOf: url) + else { + return nil + } + + return try? PropertyListDecoder().decode(Math.Table.self, from: data) + } +} diff --git a/Sources/SwiftUIMath/Internal/Font/PlatformFont.swift b/Sources/SwiftUIMath/Internal/Font/PlatformFont.swift new file mode 100644 index 0000000..425d145 --- /dev/null +++ b/Sources/SwiftUIMath/Internal/Font/PlatformFont.swift @@ -0,0 +1,35 @@ +@preconcurrency import CoreGraphics +@preconcurrency import CoreText +import Foundation + +extension Math { + final class PlatformFont: Sendable { + let font: Font + let cgFont: CGFont + let ctFont: CTFont + let metrics: FontMetrics + + init?(font: Font) { + guard + let cgFont = FontRegistry.shared.graphicsFont(named: font.name), + let ctFont = FontRegistry.shared.font(named: font.name, size: font.size), + let table = FontRegistry.shared.table(named: font.name) + else { + return nil + } + + self.font = font + self.cgFont = cgFont + self.ctFont = ctFont + self.metrics = FontMetrics( + font: font, + unitsPerEm: UInt(CTFontGetUnitsPerEm(ctFont)), + table: table + ) + } + + func withSize(_ size: CGFloat) -> PlatformFont { + PlatformFont(font: .init(name: font.name, size: size))! + } + } +} diff --git a/Sources/SwiftUIMath/Internal/Font/Table.swift b/Sources/SwiftUIMath/Internal/Font/Table.swift new file mode 100644 index 0000000..f0c70f2 --- /dev/null +++ b/Sources/SwiftUIMath/Internal/Font/Table.swift @@ -0,0 +1,39 @@ +import Foundation + +extension Math { + struct Table: Codable, Sendable { + struct Assembly: Codable, Sendable { + struct Part: Codable, Sendable { + let advance: Int + let endConnector: Int + let extender: Bool + let glyph: String + let startConnector: Int + } + + let italic: Int + let parts: [Part] + } + + private enum CodingKeys: String, CodingKey { + case version + case accents + case constants + case italic + case hVariants = "h_variants" + case vVariants = "v_variants" + case vAssembly = "v_assembly" + } + + let version: String + + let accents: [String: Int] + let constants: [String: Int] + let italic: [String: Int] + + let hVariants: [String: [String]] + let vVariants: [String: [String]] + + let vAssembly: [String: Assembly] + } +} diff --git a/Sources/SwiftUIMath/Internal/Helpers/KeyBox.swift b/Sources/SwiftUIMath/Internal/Helpers/KeyBox.swift new file mode 100644 index 0000000..9890496 --- /dev/null +++ b/Sources/SwiftUIMath/Internal/Helpers/KeyBox.swift @@ -0,0 +1,22 @@ +import Foundation + +final class KeyBox: NSObject { + let wrappedValue: Value + + init(_ wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + override var hash: Int { + var hasher = Hasher() + hasher.combine(wrappedValue) + return hasher.finalize() + } + + override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? KeyBox else { + return false + } + return wrappedValue == other.wrappedValue + } +} diff --git a/Sources/SwiftUIMath/Internal/Helpers/Platform.swift b/Sources/SwiftUIMath/Internal/Helpers/Platform.swift index 3b3f25f..1ba4897 100644 --- a/Sources/SwiftUIMath/Internal/Helpers/Platform.swift +++ b/Sources/SwiftUIMath/Internal/Helpers/Platform.swift @@ -1,5 +1,3 @@ -// Derived from SwiftMath by Mike Griebling (MIT License) - import SwiftUI #if canImport(UIKit) diff --git a/Sources/SwiftUIMath/Internal/Helpers/Unicode.swift b/Sources/SwiftUIMath/Internal/Helpers/Unicode.swift index 087dd64..ae96025 100644 --- a/Sources/SwiftUIMath/Internal/Helpers/Unicode.swift +++ b/Sources/SwiftUIMath/Internal/Helpers/Unicode.swift @@ -1,5 +1,3 @@ -// Derived from SwiftMath by Mike Griebling (MIT License) - import Foundation extension String { diff --git a/Sources/SwiftUIMath/Math.swift b/Sources/SwiftUIMath/Math.swift new file mode 100644 index 0000000..f331b45 --- /dev/null +++ b/Sources/SwiftUIMath/Math.swift @@ -0,0 +1,14 @@ +import SwiftUI + +public struct Math: View { + public init() { + } + + public var body: some View { + Text("TODO: implement") + } +} + +#Preview { + Math() +}