font: add Windows font discovery backend (#12386)

Adds a FreeType-based `Discover` implementation for Windows. It walks
the system font directory (`%SYSTEMROOT%\Fonts`) and the per-user
directory (`%LOCALAPPDATA%\Microsoft\Windows\Fonts`), matches
descriptors by FreeType `family_name` (falling back to the SFNT name
table), and, when a codepoint is in the descriptor, filters on CMap
coverage.

Wired up as a new `.freetype_windows` backend which `Backend.default()`
now returns on Windows. Existing freetype-only paths are untouched and
no other platform is affected; cross-platform switches were extended to
handle the new enum value the same way they handle `.freetype`.

With this in place, the standard code paths (`+list-fonts`,
`SharedGridSet` font-family lookup, `CodepointResolver` fallback) work
on Windows without any `os.tag == .windows` branches in the caller.

Verified by the `build-libghostty-windows-gnu` CI job. No runtime binary
ships yet on Windows (no apprt), but this is a drop-in for the discovery
API that the Win32 apprt (and the revisited `+list-fonts` PR #12384)
will use. Once this lands, #12384 can be closed and `+list-fonts` will
work on Windows through the ordinary discovery code path, which
addresses the review feedback that `+list-fonts` should only show fonts
the internal discovery can find.

---

AI usage disclosure: developed with Claude Code (Claude Opus 4.7).
Claude drafted the implementation based on my design direction -- I
picked the "add a Discover backend" shape over the ad-hoc approach in
the earlier `+list-fonts` PR. I reviewed each diff and validated it with
a Windows GNU-ABI smoke build before pushing.

Part of the Win32 apprt upstreaming series (see discussion #2563 /
mattn/ghostty#1).
This commit is contained in:
Mitchell Hashimoto
2026-04-23 10:45:50 -07:00
committed by GitHub
10 changed files with 404 additions and 20 deletions

View File

@@ -100,8 +100,12 @@ fn runArgs(alloc_gpa: Allocator, argsIter: anytype) !u8 {
var families: std.ArrayList([]const u8) = .empty;
var map: std.StringHashMap(std.ArrayListUnmanaged([]const u8)) = .init(alloc);
// Look up all available fonts
var disco = font.Discover.init();
// Look up all available fonts. The library is only used by backends
// that need it (the Windows backend opens candidate font files with
// FreeType); other backends ignore it.
var font_lib = try font.Library.init(alloc);
defer font_lib.deinit();
var disco = font.Discover.init(font_lib);
defer disco.deinit();
var disco_it = try disco.discover(alloc, .{
.family = config.family,

View File

@@ -26,6 +26,10 @@ fc: if (options.backend == .fontconfig_freetype) ?Fontconfig else void =
ct: if (font.Discover == font.discovery.CoreText) ?CoreText else void =
if (font.Discover == font.discovery.CoreText) null else {},
/// Windows (FreeType directory scan)
win: if (options.backend == .freetype_windows) ?Windows else void =
if (options.backend == .freetype_windows) null else {},
/// Canvas
wc: if (options.backend == .web_canvas) ?WebCanvas else void =
if (options.backend == .web_canvas) null else {},
@@ -51,6 +55,42 @@ pub const Fontconfig = struct {
}
};
/// Windows specific data. Only present with the freetype_windows backend.
///
/// Unlike Fontconfig/CoreText which carry lightweight descriptor handles,
/// the Windows backend has no external descriptor service — the "deferred"
/// metadata is the FreeType face itself. We keep a pre-loaded face (loaded
/// at discovery time) to answer `hasCodepoint` cheaply without re-opening
/// the file on every query, and remember the path so `load()` can open a
/// fresh face at the caller's requested size/options.
pub const Windows = struct {
/// Path to the font file. Owned here.
path: [:0]const u8,
/// Face index within the file (for .ttc collections).
face_index: i32,
/// Variations to apply on load.
variations: []const font.face.Variation,
/// Pre-loaded face used for cheap metadata queries (glyphIndex,
/// hasColor). The size it was opened at is irrelevant for these
/// queries since the CMap is size-independent. Deinit'd with us.
peek: Face,
/// Whether the face presents as emoji (has color glyphs) or text.
presentation: Presentation,
/// Allocator that owns `path`.
alloc: Allocator,
pub fn deinit(self: *Windows) void {
self.peek.deinit();
self.alloc.free(self.path);
self.* = undefined;
}
};
/// CoreText specific data. This is only present when building with CoreText.
pub const CoreText = struct {
/// The initialized font
@@ -88,6 +128,7 @@ pub fn deinit(self: *DeferredFace) void {
switch (options.backend) {
.fontconfig_freetype => if (self.fc) |*fc| fc.deinit(),
.freetype => {},
.freetype_windows => if (self.win) |*w| w.deinit(),
.web_canvas => if (self.wc) |*wc| wc.deinit(),
.coretext,
.coretext_freetype,
@@ -103,6 +144,8 @@ pub fn familyName(self: DeferredFace, buf: []u8) ![]const u8 {
switch (options.backend) {
.freetype => {},
.freetype_windows => if (self.win) |w| return try w.peek.name(buf),
.fontconfig_freetype => if (self.fc) |fc|
return (try fc.pattern.get(.family, 0)).string,
@@ -131,6 +174,8 @@ pub fn name(self: DeferredFace, buf: []u8) ![]const u8 {
switch (options.backend) {
.freetype => {},
.freetype_windows => if (self.win) |w| return try w.peek.name(buf),
.fontconfig_freetype => if (self.fc) |fc|
return (try fc.pattern.get(.fullname, 0)).string,
@@ -164,6 +209,7 @@ pub fn load(
) !Face {
return switch (options.backend) {
.fontconfig_freetype => try self.loadFontconfig(lib, opts),
.freetype_windows => try self.loadWindows(lib, opts),
.coretext, .coretext_harfbuzz, .coretext_noshape => try self.loadCoreText(lib, opts),
.coretext_freetype => try self.loadCoreTextFreetype(lib, opts),
.web_canvas => try self.loadWebCanvas(opts),
@@ -191,6 +237,19 @@ fn loadFontconfig(
return face;
}
fn loadWindows(
self: *DeferredFace,
lib: Library,
opts: font.face.Options,
) !Face {
const w = self.win.?;
var face = try Face.initFile(lib, w.path, w.face_index, opts);
errdefer face.deinit();
try face.setVariations(w.variations, opts);
return face;
}
fn loadCoreText(
self: *DeferredFace,
lib: Library,
@@ -287,6 +346,14 @@ pub fn hasCodepoint(self: DeferredFace, cp: u32, p: ?Presentation) bool {
}
},
.freetype_windows => {
// Use the pre-loaded peek face for a cheap CMap lookup.
if (self.win) |w| {
if (p) |desired| if (w.presentation != desired) return false;
return w.peek.glyphIndex(cp) != null;
}
},
.coretext,
.coretext_freetype,
.coretext_harfbuzz,
@@ -411,7 +478,7 @@ test "fontconfig" {
// Get a deferred face from fontconfig
var def = def: {
var fc = discovery.Fontconfig.init();
var fc = discovery.Fontconfig.init(lib);
defer fc.deinit();
var it = try fc.discover(alloc, .{ .family = "monospace", .size = 12 });
defer it.deinit();
@@ -443,7 +510,7 @@ test "coretext" {
// Get a deferred face from fontconfig
var def = def: {
var fc = discovery.CoreText.init();
var fc = discovery.CoreText.init(lib);
var it = try fc.discover(alloc, .{ .family = "Monaco", .size = 12 });
defer it.deinit();
break :def (try it.next()).?;

View File

@@ -442,7 +442,7 @@ fn discover(self: *SharedGridSet) !?*Discover {
// If we initialized, use it
if (self.font_discover) |*v| return v;
self.font_discover = .init();
self.font_discover = .init(self.font_lib);
return &self.font_discover.?;
}

View File

@@ -6,6 +6,12 @@ pub const Backend = enum {
/// FreeType for font rendering with no font discovery enabled.
freetype,
/// FreeType for font rendering with a built-in Windows font directory
/// scanner (C:\Windows\Fonts + %LOCALAPPDATA%\Microsoft\Windows\Fonts).
/// Used when DirectWrite is not available; matches by family_name and
/// SFNT name table without any external index.
freetype_windows,
/// Fontconfig for font discovery and FreeType for font rendering.
fontconfig_freetype,
@@ -42,10 +48,10 @@ pub const Backend = enum {
if (target.os.tag == .windows) {
// Avoid fontconfig on Windows because its libxml2 dependency
// may not unpack due to symlinks. Use plain freetype for now
// which means no font discovery. Full solution would likely use
// DirectWrite which has its own discovery API.
return .freetype;
// may not unpack due to symlinks. Use the FreeType-based
// Windows font-directory scanner for discovery. A future
// DirectWrite backend can replace this if needed.
return .freetype_windows;
}
// macOS also supports "coretext_freetype" but there is no scenario
@@ -60,6 +66,7 @@ pub const Backend = enum {
pub fn hasFreetype(self: Backend) bool {
return switch (self) {
.freetype,
.freetype_windows,
.fontconfig_freetype,
.coretext_freetype,
=> true,
@@ -81,6 +88,7 @@ pub const Backend = enum {
=> true,
.freetype,
.freetype_windows,
.fontconfig_freetype,
.web_canvas,
=> false,
@@ -92,6 +100,7 @@ pub const Backend = enum {
.fontconfig_freetype => true,
.freetype,
.freetype_windows,
.coretext,
.coretext_freetype,
.coretext_harfbuzz,
@@ -104,6 +113,7 @@ pub const Backend = enum {
pub fn hasHarfbuzz(self: Backend) bool {
return switch (self) {
.freetype,
.freetype_windows,
.fontconfig_freetype,
.coretext_freetype,
.coretext_harfbuzz,

View File

@@ -1,4 +1,5 @@
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const assert = @import("../quirks.zig").inlineAssert;
const fontconfig = @import("fontconfig");
@@ -7,6 +8,9 @@ const opentype = @import("opentype.zig");
const options = @import("main.zig").options;
const Collection = @import("main.zig").Collection;
const DeferredFace = @import("main.zig").DeferredFace;
const Face = @import("main.zig").Face;
const Library = @import("main.zig").Library;
const Presentation = @import("main.zig").Presentation;
const Variation = @import("main.zig").face.Variation;
const log = std.log.scoped(.discovery);
@@ -14,6 +18,7 @@ const log = std.log.scoped(.discovery);
/// Discover implementation for the compile options.
pub const Discover = switch (options.backend) {
.freetype => void, // no discovery
.freetype_windows => Windows,
.fontconfig_freetype => Fontconfig,
.web_canvas => void, // no discovery
.coretext,
@@ -242,7 +247,8 @@ pub const Descriptor = struct {
pub const Fontconfig = struct {
fc_config: *fontconfig.Config,
pub fn init() Fontconfig {
pub fn init(lib: Library) Fontconfig {
_ = lib;
// safe to call multiple times and concurrently
_ = fontconfig.init();
return .{ .fc_config = fontconfig.initLoadConfigAndFonts() };
@@ -333,7 +339,8 @@ pub const Fontconfig = struct {
};
pub const CoreText = struct {
pub fn init() CoreText {
pub fn init(lib: Library) CoreText {
_ = lib;
// Required for the "interface" but does nothing for CoreText.
return .{};
}
@@ -879,6 +886,263 @@ pub const CoreText = struct {
};
};
/// Windows font discovery. Enumerates font files in the system and
/// per-user font directories and matches them to a descriptor via
/// FreeType's family_name field (with a fallback to the SFNT name
/// table when family_name is missing).
///
/// No external service is used; each discover() call walks the
/// directories, opening candidate files with FreeType only as needed.
/// For typical Windows installations (~300 fonts) a name query is in
/// the tens of milliseconds. A codepoint fallback query may be
/// noticeably slower because every candidate has to be opened to
/// probe its CMap.
pub const Windows = struct {
lib: Library,
pub fn init(lib: Library) Windows {
return .{ .lib = lib };
}
pub fn deinit(self: *Windows) void {
_ = self;
}
pub fn discover(
self: *const Windows,
alloc: Allocator,
desc: Descriptor,
) !DiscoverIterator {
return .{
.alloc = alloc,
.lib = self.lib,
.desc = desc,
.variations = desc.variations,
.state = .system,
.dir = null,
.iter = null,
.system_path = null,
.user_path = null,
};
}
pub fn discoverFallback(
self: *const Windows,
alloc: Allocator,
collection: *Collection,
desc: Descriptor,
) !DiscoverIterator {
_ = collection;
return self.discover(alloc, desc);
}
pub const DiscoverIterator = struct {
alloc: Allocator,
lib: Library,
desc: Descriptor,
variations: []const Variation,
state: State,
dir: ?std.fs.Dir,
iter: ?std.fs.Dir.Iterator,
system_path: ?[:0]const u8,
user_path: ?[:0]const u8,
const State = enum { system, user, done };
pub fn deinit(self: *DiscoverIterator) void {
if (self.dir) |*d| d.close();
if (self.system_path) |p| self.alloc.free(p);
if (self.user_path) |p| self.alloc.free(p);
self.* = undefined;
}
pub fn next(self: *DiscoverIterator) !?DeferredFace {
while (true) {
// Ensure we have a directory iterator for the current state.
if (self.iter == null) {
switch (self.state) {
.system => {
const path = self.systemFontsPath() orelse {
self.state = .user;
continue;
};
self.system_path = path;
self.dir = std.fs.openDirAbsoluteZ(
path,
.{ .iterate = true },
) catch {
self.state = .user;
continue;
};
self.iter = self.dir.?.iterate();
},
.user => {
const path = self.userFontsPath() orelse {
self.state = .done;
continue;
};
self.user_path = path;
self.dir = std.fs.openDirAbsoluteZ(
path,
.{ .iterate = true },
) catch {
self.state = .done;
continue;
};
self.iter = self.dir.?.iterate();
},
.done => return null,
}
}
const entry = (self.iter.?.next() catch null) orelse {
// Finished this directory; advance state.
if (self.dir) |*d| d.close();
self.dir = null;
self.iter = null;
self.state = switch (self.state) {
.system => .user,
.user => .done,
.done => .done,
};
continue;
};
if (entry.kind != .file) continue;
if (!isFontFile(entry.name)) continue;
if (try self.tryMatch(entry.name)) |face| return face;
}
}
/// Build the system fonts directory from %SYSTEMROOT%. Returns null
/// if SYSTEMROOT is unset, which shouldn't happen on a healthy
/// Windows install but we just skip the directory rather than
/// falling back to a hardcoded drive letter.
fn systemFontsPath(self: *DiscoverIterator) ?[:0]const u8 {
const systemroot = std.process.getEnvVarOwned(
self.alloc,
"SYSTEMROOT",
) catch return null;
defer self.alloc.free(systemroot);
return std.fmt.allocPrintSentinel(
self.alloc,
"{s}\\Fonts",
.{systemroot},
0,
) catch null;
}
fn userFontsPath(self: *DiscoverIterator) ?[:0]const u8 {
const local_appdata = std.process.getEnvVarOwned(
self.alloc,
"LOCALAPPDATA",
) catch return null;
defer self.alloc.free(local_appdata);
return std.fmt.allocPrintSentinel(
self.alloc,
"{s}\\Microsoft\\Windows\\Fonts",
.{local_appdata},
0,
) catch null;
}
fn tryMatch(
self: *DiscoverIterator,
name: []const u8,
) !?DeferredFace {
const dir_path = switch (self.state) {
.system => self.system_path.?,
.user => self.user_path.?,
.done => return null,
};
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const full_path = std.fmt.bufPrintZ(
&path_buf,
"{s}\\{s}",
.{ dir_path, name },
) catch return null;
const is_ttc = std.ascii.endsWithIgnoreCase(name, ".ttc");
const max_faces: i32 = if (is_ttc) 16 else 1;
// Probe each face in the file.
var face_index: i32 = 0;
while (face_index < max_faces) : (face_index += 1) {
var face = Face.initFile(
self.lib,
full_path,
face_index,
.{ .size = .{ .points = 12 } },
) catch break;
if (self.matches(&face)) {
return try self.makeDeferred(face, full_path, face_index);
}
face.deinit();
}
return null;
}
/// Check whether the given face matches the descriptor.
fn matches(self: *const DiscoverIterator, face: *Face) bool {
if (self.desc.family) |family| {
if (!familyMatches(face, family)) return false;
}
if (self.desc.codepoint != 0) {
if (face.glyphIndex(self.desc.codepoint) == null) return false;
}
return true;
}
fn makeDeferred(
self: *DiscoverIterator,
face: Face,
full_path: []const u8,
face_index: i32,
) !DeferredFace {
const path_owned = try self.alloc.dupeZ(u8, full_path);
errdefer self.alloc.free(path_owned);
const presentation: Presentation =
if (face.hasColor()) .emoji else .text;
return DeferredFace{
.win = .{
.path = path_owned,
.face_index = face_index,
.variations = self.variations,
.peek = face,
.presentation = presentation,
.alloc = self.alloc,
},
};
}
};
fn isFontFile(name: []const u8) bool {
return std.ascii.endsWithIgnoreCase(name, ".ttf") or
std.ascii.endsWithIgnoreCase(name, ".ttc") or
std.ascii.endsWithIgnoreCase(name, ".otf");
}
/// Compare a face's family against a requested family name. Checks
/// FreeType's family_name first, then falls back to the SFNT name
/// table entry.
fn familyMatches(face: *Face, family: [:0]const u8) bool {
const ft_family: ?[*:0]const u8 = face.face.handle.*.family_name;
if (ft_family) |f| {
if (std.ascii.eqlIgnoreCase(std.mem.span(f), family)) return true;
}
var buf: [256]u8 = undefined;
const sfnt = face.name(&buf) catch "";
return sfnt.len > 0 and std.ascii.eqlIgnoreCase(sfnt, family);
}
};
test "descriptor hash" {
const testing = std.testing;
@@ -900,7 +1164,10 @@ test "fontconfig" {
const testing = std.testing;
const alloc = testing.allocator;
var fc = Fontconfig.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var fc = Fontconfig.init(lib);
defer fc.deinit();
var it = try fc.discover(alloc, .{ .family = "monospace", .size = 12 });
defer it.deinit();
@@ -912,7 +1179,10 @@ test "fontconfig codepoint" {
const testing = std.testing;
const alloc = testing.allocator;
var fc = Fontconfig.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var fc = Fontconfig.init(lib);
defer fc.deinit();
var it = try fc.discover(alloc, .{ .codepoint = 'A', .size = 12 });
defer it.deinit();
@@ -934,7 +1204,10 @@ test "coretext" {
const testing = std.testing;
const alloc = testing.allocator;
var ct = CoreText.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var ct = CoreText.init(lib);
defer ct.deinit();
var it = try ct.discover(alloc, .{ .family = "Monaco", .size = 12 });
defer it.deinit();
@@ -952,7 +1225,10 @@ test "coretext codepoint" {
const testing = std.testing;
const alloc = testing.allocator;
var ct = CoreText.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var ct = CoreText.init(lib);
defer ct.deinit();
var it = try ct.discover(alloc, .{ .codepoint = 'A', .size = 12 });
defer it.deinit();
@@ -981,7 +1257,10 @@ test "coretext sorting" {
const testing = std.testing;
const alloc = testing.allocator;
var ct = CoreText.init();
var lib = try Library.init(alloc);
defer lib.deinit();
var ct = CoreText.init(lib);
defer ct.deinit();
// We try to get a Regular, Italic, Bold, & Bold Italic version of SF Pro,
@@ -1047,3 +1326,24 @@ test "coretext sorting" {
try testing.expectEqualStrings("SF Pro Bold Italic", name);
}
}
test "windows" {
if (options.backend != .freetype_windows) return error.SkipZigTest;
const testing = std.testing;
const alloc = testing.allocator;
var lib = try Library.init(alloc);
defer lib.deinit();
var win = Windows.init(lib);
defer win.deinit();
// Arial ships on every stock Windows install.
var it = try win.discover(alloc, .{ .family = "Arial", .size = 12 });
defer it.deinit();
var face = (try it.next()) orelse return error.TestFontNotFound;
defer face.deinit();
try testing.expect(face.hasCodepoint('A', null));
}

View File

@@ -11,6 +11,7 @@ pub const web_canvas = @import("face/web_canvas.zig");
/// Face implementation for the compile options.
pub const Face = switch (options.backend) {
.freetype,
.freetype_windows,
.fontconfig_freetype,
.coretext_freetype,
=> freetype.Face,

View File

@@ -10,6 +10,7 @@ const font = @import("main.zig");
pub const Library = switch (options.backend) {
// Freetype requires a state library
.freetype,
.freetype_windows,
.fontconfig_freetype,
.coretext_freetype,
=> FreetypeLibrary,

View File

@@ -19,6 +19,7 @@ pub const default_features = feature.default_features;
/// Shaper implementation for our compile options.
pub const Shaper = switch (options.backend) {
.freetype,
.freetype_windows,
.fontconfig_freetype,
.coretext_freetype,
.coretext_harfbuzz,

View File

@@ -2585,7 +2585,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
});
} else {
// On CoreText we want to load Apple Emoji, we should have it.
var disco = font.Discover.init();
var disco = font.Discover.init(lib);
defer disco.deinit();
var disco_it = try disco.discover(alloc, .{
.family = "Apple Color Emoji",
@@ -2640,7 +2640,7 @@ fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestS
// Discover and add our font to the collection.
{
var disco = font.Discover.init();
var disco = font.Discover.init(lib);
defer disco.deinit();
var disco_it = try disco.discover(alloc, .{
.family = font_req,

View File

@@ -2071,7 +2071,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
});
} else {
// On CoreText we want to load Apple Emoji, we should have it.
var disco = font.Discover.init();
var disco = font.Discover.init(lib);
defer disco.deinit();
var disco_it = try disco.discover(alloc, .{
.family = "Apple Color Emoji",
@@ -2126,7 +2126,7 @@ fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestS
// Discover and add our font to the collection.
{
var disco = font.Discover.init();
var disco = font.Discover.init(lib);
defer disco.deinit();
var disco_it = try disco.discover(alloc, .{
.family = font_req,