font: add constraint logic to rasterizers

This is in preparation to move constraint off the GPU to simplify our
shaders, instead we only need to constrain once at raster time and never
again.

This also significantly reworks the freetype renderGlyph function to be
generally much cleaner and more straightforward.

This commit doesn't actually apply the constraints to anything yet, that
will be in following commits.
This commit is contained in:
Qwerasd
2025-07-03 16:02:12 -06:00
parent d751a93ecf
commit e441094af0
6 changed files with 608 additions and 321 deletions

View File

@ -5,3 +5,5 @@
#include <freetype/ftoutln.h> #include <freetype/ftoutln.h>
#include <freetype/ftsnames.h> #include <freetype/ftsnames.h>
#include <freetype/ttnameid.h> #include <freetype/ttnameid.h>
#include <freetype/ftbitmap.h>
#include <freetype/ftbbox.h>

View File

@ -141,6 +141,22 @@ pub fn Context(comptime T: type) type {
@bitCast(rect), @bitCast(rect),
); );
} }
pub fn scaleCTM(self: *T, sx: c.CGFloat, sy: c.CGFloat) void {
c.CGContextScaleCTM(
@ptrCast(self),
sx,
sy,
);
}
pub fn translateCTM(self: *T, tx: c.CGFloat, ty: c.CGFloat) void {
c.CGContextTranslateCTM(
@ptrCast(self),
tx,
ty,
);
}
}; };
} }

View File

@ -425,13 +425,16 @@ pub const compatibility = std.StaticStringMap(
/// ///
/// Available flags: /// Available flags:
/// ///
/// * `hinting` - Enable or disable hinting, enabled by default. /// * `hinting` - Enable or disable hinting. Enabled by default.
/// * `force-autohint` - Use the freetype auto-hinter rather than the ///
/// font's native hinter. Enabled by default. /// * `force-autohint` - Always use the freetype auto-hinter instead of
/// * `monochrome` - Instructs renderer to use 1-bit monochrome /// the font's native hinter. Enabled by default.
/// rendering. This option doesn't impact the hinter. ///
/// Enabled by default. /// * `monochrome` - Instructs renderer to use 1-bit monochrome rendering.
/// * `autohint` - Use the freetype auto-hinter. Enabled by default. /// This will disable anti-aliasing, and probably not look very good unless
/// you're using a pixel font. Disabled by default.
///
/// * `autohint` - Enable the freetype auto-hinter. Enabled by default.
/// ///
/// Example: `hinting`, `no-hinting`, `force-autohint`, `no-force-autohint` /// Example: `hinting`, `no-hinting`, `force-autohint`, `no-force-autohint`
@"freetype-load-flags": FreetypeLoadFlags = .{}, @"freetype-load-flags": FreetypeLoadFlags = .{},
@ -6961,7 +6964,7 @@ pub const FreetypeLoadFlags = packed struct {
// to these defaults. // to these defaults.
hinting: bool = true, hinting: bool = true,
@"force-autohint": bool = true, @"force-autohint": bool = true,
monochrome: bool = true, monochrome: bool = false,
autohint: bool = true, autohint: bool = true,
}; };

View File

@ -94,6 +94,17 @@ pub const RenderOptions = struct {
/// optionally by the rasterizer to better layout the glyph. /// optionally by the rasterizer to better layout the glyph.
cell_width: ?u2 = null, cell_width: ?u2 = null,
/// Constraint and alignment properties for the glyph. The rasterizer
/// should call the `constrain` function on this with the original size
/// and bearings of the glyph to get remapped values that the glyph
/// should be scaled/moved to.
constraint: Constraint = .none,
/// The number of cells, horizontally that the glyph is free to take up
/// when resized and aligned by `constraint`. This is usually 1, but if
/// there's whitespace to the right of the cell then it can be 2.
constraint_width: u2 = 1,
/// Thicken the glyph. This draws the glyph with a thicker stroke width. /// Thicken the glyph. This draws the glyph with a thicker stroke width.
/// This is purely an aesthetic setting. /// This is purely an aesthetic setting.
/// ///
@ -108,6 +119,198 @@ pub const RenderOptions = struct {
/// ///
/// CoreText only. /// CoreText only.
thicken_strength: u8 = 255, thicken_strength: u8 = 255,
/// See the `constraint` field.
pub const Constraint = struct {
/// Don't constrain the glyph in any way.
pub const none: Constraint = .{};
/// Vertical sizing rule.
size_vertical: Size = .none,
/// Horizontal sizing rule.
size_horizontal: Size = .none,
/// Vertical alignment rule.
align_vertical: Align = .none,
/// Horizontal alignment rule.
align_horizontal: Align = .none,
/// Top padding when resizing.
pad_top: f64 = 0.0,
/// Left padding when resizing.
pad_left: f64 = 0.0,
/// Right padding when resizing.
pad_right: f64 = 0.0,
/// Bottom padding when resizing.
pad_bottom: f64 = 0.0,
/// Maximum ratio of width to height when resizing.
max_xy_ratio: ?f64 = null,
pub const Size = enum {
/// Don't change the size of this glyph.
none,
/// Move the glyph and optionally scale it down
/// proportionally to fit within the given axis.
fit,
/// Move and resize the glyph proportionally to
/// cover the given axis.
cover,
/// Same as `cover` but not proportional.
stretch,
};
pub const Align = enum {
/// Don't move the glyph on this axis.
none,
/// Move the glyph so that its leading (bottom/left)
/// edge aligns with the leading edge of the axis.
start,
/// Move the glyph so that its trailing (top/right)
/// edge aligns with the trailing edge of the axis.
end,
/// Move the glyph so that it is centered on this axis.
center,
};
/// The size and position of a glyph.
pub const GlyphSize = struct {
width: f64,
height: f64,
x: f64,
y: f64,
};
/// Apply this constraint to the provided glyph
/// size, given the available width and height.
pub fn constrain(
self: Constraint,
glyph: GlyphSize,
/// Available width
cell_width: f64,
/// Available height
cell_height: f64,
) GlyphSize {
var g = glyph;
const w = cell_width -
self.pad_left * cell_width -
self.pad_right * cell_width;
const h = cell_height -
self.pad_top * cell_height -
self.pad_bottom * cell_height;
// Subtract padding from the bearings so that our
// alignment and sizing code works correctly. We
// re-add before returning.
g.x -= self.pad_left * cell_width;
g.y -= self.pad_bottom * cell_height;
switch (self.size_horizontal) {
.none => {},
.fit => if (g.width > w) {
const orig_height = g.height;
// Adjust our height and width to proportionally
// scale them to fit the glyph to the cell width.
g.height *= w / g.width;
g.width = w;
// Set our x to 0 since anything else would mean
// the glyph extends outside of the cell width.
g.x = 0;
// Compensate our y to keep things vertically
// centered as they're scaled down.
g.y += (orig_height - g.height) / 2;
} else if (g.width + g.x > w) {
// If the width of the glyph can fit in the cell but
// is currently outside due to the left bearing, then
// we reduce the left bearing just enough to fit it
// back in the cell.
g.x = w - g.width;
} else if (g.x < 0) {
g.x = 0;
},
.cover => {
const orig_height = g.height;
g.height *= w / g.width;
g.width = w;
g.x = 0;
g.y += (orig_height - g.height) / 2;
},
.stretch => {
g.width = w;
g.x = 0;
},
}
switch (self.size_vertical) {
.none => {},
.fit => if (g.height > h) {
const orig_width = g.width;
// Adjust our height and width to proportionally
// scale them to fit the glyph to the cell height.
g.width *= h / g.height;
g.height = h;
// Set our y to 0 since anything else would mean
// the glyph extends outside of the cell height.
g.y = 0;
// Compensate our x to keep things horizontally
// centered as they're scaled down.
g.x += (orig_width - g.width) / 2;
} else if (g.height + g.y > h) {
// If the height of the glyph can fit in the cell but
// is currently outside due to the bottom bearing, then
// we reduce the bottom bearing just enough to fit it
// back in the cell.
g.y = h - g.height;
} else if (g.y < 0) {
g.y = 0;
},
.cover => {
const orig_width = g.width;
g.width *= h / g.height;
g.height = h;
g.y = 0;
g.x += (orig_width - g.width) / 2;
},
.stretch => {
g.height = h;
g.y = 0;
},
}
if (self.max_xy_ratio) |ratio| if (g.width > g.height * ratio) {
const orig_width = g.width;
g.width = g.height * ratio;
g.x += (orig_width - g.width) / 2;
};
switch (self.align_horizontal) {
.none => {},
.start => g.x = 0,
.end => g.x = w - g.width,
.center => g.x = (w - g.width) / 2,
}
switch (self.align_vertical) {
.none => {},
.start => g.y = 0,
.end => g.y = h - g.height,
.center => g.y = (h - g.height) / 2,
}
// Re-add our padding before returning.
g.x += self.pad_left * cell_width;
g.y += self.pad_bottom * cell_height;
return g;
}
};
}; };
test { test {

View File

@ -291,22 +291,29 @@ pub const Face = struct {
// in the bottom left and +Y pointing up. // in the bottom left and +Y pointing up.
var rect = self.font.getBoundingRectsForGlyphs(.horizontal, &glyphs, null); var rect = self.font.getBoundingRectsForGlyphs(.horizontal, &glyphs, null);
// Determine whether this is a color glyph.
const is_color = self.isColorGlyph(glyph_index);
// And whether it's (probably) a bitmap (sbix).
const sbix = is_color and self.color != null and self.color.?.sbix;
// If we're rendering a synthetic bold then we will gain 50% of // If we're rendering a synthetic bold then we will gain 50% of
// the line width on every edge, which means we should increase // the line width on every edge, which means we should increase
// our width and height by the line width and subtract half from // our width and height by the line width and subtract half from
// our origin points. // our origin points.
if (self.synthetic_bold) |line_width| { //
// We don't add extra size if it's a sbix color font though,
// since bitmaps aren't affected by synthetic bold.
if (!sbix) if (self.synthetic_bold) |line_width| {
rect.size.width += line_width; rect.size.width += line_width;
rect.size.height += line_width; rect.size.height += line_width;
rect.origin.x -= line_width / 2; rect.origin.x -= line_width / 2;
rect.origin.y -= line_width / 2; rect.origin.y -= line_width / 2;
} };
// We make an assumption that font smoothing ("thicken") // We make an assumption that font smoothing ("thicken")
// adds no more than 1 extra pixel to any edge. We don't // adds no more than 1 extra pixel to any edge. We don't
// add extra size if it's a sbix color font though, since // add extra size if it's a sbix color font though, since
// bitmaps aren't affected by smoothing. // bitmaps aren't affected by smoothing.
const sbix = self.color != null and self.color.?.sbix;
if (opts.thicken and !sbix) { if (opts.thicken and !sbix) {
rect.size.width += 2.0; rect.size.width += 2.0;
rect.size.height += 2.0; rect.size.height += 2.0;
@ -314,18 +321,12 @@ pub const Face = struct {
rect.origin.y -= 1.0; rect.origin.y -= 1.0;
} }
// We compute the minimum and maximum x and y values. // If our rect is smaller than a quarter pixel in either axis
// We round our min points down and max points up. // then it has no outlines or they're too small to render.
const x0: i32, const x1: i32, const y0: i32, const y1: i32 = .{ //
@intFromFloat(@floor(rect.origin.x)), // In this case we just return 0-sized glyph struct.
@intFromFloat(@ceil(rect.origin.x) + @ceil(rect.size.width)), if (rect.size.width < 0.25 or rect.size.height < 0.25)
@intFromFloat(@floor(rect.origin.y)), return font.Glyph{
@intFromFloat(@ceil(rect.origin.y) + @ceil(rect.size.height)),
};
// This bitmap is blank. I've seen it happen in a font, I don't know why.
// If it is empty, we just return a valid glyph struct that does nothing.
if (x1 <= x0 or y1 <= y0) return font.Glyph{
.width = 0, .width = 0,
.height = 0, .height = 0,
.offset_x = 0, .offset_x = 0,
@ -335,8 +336,28 @@ pub const Face = struct {
.advance_x = 0, .advance_x = 0,
}; };
const width: u32 = @intCast(x1 - x0); const metrics = opts.grid_metrics;
const height: u32 = @intCast(y1 - y0); const cell_width: f64 = @floatFromInt(metrics.cell_width * opts.constraint_width);
const cell_height: f64 = @floatFromInt(metrics.cell_height);
const glyph_size = opts.constraint.constrain(
.{
.width = rect.size.width,
.height = rect.size.height,
.x = rect.origin.x,
.y = rect.origin.y + @as(f64, @floatFromInt(metrics.cell_baseline)),
},
cell_width,
cell_height,
);
const width = glyph_size.width;
const height = glyph_size.height;
const x = glyph_size.x;
const y = glyph_size.y;
const px_width: u32 = @intFromFloat(@ceil(width));
const px_height: u32 = @intFromFloat(@ceil(height));
// Settings that are specific to if we are rendering text or emoji. // Settings that are specific to if we are rendering text or emoji.
const color: struct { const color: struct {
@ -344,7 +365,7 @@ pub const Face = struct {
depth: u32, depth: u32,
space: *macos.graphics.ColorSpace, space: *macos.graphics.ColorSpace,
context_opts: c_uint, context_opts: c_uint,
} = if (!self.isColorGlyph(glyph_index)) .{ } = if (!is_color) .{
.color = false, .color = false,
.depth = 1, .depth = 1,
.space = try macos.graphics.ColorSpace.createNamed(.linearGray), .space = try macos.graphics.ColorSpace.createNamed(.linearGray),
@ -371,17 +392,17 @@ pub const Face = struct {
// usually stabilizes pretty quickly and is very infrequent so I think // usually stabilizes pretty quickly and is very infrequent so I think
// the allocation overhead is acceptable compared to the cost of // the allocation overhead is acceptable compared to the cost of
// caching it forever or having to deal with a cache lifetime. // caching it forever or having to deal with a cache lifetime.
const buf = try alloc.alloc(u8, width * height * color.depth); const buf = try alloc.alloc(u8, px_width * px_height * color.depth);
defer alloc.free(buf); defer alloc.free(buf);
@memset(buf, 0); @memset(buf, 0);
const context = macos.graphics.BitmapContext.context; const context = macos.graphics.BitmapContext.context;
const ctx = try macos.graphics.BitmapContext.create( const ctx = try macos.graphics.BitmapContext.create(
buf, buf,
width, px_width,
height, px_height,
8, 8,
width * color.depth, px_width * color.depth,
color.space, color.space,
color.context_opts, color.context_opts,
); );
@ -390,14 +411,14 @@ pub const Face = struct {
// Perform an initial fill. This ensures that we don't have any // Perform an initial fill. This ensures that we don't have any
// uninitialized pixels in the bitmap. // uninitialized pixels in the bitmap.
if (color.color) if (color.color)
context.setRGBFillColor(ctx, 1, 1, 1, 0) context.setRGBFillColor(ctx, 0, 0, 0, 0)
else else
context.setGrayFillColor(ctx, 1, 0); context.setGrayFillColor(ctx, 0, 0);
context.fillRect(ctx, .{ context.fillRect(ctx, .{
.origin = .{ .x = 0, .y = 0 }, .origin = .{ .x = 0, .y = 0 },
.size = .{ .size = .{
.width = @floatFromInt(width), .width = @floatFromInt(px_width),
.height = @floatFromInt(height), .height = @floatFromInt(px_height),
}, },
}); });
@ -427,49 +448,34 @@ pub const Face = struct {
context.setLineWidth(ctx, line_width); context.setLineWidth(ctx, line_width);
} }
context.scaleCTM(
ctx,
width / rect.size.width,
height / rect.size.height,
);
// We want to render the glyphs at (0,0), but the glyphs themselves // We want to render the glyphs at (0,0), but the glyphs themselves
// are offset by bearings, so we have to undo those bearings in order // are offset by bearings, so we have to undo those bearings in order
// to get them to 0,0. // to get them to 0,0.
self.font.drawGlyphs(&glyphs, &.{ self.font.drawGlyphs(&glyphs, &.{.{
.{ .x = -@floor(rect.origin.x),
.x = @floatFromInt(-x0), .y = -@floor(rect.origin.y),
.y = @floatFromInt(-y0), }}, ctx);
},
}, ctx);
const region = region: { // Write our rasterized glyph to the atlas.
// We reserve a region that's 1px wider and taller than we need const region = try atlas.reserve(alloc, px_width, px_height);
// in order to create a 1px separation between adjacent glyphs
// to prevent interpolation with adjacent glyphs while sampling
// from the atlas.
var region = try atlas.reserve(
alloc,
width + 1,
height + 1,
);
// We adjust the region width and height back down since we
// don't need the extra pixel, we just needed to reserve it
// so that it isn't used for other glyphs in the future.
region.width -= 1;
region.height -= 1;
break :region region;
};
atlas.set(region, buf); atlas.set(region, buf);
const metrics = opts.grid_metrics;
// This should be the distance from the bottom of // This should be the distance from the bottom of
// the cell to the top of the glyph's bounding box. // the cell to the top of the glyph's bounding box.
// const offset_y: i32 =
// The calculation is distance from bottom of cell to @as(i32, @intFromFloat(@floor(y))) +
// baseline plus distance from baseline to top of glyph. @as(i32, @intCast(px_height));
const offset_y: i32 = @as(i32, @intCast(metrics.cell_baseline)) + y1;
// This should be the distance from the left of // This should be the distance from the left of
// the cell to the left of the glyph's bounding box. // the cell to the left of the glyph's bounding box.
const offset_x: i32 = offset_x: { const offset_x: i32 = offset_x: {
var result: i32 = x0; var result: i32 = @intFromFloat(@round(x));
// If our cell was resized then we adjust our glyph's // If our cell was resized then we adjust our glyph's
// position relative to the new center. This keeps glyphs // position relative to the new center. This keeps glyphs
@ -490,8 +496,8 @@ pub const Face = struct {
_ = self.font.getAdvancesForGlyphs(.horizontal, &glyphs, &advances); _ = self.font.getAdvancesForGlyphs(.horizontal, &glyphs, &advances);
return .{ return .{
.width = width, .width = px_width,
.height = height, .height = px_height,
.offset_x = offset_x, .offset_x = offset_x,
.offset_y = offset_y, .offset_y = offset_y,
.atlas_x = region.x, .atlas_x = region.x,

View File

@ -21,6 +21,8 @@ const fastmem = @import("../../fastmem.zig");
const quirks = @import("../../quirks.zig"); const quirks = @import("../../quirks.zig");
const config = @import("../../config.zig"); const config = @import("../../config.zig");
const F26Dot6 = opentype.sfnt.F26Dot6;
const log = std.log.scoped(.font_face); const log = std.log.scoped(.font_face);
pub const Face = struct { pub const Face = struct {
@ -58,14 +60,6 @@ pub const Face = struct {
bold: bool = false, bold: bool = false,
} = .{}, } = .{},
/// The matrix applied to a regular font to create a synthetic italic.
const italic_matrix: freetype.c.FT_Matrix = .{
.xx = 0x10000,
.xy = 0x044ED, // approx. tan(15)
.yx = 0,
.yy = 0x10000,
};
/// Initialize a new font face with the given source in-memory. /// Initialize a new font face with the given source in-memory.
pub fn initFile( pub fn initFile(
lib: Library, lib: Library,
@ -330,26 +324,32 @@ pub const Face = struct {
self.ft_mutex.lock(); self.ft_mutex.lock();
defer self.ft_mutex.unlock(); defer self.ft_mutex.unlock();
const metrics = opts.grid_metrics; // We enable hinting by default, and disable it if either of the
// constraint alignments are not center or none, since this means
// that the glyph needs to be aligned flush to the cell edge, and
// hinting can mess that up.
const do_hinting = self.load_flags.hinting and
switch (opts.constraint.align_horizontal) {
.start, .end => false,
.center, .none => true,
} and
switch (opts.constraint.align_vertical) {
.start, .end => false,
.center, .none => true,
};
// If we have synthetic italic, then we apply a transformation matrix. // Load the glyph.
// We have to undo this because synthetic italic works by increasing
// the ref count of the base face.
if (self.synthetic.italic) self.face.setTransform(&italic_matrix, null);
defer if (self.synthetic.italic) self.face.setTransform(null, null);
// If our glyph has color, we want to render the color
try self.face.loadGlyph(glyph_index, .{ try self.face.loadGlyph(glyph_index, .{
// If our glyph has color, we want to render the color
.color = self.face.hasColor(), .color = self.face.hasColor(),
// If we have synthetic bold, we have to set some additional // We don't render, because we'll invoke the render
// glyph properties before render so we don't render here. // manually after applying constraints further down.
.render = !self.synthetic.bold, .render = false,
// use options from config // use options from config
.no_hinting = !self.load_flags.hinting, .no_hinting = !do_hinting,
.force_autohint = !self.load_flags.@"force-autohint", .force_autohint = !self.load_flags.@"force-autohint",
.monochrome = !self.load_flags.monochrome,
.no_autohint = !self.load_flags.autohint, .no_autohint = !self.load_flags.autohint,
// NO_SVG set to true because we don't currently support rendering // NO_SVG set to true because we don't currently support rendering
@ -359,22 +359,15 @@ pub const Face = struct {
}); });
const glyph = self.face.handle.*.glyph; const glyph = self.face.handle.*.glyph;
// For synthetic bold, we embolden the glyph and render it. const glyph_width: f64 = f26dot6ToF64(glyph.*.metrics.width);
if (self.synthetic.bold) { const glyph_height: f64 = f26dot6ToF64(glyph.*.metrics.height);
// We need to scale the embolden amount based on the font size.
// This is a heuristic I found worked well across a variety of
// founts: 1 pixel per 64 units of height.
const height: f64 = @floatFromInt(self.face.handle.*.size.*.metrics.height);
const ratio: f64 = 64.0 / 2048.0;
const amount = @ceil(height * ratio);
_ = freetype.c.FT_Outline_Embolden(&glyph.*.outline, @intFromFloat(amount));
try self.face.renderGlyph(.normal);
}
// This bitmap is blank. I've seen it happen in a font, I don't know why. // If our glyph is smaller than a quarter pixel in either axis
// If it is empty, we just return a valid glyph struct that does nothing. // then it has no outlines or they're too small to render.
const bitmap_ft = glyph.*.bitmap; //
if (bitmap_ft.rows == 0) return .{ // In this case we just return 0-sized glyph struct.
if (glyph_width < 0.25 or glyph_height < 0.25)
return font.Glyph{
.width = 0, .width = 0,
.height = 0, .height = 0,
.offset_x = 0, .offset_x = 0,
@ -384,235 +377,292 @@ pub const Face = struct {
.advance_x = 0, .advance_x = 0,
}; };
// Ensure we know how to work with the font format. And assure that // For synthetic bold, we embolden the glyph.
// or color depth is as expected on the texture atlas. If format is null if (self.synthetic.bold) {
// it means there is no native color format for our Atlas and we must try // We need to scale the embolden amount based on the font size.
// conversion. // This is a heuristic I found worked well across a variety of
const format: ?font.Atlas.Format = switch (bitmap_ft.pixel_mode) { // founts: 1 pixel per 64 units of height.
freetype.c.FT_PIXEL_MODE_MONO => null, const font_height: f64 = @floatFromInt(self.face.handle.*.size.*.metrics.height);
freetype.c.FT_PIXEL_MODE_GRAY => .grayscale, const ratio: f64 = 64.0 / 2048.0;
freetype.c.FT_PIXEL_MODE_BGRA => .bgra, const amount = @ceil(font_height * ratio);
else => { _ = freetype.c.FT_Outline_Embolden(&glyph.*.outline, @intFromFloat(amount));
log.warn("glyph={} pixel mode={}", .{ glyph_index, bitmap_ft.pixel_mode }); }
@panic("unsupported pixel mode");
// Next we need to apply any constraints.
const metrics = opts.grid_metrics;
const cell_width: f64 = @floatFromInt(metrics.cell_width * opts.constraint_width);
const cell_height: f64 = @floatFromInt(metrics.cell_height);
const glyph_x: f64 = f26dot6ToF64(glyph.*.metrics.horiBearingX);
const glyph_y: f64 = f26dot6ToF64(glyph.*.metrics.horiBearingY) - glyph_height;
const glyph_size = opts.constraint.constrain(
.{
.width = glyph_width,
.height = glyph_height,
.x = glyph_x,
.y = glyph_y + @as(f64, @floatFromInt(metrics.cell_baseline)),
}, },
}; cell_width,
cell_height,
// If our atlas format doesn't match, look for conversions if possible.
const bitmap_converted = if (format == null or atlas.format != format.?) blk: {
const func = convert.map[bitmap_ft.pixel_mode].get(atlas.format) orelse {
log.warn("glyph={} pixel mode={}", .{ glyph_index, bitmap_ft.pixel_mode });
return error.UnsupportedPixelMode;
};
log.debug("converting from pixel_mode={} to atlas_format={}", .{
bitmap_ft.pixel_mode,
atlas.format,
});
break :blk try func(alloc, bitmap_ft);
} else null;
defer if (bitmap_converted) |bm| {
const len = @as(usize, @intCast(bm.pitch)) * @as(usize, @intCast(bm.rows));
alloc.free(bm.buffer[0..len]);
};
// Now we need to see if we need to resize this bitmap. This can happen
// in scenarios where we have fixed size glyphs. For example, emoji
// can be quite large (i.e. 128x128) when we have a cell width of 24!
// The issue with large bitmaps is they take a huge amount of space in
// the atlas and force resizes quite frequently. We pay some CPU cost
// up front to resize the glyph to avoid significant CPU cost to resize
// and copy the atlas.
const bitmap_original = bitmap_converted orelse bitmap_ft;
const bitmap_resized: ?freetype.c.struct_FT_Bitmap_ = resized: {
const original_width = bitmap_original.width;
const original_height = bitmap_original.rows;
var result = bitmap_original;
// TODO: We are limiting this to only color glyphs, so mainly emoji.
// We can rework this after a future improvement (promised by Qwerasd)
// which implements more flexible resizing rules.
if (atlas.format != .grayscale 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(s).
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;
}
result.pitch = @as(c_int, @intCast(result.width)) * atlas.format.depth();
const buf = try alloc.alloc(
u8,
@as(usize, @intCast(result.pitch)) * @as(usize, @intCast(result.rows)),
); );
result.buffer = buf.ptr;
errdefer alloc.free(buf);
const width = glyph_size.width;
const height = glyph_size.height;
// This may need to be adjusted later on.
var x = glyph_size.x;
const y = glyph_size.y;
// Now we can render the glyph.
var bitmap: freetype.c.FT_Bitmap = undefined;
_ = freetype.c.FT_Bitmap_Init(&bitmap);
defer _ = freetype.c.FT_Bitmap_Done(self.lib.lib.handle, &bitmap);
switch (glyph.*.format) {
freetype.c.FT_GLYPH_FORMAT_OUTLINE => {
// Manually adjust the glyph outline with this transform.
//
// This offers better precision than using the freetype transform
// matrix, since that has 16.16 coefficients, and also I was having
// weird issues that I can only assume where due to freetype doing
// some bad caching or something when I did this using the matrix.
const scale_x = width / glyph_width;
const scale_y = height / glyph_height;
const skew: f64 =
if (self.synthetic.italic)
// We skew by 12 degrees to synthesize italics.
@tan(std.math.degreesToRadians(12))
else
0.0;
var bbox_before: freetype.c.FT_BBox = undefined;
_ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox_before);
const outline = &glyph.*.outline;
for (outline.points[0..@intCast(outline.n_points)]) |*p| {
// Convert to f64 for processing
var px = f26dot6ToF64(p.x);
var py = f26dot6ToF64(p.y);
// Scale
px *= scale_x;
py *= scale_y;
// Skew
px += py * skew;
// Convert back and store
p.x = @as(i32, @bitCast(F26Dot6.from(px)));
p.y = @as(i32, @bitCast(F26Dot6.from(py)));
}
var bbox_after: freetype.c.FT_BBox = undefined;
_ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox_after);
// If our bounding box changed, account for the lsb difference.
//
// This can happen when we skew glyphs that have a bit sticking
// out to the left higher up, like the top of the T or the serif
// on the lower case l in many monospace fonts.
x += f26dot6ToF64(bbox_after.xMin) - f26dot6ToF64(bbox_before.xMin);
try self.face.renderGlyph(
if (self.load_flags.monochrome)
.mono
else
.normal,
);
// Copy the glyph's bitmap, making sure
// that it's 8bpp and densely packed.
if (freetype.c.FT_Bitmap_Convert(
self.lib.lib.handle,
&glyph.*.bitmap,
&bitmap,
1,
) != 0) {
return error.BitmapHandlingError;
}
},
freetype.c.FT_GLYPH_FORMAT_BITMAP => {
// If our glyph has a non-color bitmap, we need
// to convert it to dense 8bpp so that the scale
// operation works correctly.
switch (glyph.*.bitmap.pixel_mode) {
freetype.c.FT_PIXEL_MODE_BGRA,
freetype.c.FT_PIXEL_MODE_GRAY,
=> {},
else => {
var converted: freetype.c.FT_Bitmap = undefined;
freetype.c.FT_Bitmap_Init(&converted);
if (freetype.c.FT_Bitmap_Convert(
self.lib.lib.handle,
&glyph.*.bitmap,
&converted,
1,
) != 0) {
return error.BitmapHandlingError;
}
// Free the existing glyph bitmap and
// replace it with the converted one.
_ = freetype.c.FT_Bitmap_Done(
self.lib.lib.handle,
&glyph.*.bitmap,
);
glyph.*.bitmap = converted;
},
}
const glyph_bitmap = glyph.*.bitmap;
// Round our target width and height
// as the size for our scaled bitmap.
const w: u32 = @intFromFloat(@round(width));
const h: u32 = @intFromFloat(@round(height));
const pitch = w * atlas.format.depth();
// Allocate a buffer for our scaled bitmap.
//
// We'll copy this to the original bitmap once we're
// done so we can free it at the end of this scope.
const buf = try alloc.alloc(u8, pitch * h);
defer alloc.free(buf);
// Resize
if (stb.stbir_resize_uint8( if (stb.stbir_resize_uint8(
bitmap_original.buffer, glyph_bitmap.buffer,
@intCast(original_width), @intCast(glyph_bitmap.width),
@intCast(original_height), @intCast(glyph_bitmap.rows),
bitmap_original.pitch, glyph_bitmap.pitch,
result.buffer, buf.ptr,
@intCast(result.width), @intCast(w),
@intCast(result.rows), @intCast(h),
result.pitch, @intCast(pitch),
atlas.format.depth(), atlas.format.depth(),
) == 0) { ) == 0) {
// This should never fail because this is a fairly straightforward // This should never fail because this is a
// in-memory operation... // fairly straightforward in-memory operation...
return error.GlyphResizeFailed; return error.GlyphResizeFailed;
} }
break :resized result; const scaled_bitmap: freetype.c.FT_Bitmap = .{
}; .buffer = buf.ptr,
defer if (bitmap_resized) |bm| { .width = @intCast(w),
const len = @as(usize, @intCast(bm.pitch)) * @as(usize, @intCast(bm.rows)); .rows = @intCast(h),
alloc.free(bm.buffer[0..len]); .pitch = @intCast(pitch),
.pixel_mode = glyph_bitmap.pixel_mode,
.num_grays = glyph_bitmap.num_grays,
}; };
const bitmap = bitmap_resized orelse (bitmap_converted orelse bitmap_ft); // Replace the bitmap's buffer and size info.
const tgt_w = bitmap.width; if (freetype.c.FT_Bitmap_Copy(
const tgt_h = bitmap.rows; self.lib.lib.handle,
&scaled_bitmap,
&bitmap,
) != 0) {
return error.BitmapHandlingError;
}
},
// Must have non-empty bitmap because we return earlier else => |f| {
// if zero. We assume the rest of this that it is nont-zero so // Glyph formats are tags, so we can
// this is important. // output a semi-readable error here.
assert(tgt_w > 0 and tgt_h > 0); log.err(
"Can't render glyph with unsupported glyph format \"{s}\"",
.{[4]u8{
@truncate(f >> 24),
@truncate(f >> 16),
@truncate(f >> 8),
@truncate(f >> 0),
}},
);
return error.UnsupportedGlyphFormat;
},
}
// If we resized our bitmap, we need to recalculate some metrics that // If this is a color glyph but we're trying to render it to the
// we use such as the top/left offsets. These need to be scaled by the // grayscale atlas, or vice versa, then we throw and error. Maybe
// same ratio as the resize. // in the future we could convert, but for now it should be fine.
const glyph_metrics = if (bitmap_resized) |bm| metrics: { switch (bitmap.pixel_mode) {
// Our ratio for the resize freetype.c.FT_PIXEL_MODE_GRAY => if (atlas.format != .grayscale) {
const ratio = ratio: { return error.WrongAtlas;
const new: f64 = @floatFromInt(bm.rows); },
const old: f64 = @floatFromInt(bitmap_original.rows); freetype.c.FT_PIXEL_MODE_BGRA => if (atlas.format != .bgra) {
break :ratio new / old; return error.WrongAtlas;
}; },
else => {
log.warn("glyph={} pixel mode={}", .{ glyph_index, bitmap.pixel_mode });
@panic("unsupported pixel mode");
},
}
var copy = glyph.*; const px_width = bitmap.width;
copy.bitmap_top = @as(c_int, @intFromFloat(@round(@as(f64, @floatFromInt(copy.bitmap_top)) * ratio))); const px_height = bitmap.rows;
copy.bitmap_left = @as(c_int, @intFromFloat(@round(@as(f64, @floatFromInt(copy.bitmap_left)) * ratio))); const len: usize = @intCast(
break :metrics copy; @as(c_uint, @intCast(@abs(bitmap.pitch))) * bitmap.rows,
} else glyph.*;
// Allocate our texture atlas region
const region = region: {
// We need to add a 1px padding to the font so that we don't
// get fuzzy issues when blending textures.
const padding = 1;
// Get the full padded region
var region = try atlas.reserve(
alloc,
tgt_w + (padding * 2), // * 2 because left+right
tgt_h + (padding * 2), // * 2 because top+bottom
); );
// Modify the region so that we remove the padding so that // If our bitmap is grayscale, make sure to multiply all pixel
// we write to the non-zero location. The data in an Altlas // values by the right factor to bring `num_grays` up to 256.
// is always initialized to zero (Atlas.clear) so we don't
// need to worry about zero-ing that.
region.x += padding;
region.y += padding;
region.width -= padding * 2;
region.height -= padding * 2;
break :region region;
};
// Copy the image into the region.
assert(region.width > 0 and region.height > 0);
{
const depth = atlas.format.depth();
// We can avoid a buffer copy if our atlas width and bitmap
// width match and the bitmap pitch is just the width (meaning
// the data is tightly packed).
const needs_copy = !(tgt_w == bitmap.width and (bitmap.width * depth) == bitmap.pitch);
// If we need to copy the data, we copy it into a temporary buffer.
const buffer = if (needs_copy) buffer: {
const temp = try alloc.alloc(u8, tgt_w * tgt_h * depth);
var dst_ptr = temp;
var src_ptr = bitmap.buffer;
var i: usize = 0;
while (i < bitmap.rows) : (i += 1) {
fastmem.copy(u8, dst_ptr, src_ptr[0 .. bitmap.width * depth]);
dst_ptr = dst_ptr[tgt_w * depth ..];
src_ptr += @as(usize, @intCast(bitmap.pitch));
}
break :buffer temp;
} else bitmap.buffer[0..(tgt_w * tgt_h * depth)];
defer if (buffer.ptr != bitmap.buffer) alloc.free(buffer);
// Write the glyph information into the atlas
assert(region.width == tgt_w);
assert(region.height == tgt_h);
atlas.set(region, buffer);
}
const offset_y: c_int = offset_y: {
// For non-scalable colorized fonts, we assume they are pictographic
// and just center the glyph. So far this has only applied to emoji
// fonts. Emoji fonts don't always report a correct ascender/descender
// (mainly Apple Emoji) so we just center them. Also, since emoji font
// aren't scalable, cell_baseline is incorrect anyways.
// //
// NOTE(mitchellh): I don't know if this is right, this doesn't // This is necessary because FT_Bitmap_Convert doesn't do this,
// _feel_ right, but it makes all my limited test cases work. // it just sets num_grays to the correct number and uses the
if (self.face.hasColor() and !self.face.isScalable()) { // original smaller pixel values.
break :offset_y @intCast(tgt_h + (metrics.cell_height -| tgt_h) / 2); if (bitmap.pixel_mode == freetype.c.FT_PIXEL_MODE_GRAY and
bitmap.num_grays < 256)
{
const factor: u8 = @intCast(255 / (bitmap.num_grays - 1));
for (bitmap.buffer[0..len]) |*p| {
p.* *= factor;
}
bitmap.num_grays = 256;
} }
// The Y offset is the offset of the top of our bitmap PLUS our // Must have non-empty bitmap because we return earlier if zero.
// baseline calculation. The baseline calculation is so that everything // We assume the rest of this that it is non-zero so this is important.
// is properly centered when we render it out into a monospace grid. assert(px_width > 0 and px_height > 0);
// Note: we add here because our X/Y is actually reversed, adding goes UP.
break :offset_y glyph_metrics.bitmap_top + @as(c_int, @intCast(metrics.cell_baseline));
};
// If this doesn't match then something is wrong.
assert(px_width * atlas.format.depth() == bitmap.pitch);
// Allocate our texture atlas region and copy our bitmap in to it.
const region = try atlas.reserve(alloc, px_width, px_height);
atlas.set(region, bitmap.buffer[0..len]);
// This should be the distance from the bottom of
// the cell to the top of the glyph's bounding box.
const offset_y: i32 =
@as(i32, @intFromFloat(@floor(y))) +
@as(i32, @intCast(px_height));
// This should be the distance from the left of
// the cell to the left of the glyph's bounding box.
const offset_x: i32 = offset_x: { const offset_x: i32 = offset_x: {
var result: i32 = glyph_metrics.bitmap_left; var result: i32 = @intFromFloat(@floor(x));
// If our cell was resized to be wider then we center our // If our cell was resized then we adjust our glyph's
// glyph in the cell. // position relative to the new center. This keeps glyphs
// centered in the cell whether it was made wider or narrower.
if (metrics.original_cell_width) |original_width| { if (metrics.original_cell_width) |original_width| {
if (original_width < metrics.cell_width) { const before: i32 = @intCast(original_width);
const diff = (metrics.cell_width - original_width) / 2; const after: i32 = @intCast(metrics.cell_width);
result += @intCast(diff); // Increase the offset by half of the difference
} // between the widths to keep things centered.
result += @divTrunc(after - before, 2);
} }
break :offset_x result; break :offset_x result;
}; };
// log.warn("renderGlyph width={} height={} offset_x={} offset_y={} glyph_metrics={}", .{
// tgt_w,
// tgt_h,
// glyph_metrics.bitmap_left,
// offset_y,
// glyph_metrics,
// });
// Store glyph metadata
return Glyph{ return Glyph{
.width = tgt_w, .width = px_width,
.height = tgt_h, .height = px_height,
.offset_x = offset_x, .offset_x = offset_x,
.offset_y = offset_y, .offset_y = offset_y,
.atlas_x = region.x, .atlas_x = region.x,
.atlas_y = region.y, .atlas_y = region.y,
.advance_x = f26dot6ToFloat(glyph_metrics.advance.x), .advance_x = f26dot6ToFloat(glyph.*.advance.x),
}; };
} }
@ -631,7 +681,7 @@ pub const Face = struct {
} }
fn f26dot6ToF64(v: freetype.c.FT_F26Dot6) f64 { fn f26dot6ToF64(v: freetype.c.FT_F26Dot6) f64 {
return @as(opentype.sfnt.F26Dot6, @bitCast(@as(u32, @intCast(v)))).to(f64); return @as(F26Dot6, @bitCast(@as(i32, @intCast(v)))).to(f64);
} }
pub const GetMetricsError = error{ pub const GetMetricsError = error{
@ -950,13 +1000,15 @@ test "color emoji" {
} }
// resize // resize
// TODO: Comprehensive tests for constraints,
// this is just an adapted legacy test.
{ {
const glyph = try ft_font.renderGlyph( const glyph = try ft_font.renderGlyph(
alloc, alloc,
&atlas, &atlas,
ft_font.glyphIndex('🥸').?, ft_font.glyphIndex('🥸').?,
.{ .grid_metrics = .{ .{ .grid_metrics = .{
.cell_width = 10, .cell_width = 13,
.cell_height = 24, .cell_height = 24,
.cell_baseline = 0, .cell_baseline = 0,
.underline_position = 0, .underline_position = 0,
@ -967,6 +1019,11 @@ test "color emoji" {
.overline_thickness = 0, .overline_thickness = 0,
.box_thickness = 0, .box_thickness = 0,
.cursor_height = 0, .cursor_height = 0,
}, .constraint_width = 2, .constraint = .{
.size_horizontal = .cover,
.size_vertical = .cover,
.align_horizontal = .center,
.align_vertical = .center,
} }, } },
); );
try testing.expectEqual(@as(u32, 24), glyph.height); try testing.expectEqual(@as(u32, 24), glyph.height);