font(freetype): constrain emoji to cell width (#6602)

When scaling emoji (with freetype), we would unilaterally scale the
bitmap to fit within the `cell_height`. For narrow fonts, this would
result in a horizontal overflow:


![image](https://github.com/user-attachments/assets/87b9f952-6f12-40b2-bbed-5bfe948f45b4)

Modify the glyph rendering such that we scale to fit within the cell
width. After doing so, the above image looks like:


![image](https://github.com/user-attachments/assets/c75bfa51-4730-4179-b032-c3afa7840d65)

The emoji glyph is noticeably smaller because we have constrained the
height further than before, but fits perfectly within two cells. I am
using Victor Mono as my font, which is pretty narrow. The effect would
be even more pronounced on something like Iosevka.
This commit is contained in:
Mitchell Hashimoto
2025-03-09 18:34:34 -07:00
committed by GitHub
2 changed files with 67 additions and 51 deletions

View File

@ -390,13 +390,28 @@ pub const Face = struct {
// and copy the atlas. // and copy the atlas.
const bitmap_original = bitmap_converted orelse bitmap_ft; const bitmap_original = bitmap_converted orelse bitmap_ft;
const bitmap_resized: ?freetype.c.struct_FT_Bitmap_ = resized: { const bitmap_resized: ?freetype.c.struct_FT_Bitmap_ = resized: {
const max = metrics.cell_height; const original_width = bitmap_original.width;
const bm = bitmap_original; const original_height = bitmap_original.rows;
if (bm.rows <= max) break :resized null; var result = bitmap_original;
// TODO: We are limiting this to only emoji. We can rework this after a future
// improvement (promised by Qwerasd) which implements more flexible resizing rules. For
// now, this will suffice
if (self.isColorGlyph(glyph_index) and opts.cell_width != null) {
const cell_width = opts.cell_width orelse unreachable;
// If we have a cell_width, we constrain the glyph to fit within the cell
result.width = metrics.cell_width * @as(u32, cell_width);
result.rows = (result.width * original_height) / original_width;
} else {
// If we don't have a cell_width, we scale to fill vertically
result.rows = metrics.cell_height;
result.width = (metrics.cell_height * original_width) / original_height;
}
// If we already fit, we don't need to resize
if (original_height <= result.rows and original_width <= result.width) {
break :resized null;
}
var result = bm;
result.rows = max;
result.width = (result.rows * bm.width) / bm.rows;
result.pitch = @as(c_int, @intCast(result.width)) * atlas.format.depth(); result.pitch = @as(c_int, @intCast(result.width)) * atlas.format.depth();
const buf = try alloc.alloc( const buf = try alloc.alloc(
@ -407,10 +422,10 @@ pub const Face = struct {
errdefer alloc.free(buf); errdefer alloc.free(buf);
if (stb.stbir_resize_uint8( if (stb.stbir_resize_uint8(
bm.buffer, bitmap_original.buffer,
@intCast(bm.width), @intCast(original_width),
@intCast(bm.rows), @intCast(original_height),
bm.pitch, bitmap_original.pitch,
result.buffer, result.buffer,
@intCast(result.width), @intCast(result.width),
@intCast(result.rows), @intCast(result.rows),
@ -520,7 +535,7 @@ pub const Face = struct {
// NOTE(mitchellh): I don't know if this is right, this doesn't // NOTE(mitchellh): I don't know if this is right, this doesn't
// _feel_ right, but it makes all my limited test cases work. // _feel_ right, but it makes all my limited test cases work.
if (self.face.hasColor() and !self.face.isScalable()) { if (self.face.hasColor() and !self.face.isScalable()) {
break :offset_y @intCast(tgt_h); break :offset_y @intCast(tgt_h + (metrics.cell_height -| tgt_h) / 2);
} }
// The Y offset is the offset of the top of our bitmap PLUS our // The Y offset is the offset of the top of our bitmap PLUS our

View File

@ -1392,29 +1392,29 @@ pub fn rebuildCells(
// Try to read the cells from the shaping cache if we can. // Try to read the cells from the shaping cache if we can.
self.font_shaper_cache.get(run) orelse self.font_shaper_cache.get(run) orelse
cache: { cache: {
// Otherwise we have to shape them. // Otherwise we have to shape them.
const cells = try self.font_shaper.shape(run); const cells = try self.font_shaper.shape(run);
// Try to cache them. If caching fails for any reason we // Try to cache them. If caching fails for any reason we
// continue because it is just a performance optimization, // continue because it is just a performance optimization,
// not a correctness issue. // not a correctness issue.
self.font_shaper_cache.put( self.font_shaper_cache.put(
self.alloc, self.alloc,
run, run,
cells, cells,
) catch |err| { ) catch |err| {
log.warn( log.warn(
"error caching font shaping results err={}", "error caching font shaping results err={}",
.{err}, .{err},
); );
};
// The cells we get from direct shaping are always owned
// by the shaper and valid until the next shaping call so
// we can safely use them.
break :cache cells;
}; };
// The cells we get from direct shaping are always owned
// by the shaper and valid until the next shaping call so
// we can safely use them.
break :cache cells;
};
// Advance our index until we reach or pass // Advance our index until we reach or pass
// our current x position in the shaper cells. // our current x position in the shaper cells.
while (shaper_cells.?[shaper_cells_i].x < x) { while (shaper_cells.?[shaper_cells_i].x < x) {
@ -1637,29 +1637,29 @@ pub fn rebuildCells(
// Try to read the cells from the shaping cache if we can. // Try to read the cells from the shaping cache if we can.
self.font_shaper_cache.get(run) orelse self.font_shaper_cache.get(run) orelse
cache: { cache: {
// Otherwise we have to shape them. // Otherwise we have to shape them.
const cells = try self.font_shaper.shape(run); const cells = try self.font_shaper.shape(run);
// Try to cache them. If caching fails for any reason we // Try to cache them. If caching fails for any reason we
// continue because it is just a performance optimization, // continue because it is just a performance optimization,
// not a correctness issue. // not a correctness issue.
self.font_shaper_cache.put( self.font_shaper_cache.put(
self.alloc, self.alloc,
run, run,
cells, cells,
) catch |err| { ) catch |err| {
log.warn( log.warn(
"error caching font shaping results err={}", "error caching font shaping results err={}",
.{err}, .{err},
); );
};
// The cells we get from direct shaping are always owned
// by the shaper and valid until the next shaping call so
// we can safely use them.
break :cache cells;
}; };
// The cells we get from direct shaping are always owned
// by the shaper and valid until the next shaping call so
// we can safely use them.
break :cache cells;
};
const cells = shaper_cells orelse break :glyphs; const cells = shaper_cells orelse break :glyphs;
// If there are no shaper cells for this run, ignore it. // If there are no shaper cells for this run, ignore it.
@ -2105,6 +2105,7 @@ fn addGlyph(
shaper_run.font_index, shaper_run.font_index,
shaper_cell.glyph_index, shaper_cell.glyph_index,
.{ .{
.cell_width = if (cell.wide == .wide) 2 else 1,
.grid_metrics = self.grid_metrics, .grid_metrics = self.grid_metrics,
.thicken = self.config.font_thicken, .thicken = self.config.font_thicken,
.thicken_strength = self.config.font_thicken_strength, .thicken_strength = self.config.font_thicken_strength,