summaryrefslogtreecommitdiff
path: root/src/font/Collection.zig
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/Collection.zig
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/Collection.zig')
-rw-r--r--src/font/Collection.zig152
1 files changed, 152 insertions, 0 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(),
+ );
+}