font: add Windows font discovery backend

Adds a FreeType-based Discover implementation for Windows that walks
the system (C:\Windows\Fonts) and per-user
(%LOCALAPPDATA%\Microsoft\Windows\Fonts) font directories, matching
descriptors via family_name / SFNT name table and optionally codepoint
presence.

Wired up as a new .freetype_windows backend which Backend.default() now
returns on Windows. Existing freetype-only paths are untouched.

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

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Yasuhiro Matsumoto
2026-04-23 13:32:49 +09:00
parent e88c6c0991
commit 61fce4d0a4
8 changed files with 339 additions and 7 deletions

View File

@@ -4,6 +4,7 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const Action = @import("ghostty.zig").Action;
const args = @import("args.zig");
const font = @import("../font/main.zig");
const discovery = @import("../font/discovery.zig");
const log = std.log.scoped(.list_fonts);
@@ -100,8 +101,18 @@ 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 Windows backend needs a FreeType
// library handle so it can open candidate font files while scanning
// the system/user font directories.
var font_lib = if (comptime font.Discover == discovery.Windows)
try font.Library.init(alloc)
else {};
defer if (comptime font.Discover == discovery.Windows) font_lib.deinit();
var disco = if (comptime font.Discover == discovery.Windows)
font.Discover.init(font_lib)
else
font.Discover.init();
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,

View File

@@ -442,7 +442,10 @@ fn discover(self: *SharedGridSet) !?*Discover {
// If we initialized, use it
if (self.font_discover) |*v| return v;
self.font_discover = .init();
self.font_discover = if (comptime Discover == discovery.Windows)
.init(self.font_lib)
else
.init();
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,
@@ -879,6 +884,239 @@ 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,
.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,
user_path: ?[:0]const u8,
const State = enum { system, user, done };
const system_fonts_dir = "C:\\Windows\\Fonts";
pub fn deinit(self: *DiscoverIterator) void {
if (self.dir) |*d| d.close();
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 => {
self.dir = std.fs.openDirAbsoluteZ(
system_fonts_dir,
.{ .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;
}
}
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 => system_fonts_dir,
.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;

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,