Ship Custom Fonts within a Swift Package

I found out that you can bundle TrueType Font files (.ttf) with Swift Packages just fine. It’s not as declarative as adding Info.plist entries to your app, but the code is very simple.

First, add fonts as managed resources under e.g. Sources/PACKAGENAME/Resources/Fonts/ (last subdirectory is optional):

// ...
targets: [
    .target(
        name: "MyLibrary",
        resources: [
            .copy("Resources/Fonts/MyFont.ttf"),
        ],
    ),
],
// ...

Then use Bundle.module to access these resources. I went with a switch to support both command-line builds and Xcode-managed app targets, to get to the resource URL’s.

With these, you can tell the low-level Core Text API to make the font files available for use. The Core Text Font Manager is responsible for knowing which font resource is available in your app, and you can tell it to add locally bundled fonts to the stack:

func registerFontsFromBundle(named names: [String]) {
    let bundle: Bundle = {
        #if SWIFT_PACKAGE
        return Bundle.module
        #else
        return Bundle.main
        #endif
    }()
    let fontURLs = names
        .compactMap { bundle.url(forResource: $0, withExtension: "ttf") }
    
    CTFontManagerRegisterFontURLs(fontURLs as CFArray, .process, true) { errors, done in
        // CTFontManager.h points out that the CFArray, if not empty, contains CFError values.
        let errors = errors as! [CFError]
        guard errors.isEmpty else {
            preconditionFailure("Registering font failed: \(errors.map(\.localizedDescription))")
        }
        return true  // true: should continue; false: should stop
    }
}

Usage

Call this early in the app lifecycle, e.g. NSApplicationDelegate.applicationDidFinishLaunching(_:).

Afterwards, you can use the fonts like you would use system fonts or fonts bundled with app targets:

let myFont = NSFont(named: "MyFont")

In SwiftUI, your App type’s .init works (or the application delegate if you have any):

// Consider an extension or other means to load and group your fonts.
let myFont = SwiftUI.Font(CTFont("MyFont" as CFString, size: 20))

@main
struct FontTestApp: App {
    init() {
        // Call as early as possible:
        registerFontsFromBundle(named: ["MyFont"])
    }

    var body: some Scene {
        WindowGroup {
            VStack {
                Text("Hello, world!")
                    .font(myFont)
            }.padding()
        }
    }
}

Works on macOS and iOS. And you see – you have to drop to Core Text for CTFont anyway there.

Even SwiftUI Previews can access this, by the way!

Error Handling

Note that I crash the app with a preconditionFailure on error.

Trying to register the same font twice would be an error you can easily reproduce. You may not want to crash the app there, but when I do ship fonts, I expect them to work and to use them, so crashing if the resource is not available is a sensible marker of a programmer error.

You might want to forward errors instead, but that would require a callback/closure because you can’t throw from within the CTFontManagerRegisterFontURLs callback.

Other Details

CTFontManagerRegisterFontURLs is the more modern Core Text API to register multiple fonts programmatically at once.

The .proccess scope tells the font manager to make the fonts available for your app’s current process, and not e.g. for the user’s current session (where fonts would be available until they log out). In most cases this is likely what you would expect to happen.

The code is based on this Gist and the similar answer on the Apple Dev forums, but using font URL arrays instead of manually getting from URL to CGDataProvider to CGFont to CTFontManagerRegisterGraphicsFont for each of the fonts. I went with the linked approaches to try everything and then explored other API in the Core Text framework (aka I used Xcode auto-completion on “CTFontManagerRegister”).

The older CTFontManagerUnregisterFontsForURLs works just as well but returns an error array pointer instead of passing errors by value to a closure. Use the modern API to avoid the manual memory management.