diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/font/discovery.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/font/discovery.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/font/discovery.zig | 1049 |
1 files changed, 1049 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/font/discovery.zig b/vendor/libghostty-vt/src/font/discovery.zig new file mode 100644 index 00000000..c419d36a --- /dev/null +++ b/vendor/libghostty-vt/src/font/discovery.zig @@ -0,0 +1,1049 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = @import("../quirks.zig").inlineAssert; +const fontconfig = @import("fontconfig"); +const macos = @import("macos"); +const opentype = @import("opentype.zig"); +const options = @import("main.zig").options; +const Collection = @import("main.zig").Collection; +const DeferredFace = @import("main.zig").DeferredFace; +const Variation = @import("main.zig").face.Variation; + +const log = std.log.scoped(.discovery); + +/// Discover implementation for the compile options. +pub const Discover = switch (options.backend) { + .freetype => void, // no discovery + .fontconfig_freetype => Fontconfig, + .web_canvas => void, // no discovery + .coretext, + .coretext_freetype, + .coretext_harfbuzz, + .coretext_noshape, + => CoreText, +}; + +/// 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 +/// value. +pub const Descriptor = struct { + /// Font family to search for. This can be a fully qualified font + /// name such as "Fira Code", "monospace", "serif", etc. Memory is + /// owned by the caller and should be freed when this descriptor + /// is no longer in use. The discovery structs will never store the + /// descriptor. + /// + /// On systems that use fontconfig (Linux), this can be a full + /// fontconfig pattern, such as "Fira Code-14:bold". + family: ?[:0]const u8 = null, + + /// Specific font style to search for. This will filter the style + /// string the font advertises. The "bold/italic" booleans later in this + /// struct filter by the style trait the font has, not the string, so + /// these can be used in conjunction or not. + style: ?[:0]const u8 = null, + + /// A codepoint that this font must be able to render. + codepoint: u32 = 0, + + /// Font size in points that the font should support. For conversion + /// to pixels, we will use 72 DPI for Mac and 96 DPI for everything else. + /// (If pixel conversion is necessary, i.e. emoji fonts) + size: f32 = 0, + + /// True if we want to search specifically for a font that supports + /// specific styles. + bold: bool = false, + italic: bool = false, + monospace: bool = false, + + /// Variation axes to apply to the font. This also impacts searching + /// for fonts since fonts with the ability to set these variations + /// will be preferred, but not guaranteed. + variations: []const Variation = &.{}, + + /// Hash the descriptor with the given hasher. + pub fn hash(self: Descriptor, hasher: anytype) void { + const autoHash = std.hash.autoHash; + const autoHashStrat = std.hash.autoHashStrat; + autoHashStrat(hasher, self.family, .Deep); + autoHashStrat(hasher, self.style, .Deep); + autoHash(hasher, self.codepoint); + autoHash(hasher, @as(u32, @bitCast(self.size))); + autoHash(hasher, self.bold); + autoHash(hasher, self.italic); + autoHash(hasher, self.monospace); + autoHash(hasher, self.variations.len); + for (self.variations) |variation| { + autoHash(hasher, variation.id); + + // This is not correct, but we don't currently depend on the + // hash value being different based on decimal values of variations. + autoHash(hasher, @as(i64, @intFromFloat(variation.value))); + } + } + + /// Returns a hash code that can be used to uniquely identify this + /// action. + pub fn hashcode(self: Descriptor) u64 { + var hasher = std.hash.Wyhash.init(0); + self.hash(&hasher); + return hasher.final(); + } + + /// Deep copy of the struct. The given allocator is expected to + /// be an arena allocator of some sort since the descriptor + /// itself doesn't support fine-grained deallocation of fields. + pub fn clone(self: *const Descriptor, alloc: Allocator) !Descriptor { + // We can't do any errdefer cleanup in here. As documented we + // expect the allocator to be an arena so any errors should be + // cleaned up somewhere else. + + var copy = self.*; + copy.family = if (self.family) |src| try alloc.dupeZ(u8, src) else null; + copy.style = if (self.style) |src| try alloc.dupeZ(u8, src) else null; + copy.variations = try alloc.dupe(Variation, self.variations); + return copy; + } + + /// Convert to Fontconfig pattern to use for lookup. The pattern does + /// not have defaults filled/substituted (Fontconfig thing) so callers + /// must still do this. + pub fn toFcPattern(self: Descriptor) *fontconfig.Pattern { + const pat = fontconfig.Pattern.create(); + if (self.family) |family| { + assert(pat.add(.family, .{ .string = family }, false)); + } + if (self.style) |style| { + assert(pat.add(.style, .{ .string = style }, false)); + } + if (self.codepoint > 0) { + const cs = fontconfig.CharSet.create(); + defer cs.destroy(); + assert(cs.addChar(self.codepoint)); + assert(pat.add(.charset, .{ .char_set = cs }, false)); + } + if (self.size > 0) assert(pat.add( + .size, + .{ .integer = @intFromFloat(@round(self.size)) }, + false, + )); + if (self.bold) assert(pat.add( + .weight, + .{ .integer = @intFromEnum(fontconfig.Weight.bold) }, + false, + )); + if (self.italic) assert(pat.add( + .slant, + .{ .integer = @intFromEnum(fontconfig.Slant.italic) }, + false, + )); + + // For fontconfig, we always add monospace in the pattern. Since + // fontconfig sorts by closeness to the pattern, this doesn't fully + // exclude non-monospace but helps prefer it. + assert(pat.add( + .spacing, + .{ .integer = @intFromEnum(fontconfig.Spacing.mono) }, + false, + )); + + 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 + if (self.family) |family_bytes| { + const family = try macos.foundation.String.createWithBytes(family_bytes, .utf8, false); + defer family.release(); + attrs.setValue( + macos.text.FontAttribute.family_name.key(), + family, + ); + } + + // Style + if (self.style) |style_bytes| { + const style = try macos.foundation.String.createWithBytes(style_bytes, .utf8, false); + defer style.release(); + attrs.setValue( + macos.text.FontAttribute.style_name.key(), + style, + ); + } + + // Codepoint support + if (self.codepoint > 0) { + const cs = try macos.foundation.CharacterSet.createWithCharactersInRange(.{ + .location = self.codepoint, + .length = 1, + }); + defer cs.release(); + attrs.setValue( + macos.text.FontAttribute.character_set.key(), + cs, + ); + } + + // Set our size attribute if set + if (self.size > 0) { + const size32: i32 = @intFromFloat(@round(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, + .monospace = self.monospace, + }; + const traits_cval: u32 = @bitCast(traits); + 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, + @as(*const i32, @ptrCast(&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(attrs)); + } +}; + +pub const Fontconfig = struct { + fc_config: *fontconfig.Config, + + pub fn init() Fontconfig { + // safe to call multiple times and concurrently + _ = fontconfig.init(); + return .{ .fc_config = fontconfig.initLoadConfigAndFonts() }; + } + + pub fn deinit(self: *Fontconfig) void { + self.fc_config.destroy(); + } + + /// Discover fonts from a descriptor. This returns an iterator that can + /// be used to build up the deferred fonts. + pub fn discover( + self: *const Fontconfig, + alloc: Allocator, + desc: Descriptor, + ) !DiscoverIterator { + _ = alloc; + + // Build our pattern that we'll search for + const pat = desc.toFcPattern(); + errdefer pat.destroy(); + assert(self.fc_config.substituteWithPat(pat, .pattern)); + pat.defaultSubstitute(); + + // Search + const res = self.fc_config.fontSort(pat, false, null); + if (res.result != .match) return error.FontConfigFailed; + errdefer res.fs.destroy(); + + return .{ + .config = self.fc_config, + .pattern = pat, + .set = res.fs, + .fonts = res.fs.fonts(), + .variations = desc.variations, + .i = 0, + }; + } + + pub fn discoverFallback( + self: *const Fontconfig, + alloc: Allocator, + collection: *Collection, + desc: Descriptor, + ) !DiscoverIterator { + _ = collection; + return try self.discover(alloc, desc); + } + + pub const DiscoverIterator = struct { + config: *fontconfig.Config, + pattern: *fontconfig.Pattern, + set: *fontconfig.FontSet, + fonts: []*fontconfig.Pattern, + variations: []const Variation, + i: usize, + + pub fn deinit(self: *DiscoverIterator) void { + self.set.destroy(); + self.pattern.destroy(); + self.* = undefined; + } + + pub fn next(self: *DiscoverIterator) fontconfig.Error!?DeferredFace { + if (self.i >= self.fonts.len) return null; + + // Get the copied pattern from our fontset that has the + // attributes configured for rendering. + const font_pattern = try self.config.fontRenderPrepare( + self.pattern, + self.fonts[self.i], + ); + errdefer font_pattern.destroy(); + + // Increment after we return + defer self.i += 1; + + return DeferredFace{ + .fc = .{ + .pattern = font_pattern, + .charset = (try font_pattern.get(.charset, 0)).char_set, + .langset = (try font_pattern.get(.lang, 0)).lang_set, + .variations = self.variations, + }, + }; + } + }; +}; + +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, alloc: Allocator, 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 + var ct_desc_arr = [_]*const macos.text.FontDescriptor{ct_desc}; + const desc_arr = try macos.foundation.Array.create(macos.text.FontDescriptor, &ct_desc_arr); + defer desc_arr.release(); + + // Build our collection + const set = try macos.text.FontCollection.createWithFontDescriptors(desc_arr); + defer set.release(); + const list = set.createMatchingFontDescriptors(); + defer list.release(); + + // Sort our descriptors + const zig_list = try copyMatchingDescriptors(alloc, list); + errdefer alloc.free(zig_list); + sortMatchingDescriptors(&desc, zig_list); + + return DiscoverIterator{ + .alloc = alloc, + .list = zig_list, + .variations = desc.variations, + .i = 0, + }; + } + + pub fn discoverFallback( + self: *const CoreText, + alloc: Allocator, + collection: *Collection, + desc: Descriptor, + ) !DiscoverIterator { + // If we have a codepoint within the CJK unified ideographs block + // then we fallback to macOS to find a font that supports it because + // there isn't a better way manually with CoreText that I can find that + // properly takes into account system locale. + // + // References: + // - http://unicode.org/charts/PDF/U4E00.pdf + // - https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/platform/fonts/LocaleInFonts.md#unified-han-ideographs + if (desc.codepoint >= 0x4E00 and + desc.codepoint <= 0x9FFF) + han: { + const han = try self.discoverCodepoint( + collection, + desc, + ) orelse break :han; + + // This is silly but our discover iterator needs a slice so + // we allocate here. This isn't a performance bottleneck but + // this is something we can optimize very easily... + const list = try alloc.alloc(*macos.text.FontDescriptor, 1); + errdefer alloc.free(list); + list[0] = han; + + return DiscoverIterator{ + .alloc = alloc, + .list = list, + .variations = desc.variations, + .i = 0, + }; + } + + const it = try self.discover(alloc, desc); + + // If our normal discovery doesn't find anything and we have a specific + // codepoint, then fallback to using CTFontCreateForString to find a + // matching font CoreText wants to use. See: + // https://github.com/ghostty-org/ghostty/issues/2499 + if (it.list.len == 0 and desc.codepoint > 0) codepoint: { + const ct_desc = try self.discoverCodepoint( + collection, + desc, + ) orelse break :codepoint; + + const list = try alloc.alloc(*macos.text.FontDescriptor, 1); + errdefer alloc.free(list); + list[0] = ct_desc; + + return DiscoverIterator{ + .alloc = alloc, + .list = list, + .variations = desc.variations, + .i = 0, + }; + } + + return it; + } + + /// Discover a font for a specific codepoint using the CoreText + /// CTFontCreateForString API. + fn discoverCodepoint( + self: *const CoreText, + collection: *Collection, + desc: Descriptor, + ) !?*macos.text.FontDescriptor { + _ = self; + + if (comptime options.backend.hasFreetype()) { + // If we have freetype, we can't use CoreText to find a font + // that supports a specific codepoint because we need to + // have a CoreText font to be able to do so. + return null; + } + + assert(desc.codepoint > 0); + + // Get our original font. This is dependent on the requested style + // from the descriptor. + const original = original: { + // In all the styles below, we try to match it but if we don't + // we always fall back to some other option. The order matters + // here. + + if (desc.bold and desc.italic) { + const entries = collection.faces.get(.bold_italic); + if (entries.count() > 0) { + break :original try collection.getFace(.{ .style = .bold_italic }); + } + } + + if (desc.bold) { + const entries = collection.faces.get(.bold); + if (entries.count() > 0) { + break :original try collection.getFace(.{ .style = .bold }); + } + } + + if (desc.italic) { + const entries = collection.faces.get(.italic); + if (entries.count() > 0) { + break :original try collection.getFace(.{ .style = .italic }); + } + } + + break :original try collection.getFace(.{ .style = .regular }); + }; + + // We need it in utf8 format + var buf: [4]u8 = undefined; + const len = try std.unicode.utf8Encode( + @intCast(desc.codepoint), + &buf, + ); + + // We need a CFString + const str = try macos.foundation.String.createWithBytes( + buf[0..len], + .utf8, + false, + ); + defer str.release(); + + // Get our range length for CTFontCreateForString. It looks like + // the range uses UTF-16 codepoints and not UTF-32 codepoints. + const range_len: usize = range_len: { + var unichars: [2]u16 = undefined; + const pair = macos.foundation.stringGetSurrogatePairForLongCharacter( + desc.codepoint, + &unichars, + ); + break :range_len if (pair) 2 else 1; + }; + + // Get our font + const font = original.font.createForString( + str, + macos.foundation.Range.init(0, range_len), + ) orelse return null; + defer font.release(); + + // Do not allow the last resort font to go through. This is the + // last font used by CoreText if it can't find anything else and + // only contains replacement characters. + last_resort: { + const name_str = font.copyPostScriptName(); + defer name_str.release(); + + // If the name doesn't fit in our buffer, then it can't + // be the last resort font so we break out. + var name_buf: [64]u8 = undefined; + const name: []const u8 = name_str.cstring(&name_buf, .utf8) orelse + break :last_resort; + + // If the name is "LastResort" then we don't want to use it. + if (std.mem.eql(u8, "LastResort", name)) return null; + } + + // Get the descriptor + return font.copyDescriptor(); + } + + fn copyMatchingDescriptors( + alloc: Allocator, + list: *macos.foundation.Array, + ) ![]*macos.text.FontDescriptor { + var result = try alloc.alloc(*macos.text.FontDescriptor, list.getCount()); + errdefer alloc.free(result); + for (0..result.len) |i| { + result[i] = list.getValueAtIndex(macos.text.FontDescriptor, i); + + // We need to retain because once the list is freed it will + // release all its members. + result[i].retain(); + } + return result; + } + + fn sortMatchingDescriptors( + desc: *const Descriptor, + list: []*macos.text.FontDescriptor, + ) void { + std.mem.sortUnstable(*macos.text.FontDescriptor, list, desc, struct { + fn lessThan( + desc_inner: *const Descriptor, + lhs: *macos.text.FontDescriptor, + rhs: *macos.text.FontDescriptor, + ) bool { + const lhs_score: Score = .score(desc_inner, lhs); + const rhs_score: Score = .score(desc_inner, rhs); + // Higher score is "less" (earlier) + return lhs_score.int() > rhs_score.int(); + } + }.lessThan); + } + + /// We represent our sorting score as a packed struct so that we + /// can compare scores numerically but build scores symbolically. + /// + /// Note that packed structs store their fields from least to most + /// significant, so the fields here are defined in increasing order + /// of precedence. + const Score = packed struct { + const Backing = @typeInfo(@This()).@"struct".backing_integer.?; + + /// Number of glyphs in the font, if two fonts have identical + /// scores otherwise then we prefer the one with more glyphs. + /// + /// (Number of glyphs clamped at u16 intmax) + glyph_count: u16 = 0, + /// A fuzzy match on the style string, less important than + /// an exact match, and less important than trait matches. + fuzzy_style: u8 = 0, + /// Whether the bold-ness of the font matches the descriptor. + /// This is less important than italic because a font that's italic + /// when it shouldn't be or not italic when it should be is a bigger + /// problem (subjectively) than being the wrong weight. + bold: bool = false, + /// Whether the italic-ness of the font matches the descriptor. + /// This is less important than an exact match on the style string + /// because we want users to be allowed to override trait matching + /// for the bold/italic/bold italic styles if they want. + italic: bool = false, + /// An exact (case-insensitive) match on the style string. + exact_style: bool = false, + /// Whether the font is monospace, this is more important than any of + /// the other fields unless we're looking for a specific codepoint, + /// in which case that is the most important thing. + monospace: bool = false, + /// If we're looking for a codepoint, whether this font has it. + codepoint: bool = false, + + pub fn int(self: Score) Backing { + return @bitCast(self); + } + + fn score(desc: *const Descriptor, ct_desc: *const macos.text.FontDescriptor) Score { + var self: Score = .{}; + + // We always load the font if we can since some things can only be + // inspected on the font itself. Fonts that can't be loaded score + // 0 automatically because we don't want a font we can't load. + const font: *macos.text.Font = macos.text.Font.createWithFontDescriptor( + ct_desc, + 12, + ) catch return self; + defer font.release(); + + // We prefer fonts with more glyphs, all else being equal. + { + const Type = @TypeOf(self.glyph_count); + self.glyph_count = std.math.cast( + Type, + font.getGlyphCount(), + ) orelse std.math.maxInt(Type); + } + + // If we're searching for a codepoint, then we + // prioritize fonts that have that codepoint. + if (desc.codepoint > 0) { + // Turn UTF-32 into UTF-16 for CT API + var unichars: [2]u16 = undefined; + const pair = macos.foundation.stringGetSurrogatePairForLongCharacter( + desc.codepoint, + &unichars, + ); + const len: usize = if (pair) 2 else 1; + + // Get our glyphs + var glyphs = [2]macos.graphics.Glyph{ 0, 0 }; + self.codepoint = font.getGlyphsForCharacters( + unichars[0..len], + glyphs[0..len], + ); + } + + // Get our symbolic traits for the descriptor so we can + // compare boolean attributes like bold, monospace, etc. + const symbolic_traits: macos.text.FontSymbolicTraits = traits: { + const traits = ct_desc.copyAttribute(.traits) orelse break :traits .{}; + defer traits.release(); + + const key = macos.text.FontTraitKey.symbolic.key(); + const symbolic = traits.getValue(macos.foundation.Number, key) orelse + break :traits .{}; + + break :traits macos.text.FontSymbolicTraits.init(symbolic); + }; + + self.monospace = symbolic_traits.monospace; + + // We try to derived data from the font itself, which is generally + // more reliable than only using the symbolic traits for this. + const is_bold: bool, const is_italic: bool = derived: { + // We start with initial guesses based on the symbolic traits, + // but refine these with more information if we can get it. + var is_italic = symbolic_traits.italic; + var is_bold = symbolic_traits.bold; + + // Read the 'head' table out of the font data if it's available. + if (head: { + const tag = macos.text.FontTableTag.init("head"); + const data = font.copyTable(tag) orelse break :head null; + defer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + break :head opentype.Head.init(ptr[0..len]) catch |err| { + log.warn("error parsing head table: {}", .{err}); + break :head null; + }; + }) |head_| { + const head: opentype.Head = head_; + is_bold = is_bold or (head.macStyle & 1 == 1); + is_italic = is_italic or (head.macStyle & 2 == 2); + } + + // Read the 'OS/2' table out of the font data if it's available. + if (os2: { + const tag = macos.text.FontTableTag.init("OS/2"); + const data = font.copyTable(tag) orelse break :os2 null; + defer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + break :os2 opentype.OS2.init(ptr[0..len]) catch |err| { + log.warn("error parsing OS/2 table: {}", .{err}); + break :os2 null; + }; + }) |os2| { + is_bold = is_bold or os2.fsSelection.bold; + is_italic = is_italic or os2.fsSelection.italic; + } + + // Check if we have variation axes in our descriptor, if we + // do then we can derive weight italic-ness or both from them. + if (font.copyAttribute(.variation_axes)) |axes| variations: { + defer axes.release(); + + // Copy the variation values for this instance of the font. + // if there are none then we just break out immediately. + const values: *macos.foundation.Dictionary = + font.copyAttribute(.variation) orelse break :variations; + defer values.release(); + + var buf: [1024]u8 = undefined; + + // If we see the 'ital' value then we ignore 'slnt'. + var ital_seen = false; + + const len = axes.getCount(); + for (0..len) |i| { + const dict = axes.getValueAtIndex(macos.foundation.Dictionary, i); + const Key = macos.text.FontVariationAxisKey; + const cf_id = dict.getValue(Key.identifier.Value(), Key.identifier.key()).?; + const cf_name = dict.getValue(Key.name.Value(), Key.name.key()).?; + const cf_def = dict.getValue(Key.default_value.Value(), Key.default_value.key()).?; + + const name_str = cf_name.cstring(&buf, .utf8) orelse ""; + + // Default value + var def: f64 = 0; + _ = cf_def.getValue(.double, &def); + // Value in this font + var val: f64 = def; + if (values.getValue( + macos.foundation.Number, + cf_id, + )) |cf_val| _ = cf_val.getValue(.double, &val); + + if (std.mem.eql(u8, "wght", name_str)) { + // Somewhat subjective threshold, we consider fonts + // bold if they have a 'wght' set greater than 600. + is_bold = val > 600; + continue; + } + if (std.mem.eql(u8, "ital", name_str)) { + is_italic = val > 0.5; + ital_seen = true; + continue; + } + if (!ital_seen and std.mem.eql(u8, "slnt", name_str)) { + // Arbitrary threshold of anything more than a 5 + // degree clockwise slant is considered italic. + is_italic = val <= -5.0; + continue; + } + } + } + + break :derived .{ is_bold, is_italic }; + }; + + self.bold = desc.bold == is_bold; + self.italic = desc.italic == is_italic; + + // Get the style string from the font. + var style_str_buf: [128]u8 = undefined; + const style_str: []const u8 = style_str: { + const style = ct_desc.copyAttribute(.style_name) orelse + break :style_str ""; + defer style.release(); + + break :style_str style.cstring(&style_str_buf, .utf8) orelse ""; + }; + + // The first string in this slice will be used for the exact match, + // and for the fuzzy match, all matching substrings will increase + // the rank. + const desired_styles: []const [:0]const u8 = desired: { + if (desc.style) |s| break :desired &.{s}; + + // If we don't have an explicitly desired style name, we base + // it on the bold and italic properties, this isn't ideal since + // fonts may use style names other than these, but it helps in + // some edge cases. + if (desc.bold) { + if (desc.italic) break :desired &.{ "bold italic", "bold", "italic", "oblique" }; + break :desired &.{ "bold", "upright" }; + } else if (desc.italic) { + break :desired &.{ "italic", "regular", "oblique" }; + } + break :desired &.{ "regular", "upright" }; + }; + + self.exact_style = std.ascii.eqlIgnoreCase( + style_str, + desired_styles[0], + ); + // Our "fuzzy match" score is 0 if the desired style isn't present + // in the string, otherwise we give higher priority for styles that + // have fewer characters not in the desired_styles list. + const fuzzy_type = @TypeOf(self.fuzzy_style); + self.fuzzy_style = @intCast(style_str.len); + for (desired_styles) |s| { + if (std.ascii.indexOfIgnoreCase(style_str, s) != null) { + self.fuzzy_style -|= @intCast(s.len); + } + } + self.fuzzy_style = std.math.maxInt(fuzzy_type) -| self.fuzzy_style; + + return self; + } + }; + + pub const DiscoverIterator = struct { + alloc: Allocator, + list: []const *macos.text.FontDescriptor, + variations: []const Variation, + i: usize, + + pub fn deinit(self: *DiscoverIterator) void { + for (self.list) |desc| { + desc.release(); + } + self.alloc.free(self.list); + self.* = undefined; + } + + pub fn next(self: *DiscoverIterator) !?DeferredFace { + if (self.i >= self.list.len) return null; + + // Get our descriptor. We need to remove the character set + // limitation because we may have used that to filter but we + // don't want it anymore because it'll restrict the characters + // available. + const desc = desc: { + // We create a copy, overwriting the character set attribute. + const attrs = try macos.foundation.MutableDictionary.create(0); + defer attrs.release(); + + attrs.setValue( + macos.text.FontAttribute.character_set.key(), + macos.c.kCFNull, + ); + + break :desc try macos.text.FontDescriptor.createCopyWithAttributes( + self.list[self.i], + @ptrCast(attrs), + ); + }; + defer desc.release(); + + // Create our font. We need a size to initialize it so we use size + // 12 but we will alter the size later. + const font = try macos.text.Font.createWithFontDescriptor(desc, 12); + errdefer font.release(); + + // Increment after we return + defer self.i += 1; + + return DeferredFace{ + .ct = .{ + .font = font, + .variations = self.variations, + }, + }; + } + }; +}; + +test "descriptor hash" { + const testing = std.testing; + + var d: Descriptor = .{}; + try testing.expect(d.hashcode() != 0); +} + +test "descriptor hash family names" { + const testing = std.testing; + + var d1: Descriptor = .{ .family = "A" }; + var d2: Descriptor = .{ .family = "B" }; + try testing.expect(d1.hashcode() != d2.hashcode()); +} + +test "fontconfig" { + if (options.backend != .fontconfig_freetype) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + var fc = Fontconfig.init(); + defer fc.deinit(); + var it = try fc.discover(alloc, .{ .family = "monospace", .size = 12 }); + defer it.deinit(); +} + +test "fontconfig codepoint" { + if (options.backend != .fontconfig_freetype) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + var fc = Fontconfig.init(); + defer fc.deinit(); + var it = try fc.discover(alloc, .{ .codepoint = 'A', .size = 12 }); + defer it.deinit(); + + // The first result should have the codepoint. Later ones may not + // because fontconfig returns all fonts sorted. + var face = (try it.next()).?; + defer face.deinit(); + try testing.expect(face.hasCodepoint('A', null)); + + // Should have other codepoints too + try testing.expect(face.hasCodepoint('B', null)); +} + +test "coretext" { + if (options.backend != .coretext and options.backend != .coretext_freetype) + return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + var ct = CoreText.init(); + defer ct.deinit(); + var it = try ct.discover(alloc, .{ .family = "Monaco", .size = 12 }); + defer it.deinit(); + var count: usize = 0; + while (try it.next()) |_| { + count += 1; + } + try testing.expect(count > 0); +} + +test "coretext codepoint" { + if (options.backend != .coretext and options.backend != .coretext_freetype) + return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + var ct = CoreText.init(); + defer ct.deinit(); + var it = try ct.discover(alloc, .{ .codepoint = 'A', .size = 12 }); + defer it.deinit(); + + // The first result should have the codepoint. Later ones may not + // because fontconfig returns all fonts sorted. + const face = (try it.next()).?; + try testing.expect(face.hasCodepoint('A', null)); + + // Should have other codepoints too + try testing.expect(face.hasCodepoint('B', null)); +} + +test "coretext sorting" { + if (options.backend != .coretext and options.backend != .coretext_freetype) + return error.SkipZigTest; + + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!// + // FIXME: Disabled for now because SF Pro is not available in CI + // The solution likely involves directly testing that the + // `sortMatchingDescriptors` function sorts a bundled test + // font correctly, instead of relying on the system fonts. + if (true) return error.SkipZigTest; + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!// + + const testing = std.testing; + const alloc = testing.allocator; + + var ct = CoreText.init(); + defer ct.deinit(); + + // We try to get a Regular, Italic, Bold, & Bold Italic version of SF Pro, + // which should be installed on all Macs, and has many styles which makes + // it a good test, since there will be many results for each discovery. + + // Regular + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Regular", name); + } + + // Regular Italic + // + // NOTE: This makes sure that we don't accidentally prefer "Thin Italic", + // which we previously did, because it has a shorter name. + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + .italic = true, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Regular Italic", name); + } + + // Bold + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + .bold = true, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Bold", name); + } + + // Bold Italic + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + .bold = true, + .italic = true, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Bold Italic", name); + } +} |
