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.
pull/8962/head
Mitchell Hashimoto 2025-09-29 12:24:42 -07:00 committed by GitHub
commit 0bddaed53b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 227 additions and 89 deletions

View File

@ -9,6 +9,7 @@ pub const Library = @import("Library.zig");
pub const Error = errors.Error;
pub const Face = face.Face;
pub const LoadFlags = face.LoadFlags;
pub const Tag = tag.Tag;
pub const mulFix = computations.mulFix;

View File

@ -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(),
);
}

View File

@ -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.

View File

@ -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 .{