Mac Font Discovery with CoreText (#17)

This implements font discovery so the `--font-family` flag works for macOS. Fonts are looked up using the Core Text API so any installed font on the Mac system can be used. 

We still use FreeType for rendering, and CoreText doesn't _quite_ give us all the information we need to build the exact face in FreeType. So a TODO after this is to now implement glyph _rendering_ using Core Text and Core Graphics. Until then, a couple fonts don't quite work (i.e. Monaco, a big one!) but many do!
This commit is contained in:
Mitchell Hashimoto
2022-10-01 22:21:30 -07:00
committed by GitHub
parent 791739de9c
commit 12c9482d48
5 changed files with 293 additions and 2 deletions

View File

@ -17,6 +17,7 @@ const system_sdk = @import("vendor/mach/libs/glfw/system_sdk.zig");
// Build options, see the build options help for more info. // Build options, see the build options help for more info.
var tracy: bool = false; var tracy: bool = false;
var enable_coretext: bool = false;
var enable_fontconfig: bool = false; var enable_fontconfig: bool = false;
pub fn build(b: *std.build.Builder) !void { pub fn build(b: *std.build.Builder) !void {
@ -38,6 +39,12 @@ pub fn build(b: *std.build.Builder) !void {
"Enable Tracy integration (default true in Debug on Linux)", "Enable Tracy integration (default true in Debug on Linux)",
) orelse (mode == .Debug and target.isLinux()); ) orelse (mode == .Debug and target.isLinux());
enable_coretext = b.option(
bool,
"coretext",
"Enable coretext for font discovery (default true on macOS)",
) orelse target.isDarwin();
enable_fontconfig = b.option( enable_fontconfig = b.option(
bool, bool,
"fontconfig", "fontconfig",
@ -65,6 +72,7 @@ pub fn build(b: *std.build.Builder) !void {
const exe = b.addExecutable("ghostty", "src/main.zig"); const exe = b.addExecutable("ghostty", "src/main.zig");
const exe_options = b.addOptions(); const exe_options = b.addOptions();
exe_options.addOption(bool, "tracy_enabled", tracy); exe_options.addOption(bool, "tracy_enabled", tracy);
exe_options.addOption(bool, "coretext", enable_coretext);
exe_options.addOption(bool, "fontconfig", enable_fontconfig); exe_options.addOption(bool, "fontconfig", enable_fontconfig);
// Exe // Exe

View File

@ -31,6 +31,20 @@ pub const Font = opaque {
@intCast(c_long, chars.len), @intCast(c_long, chars.len),
); );
} }
pub fn copyAttribute(self: *Font, comptime attr: text.FontAttribute) attr.Value() {
return @intToPtr(attr.Value(), @ptrToInt(c.CTFontCopyAttribute(
@ptrCast(c.CTFontRef, self),
@ptrCast(c.CFStringRef, attr.key()),
)));
}
pub fn copyDisplayName(self: *Font) *foundation.String {
return @intToPtr(
*foundation.String,
@ptrToInt(c.CTFontCopyDisplayName(@ptrCast(c.CTFontRef, self))),
);
}
}; };
test { test {

View File

@ -9,6 +9,7 @@ const DeferredFace = @This();
const std = @import("std"); const std = @import("std");
const assert = std.debug.assert; const assert = std.debug.assert;
const fontconfig = @import("fontconfig"); const fontconfig = @import("fontconfig");
const macos = @import("macos");
const options = @import("main.zig").options; const options = @import("main.zig").options;
const Library = @import("main.zig").Library; const Library = @import("main.zig").Library;
const Face = @import("main.zig").Face; const Face = @import("main.zig").Face;
@ -20,6 +21,9 @@ face: ?Face = null,
/// Fontconfig /// Fontconfig
fc: if (options.fontconfig) ?Fontconfig else void = if (options.fontconfig) null else {}, fc: if (options.fontconfig) ?Fontconfig else void = if (options.fontconfig) null else {},
/// CoreText
ct: if (options.coretext) ?CoreText else void = if (options.coretext) null else {},
/// Fontconfig specific data. This is only present if building with fontconfig. /// Fontconfig specific data. This is only present if building with fontconfig.
pub const Fontconfig = struct { pub const Fontconfig = struct {
/// The pattern for this font. This must be the "render prepared" pattern. /// The pattern for this font. This must be the "render prepared" pattern.
@ -38,6 +42,17 @@ pub const Fontconfig = struct {
} }
}; };
/// CoreText specific data. This is only present when building with CoreText.
pub const CoreText = struct {
/// The initialized font
font: *macos.text.Font,
pub fn deinit(self: *CoreText) void {
self.font.release();
self.* = undefined;
}
};
/// Initialize a deferred face that is already pre-loaded. The deferred face /// Initialize a deferred face that is already pre-loaded. The deferred face
/// takes ownership over the loaded face, deinit will deinit the loaded face. /// takes ownership over the loaded face, deinit will deinit the loaded face.
pub fn initLoaded(face: Face) DeferredFace { pub fn initLoaded(face: Face) DeferredFace {
@ -47,6 +62,7 @@ pub fn initLoaded(face: Face) DeferredFace {
pub fn deinit(self: *DeferredFace) void { pub fn deinit(self: *DeferredFace) void {
if (self.face) |*face| face.deinit(); if (self.face) |*face| face.deinit();
if (options.fontconfig) if (self.fc) |*fc| fc.deinit(); if (options.fontconfig) if (self.fc) |*fc| fc.deinit();
if (options.coretext) if (self.ct) |*ct| ct.deinit();
self.* = undefined; self.* = undefined;
} }
@ -63,6 +79,13 @@ pub fn name(self: DeferredFace) ![:0]const u8 {
return (try fc.pattern.get(.fullname, 0)).string; return (try fc.pattern.get(.fullname, 0)).string;
} }
if (options.coretext) {
if (self.ct) |ct| {
const display_name = ct.font.copyDisplayName();
return display_name.cstringPtr(.utf8) orelse "<unsupported internal encoding>";
}
}
return "TODO: built-in font names"; return "TODO: built-in font names";
} }
@ -80,6 +103,11 @@ pub fn load(
return; return;
} }
if (options.coretext) {
try self.loadCoreText(lib, size);
return;
}
// Unreachable because we must be already loaded or have the // Unreachable because we must be already loaded or have the
// proper configuration for one of the other deferred mechanisms. // proper configuration for one of the other deferred mechanisms.
unreachable; unreachable;
@ -100,6 +128,45 @@ fn loadFontconfig(
self.face = try Face.initFile(lib, filename, face_index, size); self.face = try Face.initFile(lib, filename, face_index, size);
} }
fn loadCoreText(
self: *DeferredFace,
lib: Library,
size: Face.DesiredSize,
) !void {
assert(self.face == null);
const ct = self.ct.?;
// Get the URL for the font so we can get the filepath
const url = ct.font.copyAttribute(.url);
defer url.release();
// Get the path from the URL
const path = url.copyPath() orelse return error.FontHasNoFile;
defer path.release();
// URL decode the path
const blank = try macos.foundation.String.createWithBytes("", .utf8, false);
defer blank.release();
const decoded = try macos.foundation.URL.createStringByReplacingPercentEscapes(
path,
blank,
);
defer decoded.release();
// Decode into a c string. 1024 bytes should be enough for anybody.
var buf: [1024]u8 = undefined;
const path_slice = decoded.cstring(buf[0..1023], .utf8) orelse
return error.FontPathCantDecode;
// Freetype requires null-terminated. We always leave space at
// the end for a zero so we set that up here.
buf[path_slice.len] = 0;
// TODO: face index 0 is not correct long term and we should switch
// to using CoreText for rendering, too.
self.face = try Face.initFile(lib, buf[0..path_slice.len :0], 0, size);
}
/// Returns true if this face can satisfy the given codepoint and /// Returns true if this face can satisfy the given codepoint and
/// presentation. If presentation is null, then it just checks if the /// presentation. If presentation is null, then it just checks if the
/// codepoint is present at all. /// codepoint is present at all.
@ -136,6 +203,20 @@ pub fn hasCodepoint(self: DeferredFace, cp: u32, p: ?Presentation) bool {
} }
} }
// If we are using coretext, we check the loaded CT font.
if (options.coretext) {
if (self.ct) |ct| {
// Turn UTF-32 into UTF-16 for CT API
var unichars: [2]u16 = undefined;
const pair = macos.foundation.stringGetSurrogatePairForLongCharacter(cp, &unichars);
const len: usize = if (pair) 2 else 1;
// Get our glyphs
var glyphs = [2]macos.graphics.Glyph{ 0, 0 };
return ct.font.getGlyphsForCharacters(unichars[0..len], glyphs[0..len]);
}
}
// This is unreachable because discovery mechanisms terminate, and // This is unreachable because discovery mechanisms terminate, and
// if we're not using a discovery mechanism, the face MUST be loaded. // if we're not using a discovery mechanism, the face MUST be loaded.
unreachable; unreachable;
@ -186,3 +267,34 @@ test "fontconfig" {
try testing.expect(def.hasCodepoint(' ', null)); try testing.expect(def.hasCodepoint(' ', null));
try testing.expect(def.face.?.glyphIndex(' ') != null); try testing.expect(def.face.?.glyphIndex(' ') != null);
} }
test "coretext" {
if (!options.coretext) return error.SkipZigTest;
const discovery = @import("main.zig").discovery;
const testing = std.testing;
// Load freetype
var lib = try Library.init();
defer lib.deinit();
// Get a deferred face from fontconfig
var def = def: {
var fc = discovery.CoreText.init();
var it = try fc.discover(.{ .family = "Monaco", .size = 12 });
defer it.deinit();
break :def (try it.next()).?;
};
defer def.deinit();
try testing.expect(!def.loaded());
try testing.expect(def.hasCodepoint(' ', null));
// Verify we can get the name
const n = try def.name();
try testing.expect(n.len > 0);
// Load it and verify it works
try def.load(lib, .{ .points = 12 });
try testing.expect(def.hasCodepoint(' ', null));
try testing.expect(def.face.?.glyphIndex(' ') != null);
}

View File

@ -1,13 +1,20 @@
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert; const assert = std.debug.assert;
const fontconfig = @import("fontconfig"); const fontconfig = @import("fontconfig");
const macos = @import("macos");
const options = @import("main.zig").options; const options = @import("main.zig").options;
const DeferredFace = @import("main.zig").DeferredFace; const DeferredFace = @import("main.zig").DeferredFace;
const log = std.log.named(.discovery); const log = std.log.named(.discovery);
/// Discover implementation for the compile options. /// Discover implementation for the compile options.
pub const Discover = if (options.fontconfig) Fontconfig else void; pub const Discover = if (options.fontconfig)
Fontconfig
else if (options.coretext)
CoreText
else
void;
/// Descriptor is used to search for fonts. The only required field /// Descriptor is used to search for fonts. The only required field
/// is "family". The rest are ignored unless they're set to a non-zero /// is "family". The rest are ignored unless they're set to a non-zero
@ -57,6 +64,70 @@ pub const Descriptor = struct {
return pat; return pat;
} }
/// Convert to Core Text font descriptor to use for lookup or
/// conversion to a specific font.
pub fn toCoreTextDescriptor(self: Descriptor) !*macos.text.FontDescriptor {
const attrs = try macos.foundation.MutableDictionary.create(0);
defer attrs.release();
// Family is always set
const family = try macos.foundation.String.createWithBytes(self.family, .utf8, false);
defer family.release();
attrs.setValue(
macos.text.FontAttribute.family_name.key(),
family,
);
// Set our size attribute if set
if (self.size > 0) {
const size32 = @intCast(i32, self.size);
const size = try macos.foundation.Number.create(
.sint32,
&size32,
);
defer size.release();
attrs.setValue(
macos.text.FontAttribute.size.key(),
size,
);
}
// Build our traits. If we set any, then we store it in the attributes
// otherwise we do nothing. We determine this by setting up the packed
// struct, converting to an int, and checking if it is non-zero.
const traits: macos.text.FontSymbolicTraits = .{
.bold = self.bold,
.italic = self.italic,
};
const traits_cval = traits.cval();
if (traits_cval > 0) {
// Setting traits is a pain. We have to create a nested dictionary
// of the symbolic traits value, and set that in our attributes.
const traits_num = try macos.foundation.Number.create(
.sint32,
@ptrCast(*const i32, &traits_cval),
);
defer traits_num.release();
const traits_dict = try macos.foundation.MutableDictionary.create(0);
defer traits_dict.release();
traits_dict.setValue(
macos.text.FontTraitKey.symbolic.key(),
traits_num,
);
attrs.setValue(
macos.text.FontAttribute.traits.key(),
traits_dict,
);
}
return try macos.text.FontDescriptor.createWithAttributes(@ptrCast(
*macos.foundation.Dictionary,
attrs,
));
}
}; };
pub const Fontconfig = struct { pub const Fontconfig = struct {
@ -134,7 +205,74 @@ pub const Fontconfig = struct {
}; };
}; };
test { pub const CoreText = struct {
pub fn init() CoreText {
// Required for the "interface" but does nothing for CoreText.
return .{};
}
pub fn deinit(self: *CoreText) void {
_ = self;
}
/// Discover fonts from a descriptor. This returns an iterator that can
/// be used to build up the deferred fonts.
pub fn discover(self: *const CoreText, desc: Descriptor) !DiscoverIterator {
_ = self;
// Build our pattern that we'll search for
const ct_desc = try desc.toCoreTextDescriptor();
defer ct_desc.release();
// Our descriptors have to be in an array
const desc_arr = try macos.foundation.Array.create(
macos.text.FontDescriptor,
&[_]*const macos.text.FontDescriptor{ct_desc},
);
defer desc_arr.release();
// Build our collection
const set = try macos.text.FontCollection.createWithFontDescriptors(desc_arr);
defer set.release();
const list = set.createMatchingFontDescriptors();
errdefer list.release();
return DiscoverIterator{
.list = list,
.i = 0,
};
}
pub const DiscoverIterator = struct {
list: *macos.foundation.Array,
i: usize,
pub fn deinit(self: *DiscoverIterator) void {
self.list.release();
self.* = undefined;
}
pub fn next(self: *DiscoverIterator) !?DeferredFace {
if (self.i >= self.list.getCount()) return null;
// Create our font. We need a size to initialize it so we use size
// 12 but we will alter the size later.
const desc = self.list.getValueAtIndex(macos.text.FontDescriptor, self.i);
const font = try macos.text.Font.createWithFontDescriptor(desc, 12);
errdefer font.release();
// Increment after we return
defer self.i += 1;
return DeferredFace{
.face = null,
.ct = .{ .font = font },
};
}
};
};
test "fontconfig" {
if (!options.fontconfig) return error.SkipZigTest; if (!options.fontconfig) return error.SkipZigTest;
const testing = std.testing; const testing = std.testing;
@ -146,3 +284,20 @@ test {
try testing.expect(!face.loaded()); try testing.expect(!face.loaded());
} }
} }
test "core text" {
if (!options.coretext) return error.SkipZigTest;
const testing = std.testing;
var ct = CoreText.init();
defer ct.deinit();
var it = try ct.discover(.{ .family = "Monaco", .size = 12 });
defer it.deinit();
var count: usize = 0;
while (try it.next()) |face| {
count += 1;
try testing.expect(!face.loaded());
}
try testing.expect(count > 0);
}

View File

@ -14,8 +14,10 @@ pub const Discover = discovery.Discover;
/// Build options /// Build options
pub const options: struct { pub const options: struct {
coretext: bool = false,
fontconfig: bool = false, fontconfig: bool = false,
} = .{ } = .{
.coretext = build_options.coretext,
.fontconfig = build_options.fontconfig, .fontconfig = build_options.fontconfig,
}; };