diff --git a/Sources/SwiftMath/MathBundle/MTFontMathTableV2.swift b/Sources/SwiftMath/MathBundle/MTFontMathTableV2.swift index 6e580bc..d15cb09 100644 --- a/Sources/SwiftMath/MathBundle/MTFontMathTableV2.swift +++ b/Sources/SwiftMath/MathBundle/MTFontMathTableV2.swift @@ -1,6 +1,6 @@ // // MTFontMathTableV2.swift -// +// // // Created by Peter Tang on 15/9/2023. // @@ -14,11 +14,11 @@ internal class MTFontMathTableV2: MTFontMathTable { private let fontSize: CGFloat private let unitsPerEm: UInt private let mTable: NSDictionary - init(mathFont: MathFont, size: CGFloat) { + init(mathFont: MathFont, size: CGFloat, unitsPerEm: UInt) { self.mathFont = mathFont self.fontSize = size - mTable = mathFont.mathTable() - unitsPerEm = mathFont.ctFont(withSize: fontSize).unitsPerEm + self.unitsPerEm = unitsPerEm + mTable = mathFont.rawMathTable() super.init(withFont: mathFont.mtfont(size: fontSize), mathTable: mTable) super._mathTable = nil // disable all possible access to _mathTable in superclass! diff --git a/Sources/SwiftMath/MathBundle/MTFontV2.swift b/Sources/SwiftMath/MathBundle/MTFontV2.swift index 53b7fe3..14c09a0 100644 --- a/Sources/SwiftMath/MathBundle/MTFontV2.swift +++ b/Sources/SwiftMath/MathBundle/MTFontV2.swift @@ -1,6 +1,6 @@ // // MTFontV2.swift -// +// // // Created by Peter Tang on 15/9/2023. // @@ -17,17 +17,18 @@ extension MathFont { public final class MTFontV2: MTFont { let font: MathFont let size: CGFloat - private lazy var _cgFont: CGFont = { - font.cgFont() - }() - private lazy var _ctFont: CTFont = { - font.ctFont(withSize: size) - }() - private lazy var _mathTab = MTFontMathTableV2(mathFont: font, size: size) + private let _cgFont: CGFont + private let _ctFont: CTFont + private let unitsPerEm: UInt + private var _mathTab: MTFontMathTableV2? init(font: MathFont = .latinModernFont, size: CGFloat) { self.font = font self.size = size - + // MathFont cgfont and ctfont are fast & threadsafe, keep a local copy is cheaper than + // handling via NSLock + self._cgFont = font.cgFont() + self._ctFont = font.ctFont(withSize: size) + self.unitsPerEm = self._ctFont.unitsPerEm super.init() super.defaultCGFont = nil @@ -43,9 +44,19 @@ public final class MTFontV2: MTFont { set { fatalError("\(#function): change to \(font.fontName) not allowed.") } get { _ctFont } } + private let mtfontV2LockOnMathTable = NSLock() override var mathTable: MTFontMathTable? { set { fatalError("\(#function): change to \(font.rawValue) not allowed.") } - get { _mathTab } + get { + guard _mathTab == nil else { return _mathTab } + //Note: lazy _mathTab initialization is now threadsafe. + mtfontV2LockOnMathTable.lock() + defer { mtfontV2LockOnMathTable.unlock() } + if _mathTab == nil { + _mathTab = MTFontMathTableV2(mathFont: font, size: size, unitsPerEm: unitsPerEm) + } + return _mathTab + } } override var rawMathTable: NSDictionary? { set { fatalError("\(#function): change to \(font.rawValue) not allowed.") } diff --git a/Sources/SwiftMath/MathBundle/MathFont.swift b/Sources/SwiftMath/MathBundle/MathFont.swift index 4c8a1b6..69cc63a 100644 --- a/Sources/SwiftMath/MathBundle/MathFont.swift +++ b/Sources/SwiftMath/MathBundle/MathFont.swift @@ -1,6 +1,6 @@ // // MathFont.swift -// +// // // Created by Peter Tang on 10/9/2023. // @@ -43,20 +43,21 @@ public enum MathFont: String, CaseIterable { public func ctFont(withSize size: CGFloat) -> CTFont { BundleManager.manager.obtainCTFont(font: self, withSize: size) } - #if os(iOS) - public func uiFont(withSize size: CGFloat) -> UIFont? { - UIFont(name: fontName, size: size) - } - #endif - #if os(macOS) - public func nsFont(withSize size: CGFloat) -> NSFont? { - NSFont(name: fontName, size: size) - } - #endif - internal func mathTable() -> NSDictionary { - BundleManager.manager.obtainMathTable(font: self) + internal func rawMathTable() -> NSDictionary { + BundleManager.manager.obtainRawMathTable(font: self) } + //Note: Below code are no longer supported as UIFont/NSFont are not threadsafe and not used in SwiftMath. + // #if os(iOS) + // public func uiFont(withSize size: CGFloat) -> UIFont? { + // UIFont(name: fontName, size: size) + // } + // #endif + // #if os(macOS) + // public func nsFont(withSize size: CGFloat) -> NSFont? { + // NSFont(name: fontName, size: size) + // } + // #endif } internal extension CTFont { /** The size of this font in points. */ @@ -67,16 +68,15 @@ internal extension CTFont { return UInt(CTFontGetUnitsPerEm(self)) } } -private class BundleManager { - static fileprivate(set) var manager: BundleManager = { - return BundleManager() - }() +internal class BundleManager { + //Note: below should be lightweight and without threadsafe problem. + static internal let manager = BundleManager() private var cgFonts = [MathFont: CGFont]() - private var ctFonts = [CTFontPair: CTFont]() - private var mathTables = [MathFont: NSDictionary]() + private var ctFonts = [CTFontSizePair: CTFont]() + private var rawMathTables = [MathFont: NSDictionary]() - private var initializedOnceAlready: Bool = false + private let threadSafeQueue = DispatchQueue(label: "com.smartmath.mathfont.threadsafequeue", attributes: .concurrent) private func registerCGFont(mathFont: MathFont) throws { guard let frameworkBundleURL = Bundle.module.url(forResource: "mathFonts", withExtension: "bundle"), @@ -89,14 +89,21 @@ private class BundleManager { guard let defaultCGFont = CGFont(dataProvider) else { throw FontError.initFontError } + threadSafeQueue.sync(flags: .barrier) { + cgFonts[mathFont] = defaultCGFont + } - cgFonts[mathFont] = defaultCGFont - + /// This does not load the complete math font, it only has about half the glyphs of the full math font. + /// In particular it does not have the math italic characters which breaks our variable rendering. + /// So we first load a CGFont from the file and then convert it to a CTFont. var errorRef: Unmanaged? = nil guard CTFontManagerRegisterGraphicsFont(defaultCGFont, &errorRef) else { throw FontError.registerFailed } - debugPrint("mathFonts bundle resource: \(mathFont.rawValue), font: \(defaultCGFont.fullName!) registered.") + let postsript = (defaultCGFont.postScriptName as? String) ?? "" + let cgfontName = (defaultCGFont.fullName as? String) ?? "" + let threadName = Thread.isMainThread ? "main" : "global" + debugPrint("mathFonts bundle resource: \(mathFont.rawValue), font: \(cgfontName), ps: \(postsript) registered on \(threadName).") } private func registerMathTable(mathFont: MathFont) throws { @@ -109,25 +116,16 @@ private class BundleManager { version == "1.3" else { throw FontError.invalidMathTable } - mathTables[mathFont] = rawMathTable - debugPrint("mathFonts bundle resource: \(mathFont.rawValue).plist registered.") - } - - 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 mathFont resource \(font.rawValue), reason \(error)") - } + threadSafeQueue.sync(flags: .barrier) { + rawMathTables[mathFont] = rawMathTable } - initializedOnceAlready.toggle() + let threadName = Thread.isMainThread ? "main" : "global" + debugPrint("mathFonts bundle resource: \(mathFont.rawValue).plist registered on \(threadName).") } private func onDemandRegistration(mathFont: MathFont) { - guard cgFonts[mathFont] == nil else { return } + guard threadSafeQueue.sync(execute: { cgFonts[mathFont] }) == nil else { return } + //Note: font registration is now threadsafe. do { try BundleManager.manager.registerCGFont(mathFont: mathFont) try BundleManager.manager.registerMathTable(mathFont: mathFont) @@ -139,7 +137,7 @@ private class BundleManager { fileprivate func obtainCGFont(font: MathFont) -> CGFont { // if !initializedOnceAlready { registerAllBundleResources() } onDemandRegistration(mathFont: font) - guard let cgFont = cgFonts[font] else { + guard let cgFont = threadSafeQueue.sync(execute: { cgFonts[font] }) else { fatalError("\(#function) unable to locate CGFont \(font.fontName)") } return cgFont @@ -148,21 +146,24 @@ private class BundleManager { fileprivate func obtainCTFont(font: MathFont, withSize size: CGFloat) -> CTFont { // if !initializedOnceAlready { registerAllBundleResources() } onDemandRegistration(mathFont: font) - 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 - } - fatalError("\(#function) unable to locate CGFont \(font.fontName), nor create CTFont") + let fontSizePair = CTFontSizePair(font: font, size: size) + let ctFont = threadSafeQueue.sync(execute: { ctFonts[fontSizePair] }) + guard ctFont == nil else { return ctFont! } + guard let cgFont = threadSafeQueue.sync(execute: { cgFonts[font] }) else { + fatalError("\(#function) unable to locate CGFont \(font.fontName) to create CTFont") } - return ctFont + //Note: ctfont creation and caching is now threadsafe. + guard threadSafeQueue.sync(execute: { ctFonts[fontSizePair] }) == nil else { return ctFonts[fontSizePair]! } + let newCTFont = CTFontCreateWithGraphicsFont(cgFont, size, nil, nil) + threadSafeQueue.sync(flags: .barrier) { + ctFonts[fontSizePair] = newCTFont + } + return newCTFont } - fileprivate func obtainMathTable(font: MathFont) -> NSDictionary { + fileprivate func obtainRawMathTable(font: MathFont) -> NSDictionary { // if !initializedOnceAlready { registerAllBundleResources() } onDemandRegistration(mathFont: font) - guard let mathTable = mathTables[font] else { + guard let mathTable = threadSafeQueue.sync(execute: { rawMathTables[font] } ) else { fatalError("\(#function) unable to locate mathTable: \(font.rawValue).plist") } return mathTable @@ -183,7 +184,7 @@ private class BundleManager { case invalidMathTable } - private struct CTFontPair: Hashable { + private struct CTFontSizePair: Hashable { let font: MathFont let size: CGFloat } diff --git a/Tests/SwiftMathTests/MTFontMathTableV2Tests.swift b/Tests/SwiftMathTests/MTFontMathTableV2Tests.swift index 0e2da91..e6bd154 100644 --- a/Tests/SwiftMathTests/MTFontMathTableV2Tests.swift +++ b/Tests/SwiftMathTests/MTFontMathTableV2Tests.swift @@ -1,6 +1,6 @@ // // MTFontMathTableV2Tests.swift -// +// // // Created by Peter Tang on 15/9/2023. // @@ -25,4 +25,57 @@ final class MTFontMathTableV2Tests: XCTestCase { print("\($0.rawValue).plist: \(values)") } } + private let executionQueue = DispatchQueue(label: "com.swiftmath.mathbundle", attributes: .concurrent) + private let executionGroup = DispatchGroup() + let totalCases = 1000 + var testCount = 0 + func testConcurrentThreadsafeScript() throws { + testCount = 0 + var mathFont: MathFont { .allCases.randomElement()! } + var size: CGFloat { CGFloat.random(in: 20 ... 40) } + let mtfonts = Array( 0 ..< 10 ).map { _ in mathFont.mtfont(size: size) } + for caseNumber in 0 ..< totalCases { + helperConcurrentMTFontMathTableV2(caseNumber, mtfont: mtfonts.randomElement()!, in: executionGroup, on: executionQueue) + } + executionGroup.notify(queue: .main) { [weak self] in + guard let self = self else { return } + XCTAssertEqual(self.testCount, totalCases) + print("\(self.testCount) completed =================") + } + } + func helperConcurrentMTFontMathTableV2(_ count: Int, mtfont: MTFontV2, in group: DispatchGroup, on queue: DispatchQueue) { + let workitem = DispatchWorkItem { + let mTable = mtfont.mathTable + let values = [ + mTable?.fractionNumeratorDisplayStyleShiftUp, + mTable?.fractionNumeratorShiftUp, + mTable?.fractionDenominatorDisplayStyleShiftDown, + mTable?.fractionDenominatorShiftDown, + mTable?.fractionNumeratorDisplayStyleGapMin, + mTable?.fractionNumeratorGapMin, + ].compactMap{$0} + if count % 50 == 0 { + print(values) // accessed these values on global thread. + } + XCTAssertNotNil(mTable) + } + workitem.notify(queue: .main) { [weak self] in + // print("\(Thread.isMainThread ? "main" : "global") completed .....") + let mTable = mtfont.mathTable + if count % 70 == 0 { + let values = [ + mTable?.fractionNumeratorDisplayStyleShiftUp, + mTable?.fractionNumeratorShiftUp, + mTable?.fractionDenominatorDisplayStyleShiftDown, + mTable?.fractionDenominatorShiftDown, + mTable?.fractionNumeratorDisplayStyleGapMin, + mTable?.fractionNumeratorGapMin, + ].compactMap{$0} + print(values) // accessed these values on main thread. + } + self?.testCount += 1 + } + queue.async(group: group, execute: workitem) + } + } diff --git a/Tests/SwiftMathTests/MTFontV2Tests.swift b/Tests/SwiftMathTests/MTFontV2Tests.swift index 37c97ef..103a5a9 100644 --- a/Tests/SwiftMathTests/MTFontV2Tests.swift +++ b/Tests/SwiftMathTests/MTFontV2Tests.swift @@ -1,6 +1,6 @@ // // MTFontV2Tests.swift -// +// // // Created by Peter Tang on 15/9/2023. // @@ -18,4 +18,69 @@ final class MTFontV2Tests: XCTestCase { XCTAssertNotNil(mTable) } } + private let executionQueue = DispatchQueue(label: "com.swiftmath.mathbundle", attributes: .concurrent) + private let executionGroup = DispatchGroup() + let totalCases = 1000 + var testCount = 0 + func testConcurrentThreadsafeScript() throws { + testCount = 0 + var mathFont: MathFont { .allCases.randomElement()! } + for caseNumber in 0 ..< totalCases { + helperConcurrentMTFontV2(caseNumber, mathFont: mathFont, in: executionGroup, on: executionQueue) + } + executionGroup.notify(queue: .main) { [weak self] in + guard let self = self else { return } + XCTAssertEqual(self.testCount, totalCases) + print("\(self.testCount) completed =================") + } + } + func helperConcurrentMTFontV2(_ count: Int, mathFont: MathFont, in group: DispatchGroup, on queue: DispatchQueue) { + let size = CGFloat.random(in: 20 ... 40) + let workitem = DispatchWorkItem { + let fontV2 = mathFont.mtfont(size: size) + XCTAssertNotNil(fontV2) + let (cgfont, ctfont) = (fontV2.defaultCGFont, fontV2.ctFont) + XCTAssertNotNil(cgfont) + XCTAssertNotNil(ctfont) + } + workitem.notify(queue: .main) { [weak self] in + // print("\(Thread.isMainThread ? "main" : "global") completed .....") + let fontV2 = mathFont.mtfont(size: size) + XCTAssertNotNil(fontV2) + let (cgfont, ctfont) = (fontV2.defaultCGFont, fontV2.ctFont) + XCTAssertNotNil(cgfont) + XCTAssertNotNil(ctfont) + let mTable = mathFont.rawMathTable() + XCTAssertNotNil(mTable) + self?.testCount += 1 + } + queue.async(group: group, execute: workitem) + } + func testConcurrentThreadsafeMathTableLockScript() throws { + testCount = 0 + var mathFont: MathFont { .allCases.randomElement()! } + var size: CGFloat { CGFloat.random(in: 20 ... 40) } + let mtfonts = Array( 0 ..< 5 ).map { _ in mathFont.mtfont(size: size) } + for caseNumber in 0 ..< totalCases { + helperConcurrentMTFontV2MathTableLock(caseNumber, mtfont: mtfonts.randomElement()!, in: executionGroup, on: executionQueue) + } + executionGroup.notify(queue: .main) { [weak self] in + guard let self = self else { return } + XCTAssertEqual(self.testCount, totalCases) + print("\(self.testCount) completed =================") + } + } + func helperConcurrentMTFontV2MathTableLock(_ count: Int, mtfont: MTFontV2, in group: DispatchGroup, on queue: DispatchQueue) { + let workitem = DispatchWorkItem { + let mathTable = mtfont.mathTable as? MTFontMathTableV2 + // each mathTable is initialized once per mtfont with a NSLock. + // this is even when mathTable is accessed via different threads. + XCTAssertNotNil(mathTable) + } + workitem.notify(queue: .main) { [weak self] in + // print("\(Thread.isMainThread ? "main" : "global") completed .....") + self?.testCount += 1 + } + queue.async(group: group, execute: workitem) + } } diff --git a/Tests/SwiftMathTests/MathFontTests.swift b/Tests/SwiftMathTests/MathFontTests.swift index ed2f441..a73b55c 100644 --- a/Tests/SwiftMathTests/MathFontTests.swift +++ b/Tests/SwiftMathTests/MathFontTests.swift @@ -3,7 +3,7 @@ import XCTest // // MathFontTests.swift -// +// // // Created by Peter Tang on 12/9/2023. // @@ -16,7 +16,10 @@ final class MathFontTests: XCTestCase { // 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") + XCTAssertEqual($0.ctFont(withSize: CGFloat(size)).fontSize, CGFloat(size), "ctFont fontSize != size.") + XCTAssertEqual($0.cgFont().postScriptName as? String, $0.fontName, "postscript Name != UIFont fontName") + // XCTAssertEqual($0.uiFont(withSize: CGFloat(size))?.familyName, $0.fontFamilyName, "uifont familyName != familyName.") + XCTAssertEqual(CTFontCopyFamilyName($0.ctFont(withSize: CGFloat(size))) as String, $0.fontFamilyName, "ctfont.family != familyName") } #if os(iOS) // for family in UIFont.familyNames.sorted() { @@ -50,4 +53,95 @@ final class MathFontTests: XCTestCase { var fontFamilyNames: [String] { MathFont.allCases.map { $0.fontFamilyName } } + + private let executionQueue = DispatchQueue(label: "com.swiftmath.mathbundle", attributes: .concurrent) + private let executionGroup = DispatchGroup() + + let totalCases = 5000 + var testCount = 0 + func testConcurrentThreadsafeScript() throws { + var mathFont: MathFont { .allCases.randomElement()! } + for caseNumber in 0 ..< totalCases { + switch caseNumber % 3 { + case 0: + helperConcurrentCGFont(caseNumber, mathFont: mathFont, in: executionGroup, on: executionQueue) + case 1: + helperConcurrentCTFont(caseNumber, mathFont: mathFont, in: executionGroup, on: executionQueue) + case 2: + helperConcurrentMathTable(caseNumber, mathFont: mathFont, in: executionGroup, on: executionQueue) + default: + continue + } + } + executionGroup.notify(queue: .main) { [weak self] in + guard let self = self else { return } + XCTAssertEqual(self.testCount, totalCases) + print("\(self.testCount) completed =================") + } + } + // func helperConcurrentOnDemandRegistration(_ count: Int, mathFont: MathFont, in group: DispatchGroup, on queue: DispatchQueue) { + // let workitem = DispatchWorkItem { + // BundleManager.manager.onDemandRegistration(mathFont: mathFont) + // } + // workitem.notify(queue: .main) { [weak self] in + // self?.testCount += 1 + // } + // queue.async(group: group, execute: workitem) + // } + // func helperConcurrentBundleRegistration(mathFont: MathFont, in group: DispatchGroup, on queue: DispatchQueue) { + // let workitem = DispatchWorkItem { + // // BundleManager.manager.onDemandRegistration(mathFont: mathFont) + // try? BundleManager.manager.registerCGFont(mathFont: mathFont) + // try? BundleManager.manager.registerMathTable(mathFont: mathFont) + // let font = BundleManager.manager.cgFonts[mathFont] + // XCTAssertNotNil(font, "font != nil") + // } + // workitem.notify(queue: .main) { [weak self] in + // // print("\(Thread.isMainThread ? "main" : "global") completed .....") + // let font = mathFont.cgFont() + // XCTAssertNotNil(font, "font != nil") + // self?.testCount += 1 + // } + // queue.async(group: group, execute: workitem) + // } + func helperConcurrentCGFont(_ count: Int, mathFont: MathFont, in group: DispatchGroup, on queue: DispatchQueue) { + let workitem = DispatchWorkItem { + let font = mathFont.cgFont() + XCTAssertNotNil(font, "font != nil") + } + workitem.notify(queue: .main) { [weak self] in + // print("\(Thread.isMainThread ? "main" : "global") completed .....") + let font = mathFont.cgFont() + XCTAssertNotNil(font, "font != nil") + self?.testCount += 1 + } + queue.async(group: group, execute: workitem) + } + func helperConcurrentCTFont(_ count: Int, mathFont: MathFont, in group: DispatchGroup, on queue: DispatchQueue) { + let size = CGFloat.random(in: 20 ... 40) + let workitem = DispatchWorkItem { + let font = mathFont.ctFont(withSize: size) + XCTAssertNotNil(font, "font != nil") + } + workitem.notify(queue: .main) { [weak self] in + // print("\(Thread.isMainThread ? "main" : "global") completed .....") + let font = mathFont.ctFont(withSize: size) + XCTAssertNotNil(font, "font != nil") + self?.testCount += 1 + } + queue.async(group: group, execute: workitem) + } + func helperConcurrentMathTable(_ count: Int, mathFont: MathFont, in group: DispatchGroup, on queue: DispatchQueue) { + let workitem = DispatchWorkItem { + let mathtable = mathFont.rawMathTable() + XCTAssertNotNil(mathtable, "mathTable != nil") + } + workitem.notify(queue: .main) { [weak self] in + // print("\(Thread.isMainThread ? "main" : "global") completed .....") + let mathtable = mathFont.rawMathTable() + XCTAssertNotNil(mathtable, "mathTable != nil") + self?.testCount += 1 + } + queue.async(group: group, execute: workitem) + } }