summaryrefslogtreecommitdiff
path: root/src/font
diff options
context:
space:
mode:
authorMitchell Hashimoto <m@mitchellh.com>2025-09-29 12:24:42 -0700
committerGitHub <noreply@github.com>2025-09-29 12:24:42 -0700
commit0bddaed53b75355506cbf84ce1bc3aaaaff22a67 (patch)
tree896fe3bf87323dd10510c4349b90a11da4af7e9a /src/font
parentb643d30d60e2540de6aea775b077b7208a20a7d2 (diff)
parent52ef17d4e0012e79b0f1db1d4119fbb17ce8d9bd (diff)
fix(font): Improve FreeType glyph measurements and add unit tests for face metrics (#8738)
Follow-up to #8720 adding * Two improvements to FreeType glyph measurements: - Ensuring that glyphs are measured with the same hinting as they are rendered, ref [#8720#issuecomment-3305408157](https://github.com/ghostty-org/ghostty/pull/8720#issuecomment-3305408157); - For outline glyphs, using the outline bbox instead of the built-in metrics, like `renderGlyph()`. * Basic unit tests for face metrics and their estimators, using the narrowest and widest fonts from the resource directory, Cozette Vector and Geist Mono. --- I also made one unrelated change to `freetype.zig`, replacing `@alignCast(@ptrCast(...))` with `@ptrCast(@alignCast(...))` on line 173. Autoformatting has been making this change on every save for weeks, and reverting the hunk before each commit is getting old, so I hope it's OK that I use this PR to upstream this decree from the formatter.
Diffstat (limited to 'src/font')
-rw-r--r--src/font/Collection.zig152
-rw-r--r--src/font/face.zig16
-rw-r--r--src/font/face/freetype.zig147
3 files changed, 226 insertions, 89 deletions
diff --git a/src/font/Collection.zig b/src/font/Collection.zig
index 997c72aa7..04b9882dc 100644
--- a/src/font/Collection.zig
+++ b/src/font/Collection.zig
@@ -1378,3 +1378,155 @@ test "adjusted sizes" {
);
}
}
+
+test "face metrics" {
+ // The web canvas backend doesn't calculate face metrics, only cell metrics
+ if (options.backend != .web_canvas) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const narrowFont = font.embedded.cozette;
+ const wideFont = font.embedded.geist_mono;
+
+ var lib = try Library.init(alloc);
+ defer lib.deinit();
+
+ var c = init();
+ defer c.deinit(alloc);
+ const size: DesiredSize = .{ .points = 12, .xdpi = 96, .ydpi = 96 };
+ c.load_options = .{ .library = lib, .size = size };
+
+ const narrowIndex = try c.add(alloc, try .init(
+ lib,
+ narrowFont,
+ .{ .size = size },
+ ), .{
+ .style = .regular,
+ .fallback = false,
+ .size_adjustment = .none,
+ });
+ const wideIndex = try c.add(alloc, try .init(
+ lib,
+ wideFont,
+ .{ .size = size },
+ ), .{
+ .style = .regular,
+ .fallback = false,
+ .size_adjustment = .none,
+ });
+
+ const narrowMetrics: font.Metrics.FaceMetrics = (try c.getFace(narrowIndex)).getMetrics();
+ const wideMetrics: font.Metrics.FaceMetrics = (try c.getFace(wideIndex)).getMetrics();
+
+ // Verify provided/measured metrics. Measured
+ // values are backend-dependent due to hinting.
+ const narrowMetricsExpected = font.Metrics.FaceMetrics{
+ .px_per_em = 16.0,
+ .cell_width = switch (options.backend) {
+ .freetype,
+ .fontconfig_freetype,
+ .coretext_freetype,
+ => 8.0,
+ .coretext,
+ .coretext_harfbuzz,
+ .coretext_noshape,
+ => 7.3828125,
+ .web_canvas => unreachable,
+ },
+ .ascent = 12.3046875,
+ .descent = -3.6953125,
+ .line_gap = 0.0,
+ .underline_position = -1.2265625,
+ .underline_thickness = 1.2265625,
+ .strikethrough_position = 6.15625,
+ .strikethrough_thickness = 1.234375,
+ .cap_height = 9.84375,
+ .ex_height = 7.3828125,
+ .ascii_height = switch (options.backend) {
+ .freetype,
+ .fontconfig_freetype,
+ .coretext_freetype,
+ => 18.0625,
+ .coretext,
+ .coretext_harfbuzz,
+ .coretext_noshape,
+ => 16.0,
+ .web_canvas => unreachable,
+ },
+ };
+ const wideMetricsExpected = font.Metrics.FaceMetrics{
+ .px_per_em = 16.0,
+ .cell_width = switch (options.backend) {
+ .freetype,
+ .fontconfig_freetype,
+ .coretext_freetype,
+ => 10.0,
+ .coretext,
+ .coretext_harfbuzz,
+ .coretext_noshape,
+ => 9.6,
+ .web_canvas => unreachable,
+ },
+ .ascent = 14.72,
+ .descent = -3.52,
+ .line_gap = 1.6,
+ .underline_position = -1.6,
+ .underline_thickness = 0.8,
+ .strikethrough_position = 4.24,
+ .strikethrough_thickness = 0.8,
+ .cap_height = 11.36,
+ .ex_height = 8.48,
+ .ascii_height = switch (options.backend) {
+ .freetype,
+ .fontconfig_freetype,
+ .coretext_freetype,
+ => 16.0,
+ .coretext,
+ .coretext_harfbuzz,
+ .coretext_noshape,
+ => 15.472000000000001,
+ .web_canvas => unreachable,
+ },
+ };
+
+ inline for (
+ .{ narrowMetricsExpected, wideMetricsExpected },
+ .{ narrowMetrics, wideMetrics },
+ ) |metricsExpected, metricsActual| {
+ inline for (@typeInfo(font.Metrics.FaceMetrics).@"struct".fields) |field| {
+ const expected = @field(metricsExpected, field.name);
+ const actual = @field(metricsActual, field.name);
+ // Unwrap optional fields
+ const expectedValue, const actualValue = unwrap: switch (@typeInfo(field.type)) {
+ .optional => {
+ if (expected) |expectedValue| if (actual) |actualValue| {
+ break :unwrap .{ expectedValue, actualValue };
+ };
+ // Null values can be compared directly
+ try std.testing.expectEqual(expected, actual);
+ continue;
+ },
+ else => break :unwrap .{ expected, actual },
+ };
+ // All non-null values are floats
+ const eps = std.math.floatEps(@TypeOf(actualValue - expectedValue));
+ try std.testing.expectApproxEqRel(
+ expectedValue,
+ actualValue,
+ std.math.sqrt(eps),
+ );
+ }
+ }
+
+ // Verify estimated metrics. icWidth() should equal the smaller of
+ // 2 * cell_width and ascii_height. For a narrow (wide) font, the
+ // smaller quantity is the former (latter).
+ try std.testing.expectEqual(
+ 2 * narrowMetrics.cell_width,
+ narrowMetrics.icWidth(),
+ );
+ try std.testing.expectEqual(
+ wideMetrics.ascii_height,
+ wideMetrics.icWidth(),
+ );
+}
diff --git a/src/font/face.zig b/src/font/face.zig
index 0f882a77f..7216fea97 100644
--- a/src/font/face.zig
+++ b/src/font/face.zig
@@ -93,6 +93,14 @@ pub const Variation = struct {
};
};
+/// The size and position of a glyph.
+pub const GlyphSize = struct {
+ width: f64,
+ height: f64,
+ x: f64,
+ y: f64,
+};
+
/// Additional options for rendering glyphs.
pub const RenderOptions = struct {
/// The metrics that are defining the grid layout. These are usually
@@ -216,14 +224,6 @@ pub const RenderOptions = struct {
icon,
};
- /// The size and position of a glyph.
- pub const GlyphSize = struct {
- width: f64,
- height: f64,
- x: f64,
- y: f64,
- };
-
/// Returns true if the constraint does anything. If it doesn't,
/// because it neither sizes nor positions the glyph, then this
/// returns false.
diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig
index 3094d8076..bdcd82ab3 100644
--- a/src/font/face/freetype.zig
+++ b/src/font/face/freetype.zig
@@ -170,7 +170,7 @@ pub const Face = struct {
if (string.len > 1024) break :skip;
var tmp: [512]u16 = undefined;
const max = string.len / 2;
- for (@as([]const u16, @alignCast(@ptrCast(string))), 0..) |c, j| tmp[j] = @byteSwap(c);
+ for (@as([]const u16, @ptrCast(@alignCast(string))), 0..) |c, j| tmp[j] = @byteSwap(c);
const len = std.unicode.utf16LeToUtf8(buf, tmp[0..max]) catch return string;
return buf[0..len];
}
@@ -351,26 +351,16 @@ pub const Face = struct {
return glyph.*.bitmap.pixel_mode == freetype.c.FT_PIXEL_MODE_BGRA;
}
- /// Render a glyph using the glyph index. The rendered glyph is stored in the
- /// given texture atlas.
- pub fn renderGlyph(
- self: Face,
- alloc: Allocator,
- atlas: *font.Atlas,
- glyph_index: u32,
- opts: font.face.RenderOptions,
- ) !Glyph {
- self.ft_mutex.lock();
- defer self.ft_mutex.unlock();
-
+ /// Set the load flags to use when loading a glyph for measurement or
+ /// rendering.
+ fn glyphLoadFlags(self: Face, constrained: bool) freetype.LoadFlags {
// Hinting should only be enabled if the configured load flags specify
// it and the provided constraint doesn't actually do anything, since
// if it does, then it'll mess up the hinting anyway when it moves or
// resizes the glyph.
- const do_hinting = self.load_flags.hinting and !opts.constraint.doesAnything();
+ const do_hinting = self.load_flags.hinting and !constrained;
- // Load the glyph.
- try self.face.loadGlyph(glyph_index, .{
+ return .{
// If our glyph has color, we want to render the color
.color = self.face.hasColor(),
@@ -392,42 +382,56 @@ pub const Face = struct {
// SVG glyphs under FreeType, since that requires bundling another
// dependency to handle rendering the SVG.
.no_svg = true,
- });
+ };
+ }
+
+ /// Get a rect that represents the position and size of the loaded glyph.
+ fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.face.GlyphSize {
+ // If we're dealing with an outline glyph then we get the
+ // outline's bounding box instead of using the built-in
+ // metrics, since that's more precise and allows better
+ // cell-fitting.
+ if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) {
+ // Get the glyph's bounding box before we transform it at all.
+ // We use this rather than the metrics, since it's more precise.
+ var bbox: freetype.c.FT_BBox = undefined;
+ _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox);
+
+ return .{
+ .x = f26dot6ToF64(bbox.xMin),
+ .y = f26dot6ToF64(bbox.yMin),
+ .width = f26dot6ToF64(bbox.xMax - bbox.xMin),
+ .height = f26dot6ToF64(bbox.yMax - bbox.yMin),
+ };
+ }
+
+ return .{
+ .x = f26dot6ToF64(glyph.*.metrics.horiBearingX),
+ .y = f26dot6ToF64(glyph.*.metrics.horiBearingY - glyph.*.metrics.height),
+ .width = f26dot6ToF64(glyph.*.metrics.width),
+ .height = f26dot6ToF64(glyph.*.metrics.height),
+ };
+ }
+
+ /// Render a glyph using the glyph index. The rendered glyph is stored in the
+ /// given texture atlas.
+ pub fn renderGlyph(
+ self: Face,
+ alloc: Allocator,
+ atlas: *font.Atlas,
+ glyph_index: u32,
+ opts: font.face.RenderOptions,
+ ) !Glyph {
+ self.ft_mutex.lock();
+ defer self.ft_mutex.unlock();
+
+ // Load the glyph.
+ try self.face.loadGlyph(glyph_index, self.glyphLoadFlags(opts.constraint.doesAnything()));
const glyph = self.face.handle.*.glyph;
// We get a rect that represents the position
// and size of the glyph before any changes.
- const rect: struct {
- x: f64,
- y: f64,
- width: f64,
- height: f64,
- } = metrics: {
- // If we're dealing with an outline glyph then we get the
- // outline's bounding box instead of using the built-in
- // metrics, since that's more precise and allows better
- // cell-fitting.
- if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) {
- // Get the glyph's bounding box before we transform it at all.
- // We use this rather than the metrics, since it's more precise.
- var bbox: freetype.c.FT_BBox = undefined;
- _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox);
-
- break :metrics .{
- .x = f26dot6ToF64(bbox.xMin),
- .y = f26dot6ToF64(bbox.yMin),
- .width = f26dot6ToF64(bbox.xMax - bbox.xMin),
- .height = f26dot6ToF64(bbox.yMax - bbox.yMin),
- };
- }
-
- break :metrics .{
- .x = f26dot6ToF64(glyph.*.metrics.horiBearingX),
- .y = f26dot6ToF64(glyph.*.metrics.horiBearingY - glyph.*.metrics.height),
- .width = f26dot6ToF64(glyph.*.metrics.width),
- .height = f26dot6ToF64(glyph.*.metrics.height),
- };
- };
+ const rect = getGlyphSize(glyph);
// If our glyph is smaller than a quarter pixel in either axis
// then it has no outlines or they're too small to render.
@@ -973,23 +977,15 @@ pub const Face = struct {
var c: u8 = ' ';
while (c < 127) : (c += 1) {
if (face.getCharIndex(c)) |glyph_index| {
- if (face.loadGlyph(glyph_index, .{
- .render = false,
- .no_svg = true,
- })) {
+ if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) {
const glyph = face.handle.*.glyph;
max = @max(
f26dot6ToF64(glyph.*.advance.x),
max,
);
- top = @max(
- f26dot6ToF64(glyph.*.metrics.horiBearingY),
- top,
- );
- bottom = @min(
- f26dot6ToF64(glyph.*.metrics.horiBearingY - glyph.*.metrics.height),
- bottom,
- );
+ const rect = getGlyphSize(glyph);
+ top = @max(rect.y + rect.height, top);
+ bottom = @min(rect.y, bottom);
} else |_| {}
}
}
@@ -1028,11 +1024,8 @@ pub const Face = struct {
self.ft_mutex.lock();
defer self.ft_mutex.unlock();
if (face.getCharIndex('H')) |glyph_index| {
- if (face.loadGlyph(glyph_index, .{
- .render = false,
- .no_svg = true,
- })) {
- break :cap f26dot6ToF64(face.handle.*.glyph.*.metrics.height);
+ if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) {
+ break :cap getGlyphSize(face.handle.*.glyph).height;
} else |_| {}
}
break :cap null;
@@ -1041,11 +1034,8 @@ pub const Face = struct {
self.ft_mutex.lock();
defer self.ft_mutex.unlock();
if (face.getCharIndex('x')) |glyph_index| {
- if (face.loadGlyph(glyph_index, .{
- .render = false,
- .no_svg = true,
- })) {
- break :ex f26dot6ToF64(face.handle.*.glyph.*.metrics.height);
+ if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) {
+ break :ex getGlyphSize(face.handle.*.glyph).height;
} else |_| {}
}
break :ex null;
@@ -1060,10 +1050,7 @@ pub const Face = struct {
const glyph = face.getCharIndex('水') orelse break :ic_width null;
- face.loadGlyph(glyph, .{
- .render = false,
- .no_svg = true,
- }) catch break :ic_width null;
+ face.loadGlyph(glyph, self.glyphLoadFlags(false)) catch break :ic_width null;
const ft_glyph = face.handle.*.glyph;
@@ -1075,21 +1062,19 @@ pub const Face = struct {
// This can sometimes happen if there's a CJK font that has been
// patched with the nerd fonts patcher and it butchers the advance
// values so the advance ends up half the width of the actual glyph.
- if (ft_glyph.*.metrics.width > ft_glyph.*.advance.x) {
+ const ft_glyph_width = getGlyphSize(ft_glyph).width;
+ const advance = f26dot6ToF64(ft_glyph.*.advance.x);
+ if (ft_glyph_width > advance) {
var buf: [1024]u8 = undefined;
const font_name = self.name(&buf) catch "<Error getting font name>";
log.warn(
"(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.",
- .{
- font_name,
- f26dot6ToF64(ft_glyph.*.metrics.width),
- f26dot6ToF64(ft_glyph.*.advance.x),
- },
+ .{ font_name, ft_glyph_width, advance },
);
break :ic_width null;
}
- break :ic_width f26dot6ToF64(ft_glyph.*.advance.x);
+ break :ic_width advance;
};
return .{