From e4b53cc909429356564387be8f688d6ddc9cf608 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Tue, 15 Jul 2025 12:57:23 -0700 Subject: [PATCH 1/3] Move face metric fallback estimates to the FaceMetric struct --- src/font/Collection.zig | 20 ++------- src/font/Metrics.zig | 90 ++++++++++++++++++++++++++++------------- 2 files changed, 65 insertions(+), 45 deletions(-) diff --git a/src/font/Collection.zig b/src/font/Collection.zig index eb4349fb0..ac36be36f 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -161,23 +161,11 @@ pub fn adjustedSize( // of CJK font sizes when mixed with latin fonts. // // We estimate the ic_width as twice the cell width if it isn't provided. - var primary_cap = primary_metrics.cap_height orelse 0.0; - if (primary_cap <= 0) primary_cap = primary_metrics.ascent * 0.75; + const primary_ex = primary_metrics.exHeight(); + const primary_ic = primary_metrics.icWidth(); - var primary_ex = primary_metrics.ex_height orelse 0.0; - if (primary_ex <= 0) primary_ex = primary_cap * 0.75; - - var primary_ic = primary_metrics.ic_width orelse 0.0; - if (primary_ic <= 0) primary_ic = primary_metrics.cell_width * 2; - - var face_cap = face_metrics.cap_height orelse 0.0; - if (face_cap <= 0) face_cap = face_metrics.ascent * 0.75; - - var face_ex = face_metrics.ex_height orelse 0.0; - if (face_ex <= 0) face_ex = face_cap * 0.75; - - var face_ic = face_metrics.ic_width orelse 0.0; - if (face_ic <= 0) face_ic = face_metrics.cell_width * 2; + const face_ex = face_metrics.exHeight(); + const face_ic = face_metrics.icWidth(); // If the line height of the scaled font would be larger than // the line height of the primary font, we don't want that, so diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index 89f6a507f..320a4f504 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -120,6 +120,60 @@ pub const FaceMetrics = struct { pub inline fn lineHeight(self: FaceMetrics) f64 { return self.ascent - self.descent + self.line_gap; } + + /// Convenience function for getting the cap height. If this is not + /// defined in the font, we estimate it as 75% of the ascent. + pub inline fn capHeight(self: FaceMetrics) f64 { + if (self.cap_height) |value| if (value > 0) return value; + return 0.75 * self.ascent; + } + + /// Convenience function for getting the ex height. If this is not + /// defined in the font, we estimate it as 75% of the cap height. + pub inline fn exHeight(self: FaceMetrics) f64 { + if (self.ex_height) |value| if (value > 0) return value; + return 0.75 * self.capHeight(); + } + + /// Convenience function for getting the ideograph width. If this is + /// not defined in the font, we estimate it as two cell widths. + pub inline fn icWidth(self: FaceMetrics) f64 { + if (self.ic_width) |value| if (value > 0) return value; + return 2 * self.cell_width; + } + + /// Convenience function for getting the underline thickness. If + /// this is not defined in the font, we estimate it as 15% of the ex + /// height. + pub inline fn underlineThickness(self: FaceMetrics) f64 { + if (self.underline_thickness) |value| if (value > 0) return value; + return 0.15 * self.exHeight(); + } + + /// Convenience function for getting the strikethrough thickness. If + /// this is not defined in the font, we set it equal to the + /// underline thickness. + pub inline fn strikethroughThickness(self: FaceMetrics) f64 { + if (self.strikethrough_thickness) |value| if (value > 0) return value; + return self.underlineThickness(); + } + + // NOTE: The getters below return positions, not sizes, so both + // positive and negative values are valid, hence no sign validation. + + /// Convenience function for getting the underline position. If + /// this is not defined in the font, we place it one underline + /// thickness below the baseline. + pub inline fn underlinePosition(self: FaceMetrics) f64 { + return self.underline_position orelse -self.underlineThickness(); + } + + /// Convenience function for getting the strikethrough position. If + /// this is not defined in the font, we center it at half the ex + /// height, so that it's perfectly centered on lower case text. + pub inline fn strikethroughPosition(self: FaceMetrics) f64 { + return self.strikethrough_position orelse (self.exHeight() + self.strikethroughThickness()) * 0.5; + } }; /// Calculate our metrics based on values extracted from a font. @@ -147,35 +201,13 @@ pub fn calc(face: FaceMetrics) Metrics { // We calculate a top_to_baseline to make following calculations simpler. const top_to_baseline = cell_height - cell_baseline; - // If we don't have a provided cap height, - // we estimate it as 75% of the ascent. - const cap_height = face.cap_height orelse face.ascent * 0.75; - - // If we don't have a provided ex height, - // we estimate it as 75% of the cap height. - const ex_height = face.ex_height orelse cap_height * 0.75; - - // If we don't have a provided underline thickness, - // we estimate it as 15% of the ex height. - const underline_thickness = @max(1, @ceil(face.underline_thickness orelse 0.15 * ex_height)); - - // If we don't have a provided strikethrough thickness - // then we just use the underline thickness for it. - const strikethrough_thickness = @max(1, @ceil(face.strikethrough_thickness orelse underline_thickness)); - - // If we don't have a provided underline position then - // we place it 1 underline-thickness below the baseline. - const underline_position = @round(top_to_baseline - - (face.underline_position orelse - -underline_thickness)); - - // If we don't have a provided strikethrough position - // then we center the strikethrough stroke at half the - // ex height, so that it's perfectly centered on lower - // case text. - const strikethrough_position = @round(top_to_baseline - - (face.strikethrough_position orelse - ex_height * 0.5 + strikethrough_thickness * 0.5)); + // Get the other font metrics or their estimates. See doc comments + // in FaceMetrics for explanations of the estimation heuristics. + const cap_height = face.capHeight(); + const underline_thickness = @max(1, @ceil(face.underlineThickness())); + const strikethrough_thickness = @max(1, @ceil(face.strikethroughThickness())); + const underline_position = @round(top_to_baseline - face.underlinePosition()); + const strikethrough_position = @round(top_to_baseline - face.strikethroughPosition()); // The calculation for icon height in the nerd fonts patcher // is two thirds cap height to one third line height, but we From 80e52aa6f02a1d3c7ddf69fb55d736875aa76d33 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Tue, 15 Jul 2025 13:04:17 -0700 Subject: [PATCH 2/3] Make normalization reference metric customizable per font --- src/font/Collection.zig | 49 ++++++++++++++++---------------------- src/font/face.zig | 20 ++++++++++++++++ src/font/face/coretext.zig | 5 ++++ src/font/face/freetype.zig | 5 ++++ 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/src/font/Collection.zig b/src/font/Collection.zig index ac36be36f..108ccb6b7 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -124,8 +124,7 @@ pub const AdjustSizeError = font.Face.GetMetricsError; // Calculate a size for the provided face that will match it with the primary // font, metrically, to improve consistency with fallback fonts. Right now we -// match the font based on the ex height, or the ideograph width if the font -// has ideographs in it. +// match the font based on the a preferred metric specified per fallback font. // // This returns null if load options is null or if self.load_options is null. // @@ -134,7 +133,7 @@ pub const AdjustSizeError = font.Face.GetMetricsError; // // TODO: In the future, provide config options that allow the user to select // which metric should be matched for fallback fonts, instead of hard -// coding it as ex height. +// coding it in the font init code. pub fn adjustedSize( self: *Collection, face: *Face, @@ -151,26 +150,26 @@ pub fn adjustedSize( const primary_metrics = try primary_face.getMetrics(); const face_metrics = try face.getMetrics(); - // We use the ex height to match our font sizes, so that the height of - // lower-case letters matches between all fonts in the fallback chain. - // - // We estimate ex height as 0.75 * cap height if it's not specifically - // provided, and we estimate cap height as 0.75 * ascent in the same case. - // - // If the fallback font has an ic_width we prefer that, for normalization - // of CJK font sizes when mixed with latin fonts. - // - // We estimate the ic_width as twice the cell width if it isn't provided. - const primary_ex = primary_metrics.exHeight(); - const primary_ic = primary_metrics.icWidth(); - - const face_ex = face_metrics.exHeight(); - const face_ic = face_metrics.icWidth(); + // The preferred metric to normalize by is specified by + // face.reference_metric, however we don't want to normalize by a + // metric not explicitly defined in `face`, so if needed we fall + // back through the other possible reference metrics in the order + // shown in the switch statement below. If the reference metric is + // not defined in the primary font, we use the default estimate from + // the face metrics. + const line_height_scale = primary_metrics.lineHeight() / face_metrics.lineHeight(); + const scale: f64 = normalize_by: switch (face.reference_metric) { + .ic_width => if (face_metrics.ic_width) |value| primary_metrics.icWidth() / value else continue :normalize_by .ex_height, + .ex_height => if (face_metrics.ex_height) |value| primary_metrics.exHeight() / value else continue :normalize_by .cap_height, + .cap_height => if (face_metrics.cap_height) |value| primary_metrics.capHeight() / value else continue :normalize_by .line_height, + .line_height => line_height_scale, + .em_size => 1.0, + }; // If the line height of the scaled font would be larger than // the line height of the primary font, we don't want that, so - // we take the minimum between matching the ic/ex and the line - // height. + // we take the minimum between matching the reference metric + // and keeping the line heights within some margin. // // NOTE: We actually allow the line height to be up to 1.2 // times the primary line height because empirically @@ -178,19 +177,13 @@ pub fn adjustedSize( // // TODO: We should probably provide a config option that lets // the user pick what metric to use for size adjustment. - const scale = @min( - 1.2 * primary_metrics.lineHeight() / face_metrics.lineHeight(), - if (face_metrics.ic_width != null) - primary_ic / face_ic - else - primary_ex / face_ex, - ); + const capped_scale = @min(scale, 1.2 * line_height_scale); // Make a copy of our load options, set the size to the size of // the provided face, and then multiply that by our scaling factor. var opts = load_options; opts.size = face.size; - opts.size.points *= @as(f32, @floatCast(scale)); + opts.size.points *= @as(f32, @floatCast(capped_scale)); return opts; } diff --git a/src/font/face.zig b/src/font/face.zig index fc5118c3d..1642095ce 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -39,6 +39,12 @@ pub const freetype_load_flags_default: FreetypeLoadFlags = if (FreetypeLoadFlags pub const Options = struct { size: DesiredSize, freetype_load_flags: FreetypeLoadFlags = freetype_load_flags_default, + + // reference_metric defaults to ic_width to ensure appropriate + // normalization of CJK font sizes when mixed with latin fonts. See + // the implementation of Collections.adjustedSize() for fallback + // rules when the font does not define the specified metric. + reference_metric: ReferenceMetric = .ic_width, }; /// The desired size for loading a font. @@ -57,6 +63,20 @@ pub const DesiredSize = struct { } }; +pub const ReferenceMetric = enum { + // The font's ideograph width + ic_width, + // The font's ex height + ex_height, + // The font's cap height + cap_height, + // The font's line height + line_height, + // The font's em size (i.e., what point sizes like 12 refer to). + // Usually equivalent to line height, but that's just convention. + em_size, +}; + /// A font variation setting. The best documentation for this I know of /// is actually the CSS font-variation-settings property on MDN: /// https://developer.mozilla.org/en-US/docs/Web/CSS/font-variation-settings diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index bb9a472d2..51956a55c 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -34,6 +34,10 @@ pub const Face = struct { /// The current size this font is set to. size: font.face.DesiredSize, + // The preferred font metric to use when normalizing the size of a + // fallback font to the primary font. + reference_metric: font.face.ReferenceMetric, + /// True if our build is using Harfbuzz. If we're not, we can avoid /// some Harfbuzz-specific code paths. const harfbuzz_shaper = font.options.backend.hasHarfbuzz(); @@ -110,6 +114,7 @@ pub const Face = struct { .hb_font = hb_font, .color = color, .size = opts.size, + .reference_metric = opts.reference_metric, }; result.quirks_disable_default_font_features = quirks.disableDefaultFontFeatures(&result); diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index f42868e5c..4eb3a091a 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -62,6 +62,10 @@ pub const Face = struct { /// The current size this font is set to. size: font.face.DesiredSize, + // The preferred font metric to use when normalizing the size of a + // fallback font to the primary font. + reference_metric: font.face.ReferenceMetric, + /// Initialize a new font face with the given source in-memory. pub fn initFile( lib: Library, @@ -111,6 +115,7 @@ pub const Face = struct { .ft_mutex = ft_mutex, .load_flags = opts.freetype_load_flags, .size = opts.size, + .reference_metric = opts.reference_metric, }; result.quirks_disable_default_font_features = quirks.disableDefaultFontFeatures(&result); From 69b83e7de4d6969e1709b38d187bc2f5ae5cc199 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Tue, 15 Jul 2025 13:04:51 -0700 Subject: [PATCH 3/3] Use em size as nerd font reference metric --- src/build/SharedDeps.zig | 2 +- src/font/SharedGridSet.zig | 2 +- src/font/face.zig | 30 ++++++++---------------------- 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index ea7e696ef..bdc1dfe14 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -533,7 +533,7 @@ pub fn add( const nf_symbols = b.dependency("nerd_fonts_symbols_only", .{}); step.root_module.addAnonymousImport( "nerd_fonts_symbols_only", - .{ .root_source_file = nf_symbols.path("SymbolsNerdFontMono-Regular.ttf") }, + .{ .root_source_file = nf_symbols.path("SymbolsNerdFont-Regular.ttf") }, ); } diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig index 14a8babad..cdea3a467 100644 --- a/src/font/SharedGridSet.zig +++ b/src/font/SharedGridSet.zig @@ -305,7 +305,7 @@ fn collection( .{ .fallback_loaded = try Face.init( self.font_lib, font.embedded.symbols_nerd_font, - load_options.faceOptions(), + load_options.faceOptions().setReferenceMetric(.em_size), ) }, ); diff --git a/src/font/face.zig b/src/font/face.zig index 1642095ce..a211be9bc 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -45,6 +45,13 @@ pub const Options = struct { // the implementation of Collections.adjustedSize() for fallback // rules when the font does not define the specified metric. reference_metric: ReferenceMetric = .ic_width, + + // Convenience function to create a copy with a different reference metric. + pub fn setReferenceMetric(self: Options, reference_metric: ReferenceMetric) Options { + var opts = self; + opts.reference_metric = reference_metric; + return opts; + } }; /// The desired size for loading a font. @@ -242,7 +249,7 @@ pub const RenderOptions = struct { ) GlyphSize { var g = glyph; - var available_width: f64 = @floatFromInt( + const available_width: f64 = @floatFromInt( metrics.cell_width * @min( self.max_constraint_width, constraint_width, @@ -253,22 +260,6 @@ pub const RenderOptions = struct { .icon => metrics.icon_height, }); - // We make the opinionated choice here to reduce the width - // of icon-height symbols by the same amount horizontally, - // since otherwise wide aspect ratio icons like folders end - // up far too wide. - // - // But we *only* do this if the constraint width is 2, since - // otherwise it would make them way too small when sized for - // a single cell. - const is_icon_width = self.height == .icon and @min(self.max_constraint_width, constraint_width) > 1; - const orig_avail_width = available_width; - if (is_icon_width) { - const cell_height: f64 = @floatFromInt(metrics.cell_height); - const ratio = available_height / cell_height; - available_width *= ratio; - } - const w = available_width - self.pad_left * available_width - self.pad_right * available_width; @@ -392,11 +383,6 @@ pub const RenderOptions = struct { .center => g.y = (h - g.height) / 2, } - // Add offset for icon width restriction, to keep it centered. - if (is_icon_width) { - g.x += (orig_avail_width - available_width) / 2; - } - // Re-add our padding before returning. g.x += self.pad_left * available_width; g.y += self.pad_bottom * available_height;