diff --git a/Sources/SwiftMath/MathBundle/MathFont.swift b/Sources/SwiftMath/MathBundle/MathFont.swift new file mode 100644 index 0000000..66d4c01 --- /dev/null +++ b/Sources/SwiftMath/MathBundle/MathFont.swift @@ -0,0 +1,145 @@ +// +// File.swift +// +// +// Created by Peter Tang on 10/9/2023. +// + +import Foundation +import CoreText + +public enum MathFont: String, CaseIterable { + + case latinModernFont = "latinmodern-math" + case kpMathLightFont = "KpMath-Light" + case kpMathSansFont = "KpMath-Sans" + case xitsFont = "xits-math" + case termesFont = "texgyretermes-math" + + public func cgFont() -> CGFont? { + BundleManager.manager.obtainCGFont(font: self) + } + public func ctFont(withSize size: CGFloat) -> CTFont? { + BundleManager.manager.obtainCTFont(font: self, withSize: size) + } + internal func mathTable() -> NSDictionary? { + BundleManager.manager.obtainMathTable(font: self) + } + internal func get(nameForGlyph glyph: CGGlyph) -> String { + let name = cgFont()?.name(for: glyph) as? String + return name ?? "" + } + internal func get(glyphWithName name: String) -> CGGlyph? { + cgFont()?.getGlyphWithGlyphName(name: name as CFString) + } +} +internal extension CTFont { + /** The size of this font in points. */ + var fontSize: CGFloat { + CTFontGetSize(self) + } + var unitsPerEm: UInt { + return UInt(CTFontGetUnitsPerEm(self)) + } +} +private class BundleManager { + static fileprivate(set) var manager: BundleManager = { + return BundleManager() + }() + + private var cgFonts = [MathFont: CGFont]() + private var ctFonts = [CTFontPair: CTFont]() + private var mathTables = [MathFont: NSDictionary]() + + private var initializedOnceAlready: Bool = false + + private func registerCGFont(mathFont: MathFont) throws { + guard let frameworkBundleURL = Bundle.module.url(forResource: "mathFonts", withExtension: "bundle"), + let resourceBundleURL = Bundle(url: frameworkBundleURL)?.path(forResource: mathFont.rawValue, ofType: "otf") else { + throw FontError.fontPathNotFound + } + guard let fontData = NSData(contentsOfFile: resourceBundleURL), let dataProvider = CGDataProvider(data: fontData) else { + throw FontError.invalidFontFile + } + guard let defaultCGFont = CGFont(dataProvider) else { + throw FontError.initFontError + } + + cgFonts[mathFont] = defaultCGFont + + var errorRef: Unmanaged? = nil + guard CTFontManagerRegisterGraphicsFont(defaultCGFont, &errorRef) else { + throw FontError.registerFailed + } + print("mathFonts bundle: \(mathFont.rawValue) registered.") + } + + private func registerMathTable(mathFont: MathFont) throws { + guard let frameworkBundleURL = Bundle.module.url(forResource: "mathFonts", withExtension: "bundle"), + let mathTablePlist = Bundle(url: frameworkBundleURL)?.url(forResource: mathFont.rawValue, withExtension:"plist") else { + throw FontError.fontPathNotFound + } + guard let rawMathTable = NSDictionary(contentsOf: mathTablePlist), + let version = rawMathTable["version"] as? String, + version == "1.3" else { + throw FontError.invalidMathTable + } + //FIXME: mathTable = MTFontMathTable(withFont:self, mathTable:rawMathTable) + mathTables[mathFont] = rawMathTable + } + + private func registerAllBundleResources() { + guard !initializedOnceAlready else { return } + MathFont.allCases.forEach { font in + do { + try BundleManager.manager.registerCGFont(mathFont: font) + try BundleManager.manager.registerMathTable(mathFont: font) + } catch { + fatalError("MTMathFonts:\(#function) Couldn't load math fonts \(font.rawValue), reason \(error)") + } + } + initializedOnceAlready.toggle() + } + + fileprivate func obtainCGFont(font: MathFont) -> CGFont? { + if !initializedOnceAlready { registerAllBundleResources() } + return cgFonts[font] + } + + fileprivate func obtainCTFont(font: MathFont, withSize size: CGFloat) -> CTFont? { + if !initializedOnceAlready { registerAllBundleResources() } + let fontPair = CTFontPair(font: font, size: size) + guard let ctFont = ctFonts[fontPair] else { + if let cgFont = cgFonts[font] { + let ctFont = CTFontCreateWithGraphicsFont(cgFont, size, nil, nil) + ctFonts[fontPair] = ctFont + return ctFont + } + return nil + } + return ctFont + } + fileprivate func obtainMathTable(font: MathFont) -> NSDictionary? { + if !initializedOnceAlready { registerAllBundleResources() } + return mathTables[font] + } + deinit { + ctFonts.removeAll() + var errorRef: Unmanaged? = nil + cgFonts.values.forEach { cgFont in + CTFontManagerUnregisterGraphicsFont(cgFont, &errorRef) + } + } + public enum FontError: Error { + case invalidFontFile + case fontPathNotFound + case initFontError + case registerFailed + case invalidMathTable + } + + private struct CTFontPair: Hashable { + let font: MathFont + let size: CGFloat + } +} diff --git a/Sources/SwiftMath/MathBundle/MathTable.swift b/Sources/SwiftMath/MathBundle/MathTable.swift new file mode 100644 index 0000000..0f8a604 --- /dev/null +++ b/Sources/SwiftMath/MathBundle/MathTable.swift @@ -0,0 +1,308 @@ +// +// MathTable.swift +// +// +// Created by Peter Tang on 11/9/2023. +// + +import Foundation +import CoreText + +// struct GlyphPart { +// /// The glyph that represents this part +// var glyph: CGGlyph! +// +// /// Full advance width/height for this part, in the direction of the extension in points. +// var fullAdvance: CGFloat = 0 +// +// /// Advance width/ height of the straight bar connector material at the beginning of the glyph in points. +// var startConnectorLength: CGFloat = 0 +// +// /// Advance width/ height of the straight bar connector material at the end of the glyph in points. +// var endConnectorLength: CGFloat = 0 +// +// /// If this part is an extender. If set, the part can be skipped or repeated. +// var isExtender: Bool = false +// } + +/** This class represents the Math table of an open type font. + + The math table is documented here: https://www.microsoft.com/typography/otspec/math.htm + + How the constants in this class affect the display is documented here: + http://www.tug.org/TUGboat/tb30-1/tb94vieth.pdf + + Note: We don't parse the math table from the open type font. Rather we parse it + in python and convert it to a .plist file which is easily consumed by this class. + This approach is preferable to spending an inordinate amount of time figuring out + how to parse the returned NSData object using the open type rules. + + Remark: This class is not meant to be used outside of this library. + */ +internal struct MathTable { + let kConstants = "constants" + + let font: MathFont + private let unitsPerEm: UInt + private let fontSize: CGFloat + + init(withFont font: MathFont, fontSize: CGFloat, unitsPerEm: UInt) { + self.font = font + self.unitsPerEm = unitsPerEm + self.fontSize = fontSize + } + func fontUnitsToPt(_ fontUnits: Int) -> CGFloat { + CGFloat(fontUnits) * fontSize / CGFloat(unitsPerEm) + } + func constantFromTable(_ constName: String) -> CGFloat { + guard let consts = font.mathTable()?[kConstants] as? NSDictionary, let val = consts[constName] as? NSNumber else { + fatalError("\(#function) unable to extract \(constName) from plist") + } + return fontUnitsToPt(val.intValue) + } + func percentFromTable(_ percentName: String) -> CGFloat { + guard let consts = font.mathTable()?[kConstants] as? NSDictionary, let val = consts[percentName] as? NSNumber else { + fatalError("\(#function) unable to extract \(percentName) from plist") + } + return CGFloat(val.floatValue) / 100 + } + /// Math Font Metrics from the opentype specification + // MARK: - Fractions + var fractionNumeratorDisplayStyleShiftUp:CGFloat { constantFromTable("FractionNumeratorDisplayStyleShiftUp") } // \sigma_8 in TeX + var fractionNumeratorShiftUp:CGFloat { constantFromTable("FractionNumeratorShiftUp") } // \sigma_9 in TeX + var fractionDenominatorDisplayStyleShiftDown:CGFloat { constantFromTable("FractionDenominatorDisplayStyleShiftDown") } // \sigma_11 in TeX + var fractionDenominatorShiftDown:CGFloat { constantFromTable("FractionDenominatorShiftDown") } // \sigma_12 in TeX + var fractionNumeratorDisplayStyleGapMin:CGFloat { constantFromTable("FractionNumDisplayStyleGapMin") } // 3 * \xi_8 in TeX + var fractionNumeratorGapMin:CGFloat { constantFromTable("FractionNumeratorGapMin") } // \xi_8 in TeX + var fractionDenominatorDisplayStyleGapMin:CGFloat { constantFromTable("FractionDenomDisplayStyleGapMin") } // 3 * \xi_8 in TeX + var fractionDenominatorGapMin:CGFloat { constantFromTable("FractionDenominatorGapMin") } // \xi_8 in TeX + var fractionRuleThickness:CGFloat { constantFromTable("FractionRuleThickness") } // \xi_8 in TeX + var skewedFractionHorizonalGap:CGFloat { constantFromTable("SkewedFractionHorizontalGap") } // \sigma_20 in TeX + var skewedFractionVerticalGap:CGFloat { constantFromTable("SkewedFractionVerticalGap") } // \sigma_21 in TeX + + // MARK: - Non-standard + /// FractionDelimiterSize and FractionDelimiterDisplayStyleSize are not constants + /// specified in the OpenType Math specification. Rather these are proposed LuaTeX extensions + /// for the TeX parameters \sigma_20 (delim1) and \sigma_21 (delim2). Since these do not + /// exist in the fonts that we have, we use the same approach as LuaTeX and use the fontSize + /// to determine these values. The constants used are the same as LuaTeX and KaTeX and match the + /// metrics values of the original TeX fonts. + /// Note: An alternative approach is to use DelimitedSubFormulaMinHeight for \sigma21 and use a factor + /// of 2 to get \sigma 20 as proposed in Vieth paper. + /// The XeTeX implementation sets \sigma21 = fontSize and \sigma20 = DelimitedSubFormulaMinHeight which + /// will produce smaller delimiters. + /// Of all the approaches we've implemented LuaTeX's approach since it mimics LaTeX most accurately. + var fractionDelimiterSize: CGFloat { 1.01 * fontSize } + + /// Modified constant from 2.4 to 2.39, it matches KaTeX and looks better. + var fractionDelimiterDisplayStyleSize: CGFloat { 2.39 * fontSize } + + // MARK: - Stacks + var stackTopDisplayStyleShiftUp:CGFloat { constantFromTable("StackTopDisplayStyleShiftUp") } // \sigma_8 in TeX + var stackTopShiftUp:CGFloat { constantFromTable("StackTopShiftUp") } // \sigma_10 in TeX + var stackDisplayStyleGapMin:CGFloat { constantFromTable("StackDisplayStyleGapMin") } // 7 \xi_8 in TeX + var stackGapMin:CGFloat { constantFromTable("StackGapMin") } // 3 \xi_8 in TeX + var stackBottomDisplayStyleShiftDown:CGFloat { constantFromTable("StackBottomDisplayStyleShiftDown") } // \sigma_11 in TeX + var stackBottomShiftDown:CGFloat { constantFromTable("StackBottomShiftDown") } // \sigma_12 in TeX + + var stretchStackBottomShiftDown:CGFloat { constantFromTable("StretchStackBottomShiftDown") } + var stretchStackGapAboveMin:CGFloat { constantFromTable("StretchStackGapAboveMin") } + var stretchStackGapBelowMin:CGFloat { constantFromTable("StretchStackGapBelowMin") } + var stretchStackTopShiftUp:CGFloat { constantFromTable("StretchStackTopShiftUp") } + + // MARK: - super/sub scripts + + var superscriptShiftUp:CGFloat { constantFromTable("SuperscriptShiftUp") } // \sigma_13, \sigma_14 in TeX + var superscriptShiftUpCramped:CGFloat { constantFromTable("SuperscriptShiftUpCramped") } // \sigma_15 in TeX + var subscriptShiftDown:CGFloat { constantFromTable("SubscriptShiftDown") } // \sigma_16, \sigma_17 in TeX + var superscriptBaselineDropMax:CGFloat { constantFromTable("SuperscriptBaselineDropMax") } // \sigma_18 in TeX + var subscriptBaselineDropMin:CGFloat { constantFromTable("SubscriptBaselineDropMin") } // \sigma_19 in TeX + var superscriptBottomMin:CGFloat { constantFromTable("SuperscriptBottomMin") } // 1/4 \sigma_5 in TeX + var subscriptTopMax:CGFloat { constantFromTable("SubscriptTopMax") } // 4/5 \sigma_5 in TeX + var subSuperscriptGapMin:CGFloat { constantFromTable("SubSuperscriptGapMin") } // 4 \xi_8 in TeX + var superscriptBottomMaxWithSubscript:CGFloat { constantFromTable("SuperscriptBottomMaxWithSubscript") } // 4/5 \sigma_5 in TeX + + var spaceAfterScript:CGFloat { constantFromTable("SpaceAfterScript") } + + // MARK: - radicals + var radicalExtraAscender:CGFloat { constantFromTable("RadicalExtraAscender") } // \xi_8 in Tex + var radicalRuleThickness:CGFloat { constantFromTable("RadicalRuleThickness") } // \xi_8 in Tex + var radicalDisplayStyleVerticalGap:CGFloat { constantFromTable("RadicalDisplayStyleVerticalGap") } // \xi_8 + 1/4 \sigma_5 in Tex + var radicalVerticalGap:CGFloat { constantFromTable("RadicalVerticalGap") } // 5/4 \xi_8 in Tex + var radicalKernBeforeDegree:CGFloat { constantFromTable("RadicalKernBeforeDegree") } // 5 mu in Tex + var radicalKernAfterDegree:CGFloat { constantFromTable("RadicalKernAfterDegree") } // -10 mu in Tex + var radicalDegreeBottomRaisePercent:CGFloat { percentFromTable("RadicalDegreeBottomRaisePercent") } // 60% in Tex + + // MARK: - Limits + var upperLimitBaselineRiseMin:CGFloat { constantFromTable("UpperLimitBaselineRiseMin") } // \xi_11 in TeX + var upperLimitGapMin:CGFloat { constantFromTable("UpperLimitGapMin") } // \xi_9 in TeX + var lowerLimitGapMin:CGFloat { constantFromTable("LowerLimitGapMin") } // \xi_10 in TeX + var lowerLimitBaselineDropMin:CGFloat { constantFromTable("LowerLimitBaselineDropMin") } // \xi_12 in TeX + var limitExtraAscenderDescender:CGFloat { 0 } // \xi_13 in TeX, not present in OpenType so we always set it to 0. + + // MARK: - Underline + var underbarVerticalGap:CGFloat { constantFromTable("UnderbarVerticalGap") } // 3 \xi_8 in TeX + var underbarRuleThickness:CGFloat { constantFromTable("UnderbarRuleThickness") } // \xi_8 in TeX + var underbarExtraDescender:CGFloat { constantFromTable("UnderbarExtraDescender") } // \xi_8 in TeX + + // MARK: - Overline + var overbarVerticalGap:CGFloat { constantFromTable("OverbarVerticalGap") } // 3 \xi_8 in TeX + var overbarRuleThickness:CGFloat { constantFromTable("OverbarRuleThickness") } // \xi_8 in TeX + var overbarExtraAscender:CGFloat { constantFromTable("OverbarExtraAscender") } // \xi_8 in TeX + + // MARK: - Constants + + var axisHeight:CGFloat { constantFromTable("AxisHeight") } // \sigma_22 in TeX + var scriptScaleDown:CGFloat { percentFromTable("ScriptPercentScaleDown") } + var scriptScriptScaleDown:CGFloat { percentFromTable("ScriptScriptPercentScaleDown") } + var mathLeading:CGFloat { constantFromTable("MathLeading") } + var delimitedSubFormulaMinHeight:CGFloat { constantFromTable("DelimitedSubFormulaMinHeight") } + + // MARK: - Accent + + var accentBaseHeight:CGFloat { constantFromTable("AccentBaseHeight") } // \fontdimen5 in TeX (x-height) + var flattenedAccentBaseHeight:CGFloat { constantFromTable("FlattenedAccentBaseHeight") } + + + // MARK: - Variants + + let kVertVariants = "v_variants" + let kHorizVariants = "h_variants" + + /** Returns an Array of all the vertical variants of the glyph if any. If + there are no variants for the glyph, the array contains the given glyph. */ + func getVerticalVariantsForGlyph( _ glyph: CGGlyph) -> [NSNumber?] { + guard let variants = font.mathTable()?[kVertVariants] as? NSDictionary else { + fatalError("\(#function) unable to extract \(glyph) from plist") + } + return self.getVariantsForGlyph(glyph, inDictionary: variants) + } + + /** Returns an Array of all the horizontal variants of the glyph if any. If + there are no variants for the glyph, the array contains the given glyph. */ + func getHorizontalVariantsForGlyph( _ glyph: CGGlyph) -> [NSNumber?] { + guard let variants = font.mathTable()?[kHorizVariants] as? NSDictionary else { + fatalError("\(#function) unable to extract \(glyph) from plist") + } + return self.getVariantsForGlyph(glyph, inDictionary:variants) + } + + func getVariantsForGlyph(_ glyph: CGGlyph, inDictionary variants: NSDictionary) -> [NSNumber?] { + let glyphName = font.get(nameForGlyph: glyph) + let variantGlyphs = variants[glyphName] as? NSArray + var glyphArray = [NSNumber]() + if variantGlyphs == nil || variantGlyphs?.count == 0, let glyph = font.get(glyphWithName: glyphName) { + // There are no extra variants, so just add the current glyph to it. + glyphArray.append(NSNumber(value:glyph)) + return glyphArray + } else if let variantGlyphs = variantGlyphs { + for gvn in variantGlyphs { + if let glyphVariantName = gvn as? String, let variantGlyph = font.get(glyphWithName: glyphVariantName) { + glyphArray.append(NSNumber(value:variantGlyph)) + } + } + } + return glyphArray + } + + /** Returns a larger vertical variant of the given glyph if any. + If there is no larger version, this returns the current glyph. + */ + func getLargerGlyph(_ glyph:CGGlyph) -> CGGlyph { + let variants = font.mathTable()?[kVertVariants] as? NSDictionary + let glyphName = font.get(nameForGlyph: glyph) + let variantGlyphs = variants![glyphName] as? NSArray + if variantGlyphs == nil || variantGlyphs?.count == 0 { + // There are no extra variants, so just returnt the current glyph. + return glyph + } + // Find the first variant with a different name. + for gvn in variantGlyphs! { + if let glyphVariantName = gvn as? String, + glyphVariantName != glyphName, + let variantGlyph = font.get(glyphWithName: glyphVariantName) { + return variantGlyph + } + } + // We did not find any variants of this glyph so return it. + return glyph; + } + + // MARK: - Italic Correction + + let kItalic = "italic" + + /** Returns the italic correction for the given glyph if any. If there + isn't any this returns 0. */ + func getItalicCorrection(_ glyph: CGGlyph) -> CGFloat { + let italics = font.mathTable()?[kItalic] as? NSDictionary + let glyphName = font.get(nameForGlyph: glyph) + let val = italics![glyphName] as? NSNumber + // if val is nil, this returns 0. + return fontUnitsToPt(val?.intValue ?? 0) + } + + // MARK: - Accents + + let kAccents = "accents" + + /** Returns the adjustment to the top accent for the given glyph if any. + If there isn't any this returns -1. */ + func getTopAccentAdjustment(_ glyph: CGGlyph) -> CGFloat { + var glyph = glyph + let accents = font.mathTable()?[kAccents] as? NSDictionary + let glyphName = font.get(nameForGlyph: glyph) + let val = accents![glyphName] as? NSNumber + if let val = val { + return self.fontUnitsToPt(val.intValue) + } else { + // If no top accent is defined then it is the center of the advance width. + var advances = CGSize.zero + guard let ctFont = font.ctFont(withSize: fontSize) else { + fatalError("\(#function) unable to obtain ctFont \(font.rawValue) with size \(fontSize)") + } + CTFontGetAdvancesForGlyphs(ctFont, .horizontal, &glyph, &advances, 1) + return advances.width/2 + } + } + + // MARK: - Glyph Construction + + /** Minimum overlap of connecting glyphs during glyph construction */ + var minConnectorOverlap:CGFloat { constantFromTable("MinConnectorOverlap") } + + let kVertAssembly = "v_assembly" + let kAssemblyParts = "parts" + + /** Returns an array of the glyph parts to be used for constructing vertical variants + of this glyph. If there is no glyph assembly defined, returns an empty array. */ + func getVerticalGlyphAssembly(forGlyph glyph:CGGlyph) -> [GlyphPart] { + let assemblyTable = font.mathTable()?[kVertAssembly] as? NSDictionary + let glyphName = font.get(nameForGlyph: glyph) + guard let assemblyInfo = assemblyTable?[glyphName] as? NSDictionary, + let parts = assemblyInfo[kAssemblyParts] as? NSArray else { + // No vertical assembly defined for glyph + // parts should always have been defined, but if it isn't return nil + return [] + } + var rv = [GlyphPart]() + for part in parts { + let partInfo = part as? NSDictionary + var part = GlyphPart() + if let adv = partInfo?["advance"] as? NSNumber, + let end = partInfo?["endConnector"] as? NSNumber, + let start = partInfo?["startConnector"] as? NSNumber, + let ext = partInfo?["extender"] as? NSNumber, + let glyphName = partInfo?["glyph"] as? String { + part.fullAdvance = fontUnitsToPt(adv.intValue) + part.endConnectorLength = fontUnitsToPt(end.intValue) + part.startConnectorLength = fontUnitsToPt(start.intValue) + part.isExtender = ext.boolValue + part.glyph = font.get(glyphWithName: glyphName) + rv.append(part) + } + } + return rv + } + +} diff --git a/Tests/SwiftMathTests/MathFontTests.swift b/Tests/SwiftMathTests/MathFontTests.swift new file mode 100644 index 0000000..65ddeac --- /dev/null +++ b/Tests/SwiftMathTests/MathFontTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import SwiftMath + +// +// MathFontTests.swift +// +// +// Created by Peter Tang on 12/9/2023. +// + +final class MathFontTests: XCTestCase { + func testMathFontScript() throws { + // for family in UIFont.familyNames.sorted() { + // let names = UIFont.fontNames(forFamilyName: family) + // print("Family: \(family) Font names: \(names)") + // } + let size = Int.random(in: 20 ... 40) + MathFont.allCases.forEach { + // print("\(#function) cgfont \($0.cgFont())") + // print("\(#function) ctfont \($0.ctFont(withSize: CGFloat(size)))") + XCTAssertNotNil($0.cgFont()) + XCTAssertNotNil($0.ctFont(withSize: CGFloat(size))) + XCTAssertEqual($0.ctFont(withSize: CGFloat(size))?.fontSize, CGFloat(size), "ctFont fontSize test") + } + } +} diff --git a/Tests/SwiftMathTests/MathTableTests.swift b/Tests/SwiftMathTests/MathTableTests.swift new file mode 100644 index 0000000..21798b8 --- /dev/null +++ b/Tests/SwiftMathTests/MathTableTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import SwiftMath + +// +// MathTableTests.swift +// +// +// Created by Peter Tang on 12/9/2023. +// + +final class MathTableTests: XCTestCase { + func testMathFontScript() throws { + let size = Int.random(in: 20 ... 40) + MathFont.allCases.forEach { + // print("\(#function) cgfont \($0.cgFont())") + // print("\(#function) ctfont \($0.ctFont(withSize: CGFloat(size)))") + // XCTAssertNotNil($0.cgFont()) + // XCTAssertNotNil($0.ctFont(withSize: CGFloat(size))) + // XCTAssertEqual($0.ctFont(withSize: CGFloat(size))?.fontSize, CGFloat(size), "ctFont fontSize test") + let ctFont = $0.ctFont(withSize: CGFloat(size)) + if let unitsPerEm = ctFont?.unitsPerEm { + let mathTable = MathTable(withFont: $0, fontSize: CGFloat(size), unitsPerEm: unitsPerEm) + + let values = [ + mathTable.fractionNumeratorDisplayStyleShiftUp, + mathTable.fractionNumeratorShiftUp, + mathTable.fractionDenominatorDisplayStyleShiftDown, + mathTable.fractionDenominatorShiftDown, + mathTable.fractionNumeratorDisplayStyleGapMin, + mathTable.fractionNumeratorGapMin, + ] + print("\(ctFont) -> \(values)") + } + } + } +}