// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Loads the fonts fontconfig's configuration finds into faces the renderer //! can draw, and keeps them. //! //! A font file is read and decoded once however many faces are made from //! it: a variable font matched at several weights is one `font.Font` and //! several `Face`s. Everything the loader hands out lives until `deinit`, //! at a stable address, so a `GlyphCache` can key on the faces. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const font = @import("font"); const fc = @import("font_config"); const renderer = @import("font_renderer"); const Face = renderer.Face; const FontList = @import("FontList.zig"); const Loader = @This(); gpa: Allocator, io: Io, config: *const fc.Config, fonts: std.ArrayList(*FontEntry) = .empty, faces: std.ArrayList(*FaceEntry) = .empty, /// The largest font file that will be read. pub const max_file_size = 256 * 1024 * 1024; const FontEntry = struct { path: []const u8, face_index: u32, data: []const u8, font: font.Font, }; const FaceEntry = struct { font: *FontEntry, named_instance: ?u16, variations: []const Face.Variation, face: Face, }; /// A loader that finds fonts with `config`, which must outlive it and have /// been scanned. pub fn init(gpa: Allocator, io: Io, config: *const fc.Config) Loader { return .{ .gpa = gpa, .io = io, .config = config }; } pub fn deinit(l: *Loader) void { for (l.faces.items) |e| { e.face.deinit(l.gpa); l.gpa.free(e.variations); l.gpa.destroy(e); } l.faces.deinit(l.gpa); for (l.fonts.items) |e| { e.font.deinit(); l.gpa.free(e.data); l.gpa.free(e.path); l.gpa.destroy(e); } l.fonts.deinit(l.gpa); l.* = undefined; } pub const LoadError = Allocator.Error || font.Error || Io.Dir.ReadFileAllocError || error{ /// A pattern with no file to load. NoFile, }; /// The face a matched pattern describes: its `file`, the face and named /// instance its `index` encodes as FreeType does (`instance << 16 | face`), /// the axis settings in its `fontvariations`, which /// `Config.renderPrepare` writes for a variable font, whether its /// `embeddedbitmap` allows the font's bitmaps, and the hinting its /// `hinting` and `hintstyle` ask for (see `hintingOf`). pub fn face(l: *Loader, pattern: *const fc.Pattern) LoadError!*Face { const path = pattern.getString(.file, 0) orelse return error.NoFile; const index: u32 = @bitCast(pattern.getInteger(.index, 0) orelse 0); const instance: u16 = @truncate(index >> 16); var vars: std.ArrayList(Face.Variation) = .empty; defer vars.deinit(l.gpa); if (pattern.getString(.fontvariations, 0)) |s| try parseVariations(l.gpa, s, &vars); return l.loadFace(path, index & 0xffff, .{ // 0 is the font as it is and 0x8000 the whole variable font, which // fontconfig gives its own pattern; 1 and up are the named // instances, counted from one. .named_instance = if (instance == 0 or instance == 0x8000) null else instance - 1, .variations = vars.items, .embedded_bitmaps = (pattern.getBool(.embeddedbitmap, 0) orelse .true) != .false, .hinting = hintingOf(pattern), }); } /// The hinting a pattern asks for: none when its `hinting` is false or its /// `hintstyle` is `hintnone`, `light` for `hintslight`, and `full` for /// `hintmedium` and `hintfull`, fontconfig's default. That is cairo's /// mapping, in which only `hintslight` asks for FreeType's light mode. /// `autohint` asks for the autohinter rather than the font's own /// instructions, and this renderer's autohinter is the light one. pub fn hintingOf(pattern: *const fc.Pattern) Face.Hinting { if ((pattern.getBool(.hinting, 0) orelse .true) == .false) return .none; const style = pattern.getInteger(.hintstyle, 0) orelse fc.object.hint.full; if (style <= fc.object.hint.none) return .none; if (style == fc.object.hint.slight) return .light; if ((pattern.getBool(.autohint, 0) orelse .false) == .true) return .light; return .full; } /// Face `face_index` of the font file at `path`, at `options`, loading the /// file if it has not been loaded. pub fn loadFace(l: *Loader, path: []const u8, face_index: u32, options: Face.Options) LoadError!*Face { const fe = try l.loadFont(path, face_index); for (l.faces.items) |e| { if (e.font == fe and std.meta.eql(e.named_instance, options.named_instance) and e.face.embedded_bitmaps == options.embedded_bitmaps and e.face.hinting == options.hinting and sameVariations(e.variations, options.variations)) return &e.face; } const entry = try l.gpa.create(FaceEntry); errdefer l.gpa.destroy(entry); const variations = try l.gpa.dupe(Face.Variation, options.variations); errdefer l.gpa.free(variations); entry.* = .{ .font = fe, .named_instance = options.named_instance, .variations = variations, .face = try Face.init(l.gpa, &fe.font, .{ .named_instance = options.named_instance, .variations = variations, .embedded_bitmaps = options.embedded_bitmaps, .hinting = options.hinting, }), }; errdefer entry.face.deinit(l.gpa); try l.faces.append(l.gpa, entry); return &entry.face; } fn sameVariations(a: []const Face.Variation, b: []const Face.Variation) bool { if (a.len != b.len) return false; for (a, b) |x, y| { if (!x.tag.eql(y.tag) or x.value != y.value) return false; } return true; } fn loadFont(l: *Loader, path: []const u8, face_index: u32) LoadError!*FontEntry { for (l.fonts.items) |e| { if (e.face_index == face_index and std.mem.eql(u8, e.path, path)) return e; } const entry = try l.gpa.create(FontEntry); errdefer l.gpa.destroy(entry); const owned_path = try l.gpa.dupe(u8, path); errdefer l.gpa.free(owned_path); const data = try Io.Dir.cwd().readFileAlloc(l.io, path, l.gpa, .limited(max_file_size)); errdefer l.gpa.free(data); entry.* = .{ .path = owned_path, .face_index = face_index, .data = data, // The bytes are kept alongside, so the font borrows them rather // than holding a second copy. .font = try font.Font.parseIndex(l.gpa, data, face_index, .{ .copy = false }), }; errdefer entry.font.deinit(); try l.fonts.append(l.gpa, entry); return entry; } /// The fonts that best match `query`, best first, as a fallback list: /// `query` goes through the configuration's rules as `fc-match` puts it, /// and fonts adding no character to those before them are left out, as /// `fc-match -s` leaves them out. Nothing is loaded until asked for. pub fn resolve(l: *Loader, query: *const fc.Pattern) (Allocator.Error || fc.Config.MatchError)!FontList { var prepared = try query.duplicate(l.gpa); errdefer prepared.deinit(); try l.config.prepare(&prepared); const fonts = try l.config.sort(l.gpa, &prepared, true, null); errdefer l.gpa.free(fonts); const states = try l.gpa.alloc(FontList.State, fonts.len); @memset(states, .unloaded); return .{ .loader = l, .query = prepared, .fonts = fonts, .states = states }; } /// `resolve` for a query in fontconfig's text form, `Family-12:bold`. pub fn resolveName(l: *Loader, name: []const u8) (Allocator.Error || fc.Config.MatchError || fc.name.ParseError)!FontList { var query = try fc.name.parse(l.gpa, name); defer query.deinit(); return l.resolve(&query); } /// Parses fontconfig's `fontvariations`: `tag=value` pairs separated by /// commas, as in `wght=700,wdth=75`. A pair that does not parse is skipped. pub fn parseVariations(gpa: Allocator, s: []const u8, out: *std.ArrayList(Face.Variation)) Allocator.Error!void { var it = std.mem.tokenizeScalar(u8, s, ','); while (it.next()) |pair| { const eq = std.mem.findScalar(u8, pair, '=') orelse continue; const tag = std.mem.trim(u8, pair[0..eq], " "); if (tag.len == 0 or tag.len > 4) continue; var t: [4]u8 = @splat(' '); @memcpy(t[0..tag.len], tag); const value = std.fmt.parseFloat(f32, std.mem.trim(u8, pair[eq + 1 ..], " ")) catch continue; try out.append(gpa, .{ .tag = .{ .bytes = t }, .value = value }); } } test "fontvariations parse to axis settings" { var out: std.ArrayList(Face.Variation) = .empty; defer out.deinit(std.testing.allocator); try parseVariations(std.testing.allocator, "wght=700, wdth=75.5,bad,=3,opsz=x", &out); try std.testing.expectEqual(@as(usize, 2), out.items.len); try std.testing.expect(out.items[0].tag.eql(.init("wght"))); try std.testing.expectEqual(@as(f32, 700), out.items[0].value); try std.testing.expect(out.items[1].tag.eql(.init("wdth"))); try std.testing.expectEqual(@as(f32, 75.5), out.items[1].value); }