font: add exact glyph protocol constraints

Extend glyph render constraints with cell-span sizing modes for height,
width, contain, cover bounds, and stretch bounds. These preserve the
existing face-targeted behavior for platform fonts, emoji, and Nerd Font
rules while giving registered glyphs a target based on terminal cell
spans.

Map Glyph Protocol registration options to the new constraint modes so
sizing follows the spec formulas based on authored advance width and line
height. Baseline alignment now places design-space y=0 on the terminal
text baseline instead of approximating it as start alignment.

Document the placement formulas in the local protocol summary and add
focused tests for constraint mapping, cell-span padding, line-height and
advance scaling, contain versus cover behavior, stretch, and baseline
placement.
This commit is contained in:
Mitchell Hashimoto
2026-06-05 13:32:57 -07:00
parent e45f002d1a
commit b661adad2e
4 changed files with 431 additions and 69 deletions

View File

@@ -87,6 +87,9 @@ pub const RenderOptions = struct {
/// Sizing rule.
size: Constraint.Size = .none,
/// Target coordinate space for sizing, alignment, and padding.
target: Target = .face,
/// Vertical alignment rule.
align_vertical: Align = .none,
/// Horizontal alignment rule.
@@ -136,6 +139,21 @@ pub const RenderOptions = struct {
/// Stretch the glyph to exactly fit the bounds in both
/// directions, disregarding aspect ratio.
stretch,
/// Scale the glyph up or down to exactly match the target height,
/// preserving aspect ratio.
height,
/// Scale the glyph up or down to exactly match the target width,
/// preserving aspect ratio.
width,
/// Scale the glyph up or down to fit within the target bounds,
/// preserving aspect ratio.
contain,
/// Scale the glyph up or down to cover the target bounds,
/// preserving aspect ratio.
cover_bounds,
/// Stretch the glyph to exactly fit the target bounds in both
/// directions, disregarding aspect ratio.
stretch_bounds,
};
pub const Align = enum {
@@ -153,6 +171,9 @@ pub const RenderOptions = struct {
/// but always with respect to the first cell even for
/// multi-cell constraints. (Nerd Font specific rule.)
center1,
/// Move the glyph so that its design-space baseline aligns with
/// the terminal text baseline.
baseline,
};
pub const Height = enum {
@@ -166,6 +187,17 @@ pub const RenderOptions = struct {
icon,
};
pub const Target = enum {
/// Size and align relative to the primary face metrics. This is
/// the default behavior used for normal fonts, emoji, and Nerd
/// Font constraints.
face,
/// Size and align relative to the full terminal cell span. The
/// width is `cell_width * constraint_width`; the height is
/// `cell_height`.
cell_span,
};
/// Returns true if the constraint does anything. If it doesn't,
/// because it neither sizes nor positions the glyph, then this
/// returns false.
@@ -221,10 +253,13 @@ pub const RenderOptions = struct {
) Glyph.Size {
// For extra wide font faces, never stretch glyphs across two cells.
// This mirrors font_patcher.
const min_constraint_width: u2 = if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height))
1
else
@min(self.max_constraint_width, constraint_width);
const min_constraint_width: u2 = switch (self.target) {
.cell_span => constraint_width,
.face => if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height))
1
else
@min(self.max_constraint_width, constraint_width),
};
// The bounding box for the glyph's scale group.
// Scaling and alignment rules are calculated for
@@ -280,24 +315,9 @@ pub const RenderOptions = struct {
return .{ 1.0, 1.0 };
}
const target_width, const target_height = self.targetSize(metrics, min_constraint_width);
const multi_cell = (min_constraint_width > 1);
const pad_width_factor = @as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right);
const pad_height_factor = 1 - (self.pad_bottom + self.pad_top);
const target_width = pad_width_factor * metrics.face_width;
const target_height = pad_height_factor * switch (self.height) {
.cell => metrics.face_height,
// Like font-patcher, the icon constraint height depends on the
// constraint width. Unlike font-patcher, the multi-cell
// icon_height may be different from face_height due to the
// `adjust-icon-height` config option.
.icon => if (multi_cell)
metrics.icon_height
else
metrics.icon_height_single,
};
var width_factor = target_width / group.width;
var height_factor = target_height / group.height;
@@ -336,6 +356,21 @@ pub const RenderOptions = struct {
width_factor = height_factor;
},
.stretch => {},
.height => {
width_factor = height_factor;
},
.width => {
height_factor = width_factor;
},
.contain => {
height_factor = @min(width_factor, height_factor);
width_factor = height_factor;
},
.cover_bounds => {
height_factor = @max(width_factor, height_factor);
width_factor = height_factor;
},
.stretch_bounds => {},
}
// Reduce aspect ratio if required
@@ -359,15 +394,23 @@ pub const RenderOptions = struct {
// we don't touch vertical alignment.
return group.y;
}
// We use face_height and offset by face_y, rather than
// using cell_height directly, to account for the asymmetry
// of the pixel cell around the face (a consequence of
// aligning the baseline with a pixel boundary rather than
// vertically centering the face).
const pad_bottom_dy = self.pad_bottom * metrics.face_height;
const pad_top_dy = self.pad_top * metrics.face_height;
const start_y = metrics.face_y + pad_bottom_dy;
const end_y = metrics.face_y + (metrics.face_height - group.height - pad_top_dy);
const span_height: f64 = switch (self.target) {
.face => metrics.face_height,
.cell_span => @floatFromInt(metrics.cell_height),
};
const origin_y: f64 = switch (self.target) {
// We use face_height and offset by face_y, rather than using
// cell_height directly, to account for the asymmetry of the
// pixel cell around the face (a consequence of aligning the
// baseline with a pixel boundary rather than vertically
// centering the face).
.face => metrics.face_y,
.cell_span => 0,
};
const pad_bottom_dy = self.pad_bottom * span_height;
const pad_top_dy = self.pad_top * span_height;
const start_y = origin_y + pad_bottom_dy;
const end_y = origin_y + (span_height - group.height - pad_top_dy);
const center_y = (start_y + end_y) / 2;
return switch (self.align_vertical) {
// NOTE: Even if there is no prescribed alignment, we ensure
@@ -383,6 +426,7 @@ pub const RenderOptions = struct {
.start => start_y,
.end => end_y,
.center, .center1 => center_y,
.baseline => @floatFromInt(metrics.cell_baseline),
};
}
@@ -398,18 +442,27 @@ pub const RenderOptions = struct {
// axis, we don't touch horizontal alignment.
return group.x;
}
// For multi-cell constraints, we align relative to the span
// from the left edge of the first cell to the right edge of
// the last face cell assuming it's left-aligned within the
// rounded and adjusted pixel cell. Any horizontal offset to
// center the face within the grid cell is the responsibility
// of the backend-specific rendering code, and should be done
// after applying constraints.
const full_face_span = metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width));
const pad_left_dx = self.pad_left * metrics.face_width;
const pad_right_dx = self.pad_right * metrics.face_width;
const span_width, const pad_width = switch (self.target) {
// For multi-cell constraints, we align relative to the span
// from the left edge of the first cell to the right edge of
// the last face cell assuming it's left-aligned within the
// rounded and adjusted pixel cell. Any horizontal offset to
// center the face within the grid cell is the responsibility
// of the backend-specific rendering code, and should be done
// after applying constraints.
.face => .{
metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width)),
metrics.face_width,
},
.cell_span => .{
@as(f64, @floatFromInt(min_constraint_width * metrics.cell_width)),
@as(f64, @floatFromInt(min_constraint_width * metrics.cell_width)),
},
};
const pad_left_dx = self.pad_left * pad_width;
const pad_right_dx = self.pad_right * pad_width;
const start_x = pad_left_dx;
const end_x = full_face_span - group.width - pad_right_dx;
const end_x = span_width - group.width - pad_right_dx;
return switch (self.align_horizontal) {
// NOTE: Even if there is no prescribed alignment, we ensure
// that the glyph doesn't protrude outside the padded cell,
@@ -429,6 +482,66 @@ pub const RenderOptions = struct {
const end1_x = metrics.face_width - group.width - pad_right_dx;
break :center1 @max(start_x, (start_x + end1_x) / 2);
},
// Baseline is vertical-only. We share the Align enum between
// axes so registered-glyph options can be stored in the same
// Constraint struct, but the parser never maps a horizontal
// alignment to .baseline.
.baseline => unreachable,
};
}
/// Return the available target width and height after padding.
///
/// Face-targeted constraints preserve the historical behavior used by
/// platform fonts and Nerd Font rules: horizontal padding is measured
/// against one face width, and the available span is one face width
/// plus any additional grid cells. Vertical sizing uses either the
/// face height or configured icon height.
///
/// Cell-span constraints are for glyphs whose layout contract is the
/// terminal cells themselves. Padding is measured against the full
/// cell span, so a two-cell glyph with 10% left padding starts after
/// 10% of both cells combined, not 10% of a single face width.
fn targetSize(
self: Constraint,
metrics: Metrics,
min_constraint_width: u2,
) struct { f64, f64 } {
const multi_cell = (min_constraint_width > 1);
return switch (self.target) {
.face => .{
// Historical font constraints measure horizontal padding
// in face-width units. Additional cells add raw grid-cell
// width to the available span, matching aligned_x.
(@as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right)) * metrics.face_width,
// Vertical face constraints operate on the selected face
// or icon height. Icon height may depend on whether the
// constraint spans multiple cells.
(1 - (self.pad_bottom + self.pad_top)) * switch (self.height) {
.cell => metrics.face_height,
// Like font-patcher, the icon constraint height depends on the
// constraint width. Unlike font-patcher, the multi-cell
// icon_height may be different from face_height due to the
// `adjust-icon-height` config option.
.icon => if (multi_cell)
metrics.icon_height
else
metrics.icon_height_single,
},
},
.cell_span => span: {
const span_width: f64 = @floatFromInt(min_constraint_width * metrics.cell_width);
const span_height: f64 = @floatFromInt(metrics.cell_height);
break :span .{
// Cell-span constraints use the full terminal span as
// the target box. This is the exact contract needed by
// runtime registered glyphs, whose width is declared in
// terminal cells rather than face metrics.
(1 - (self.pad_left + self.pad_right)) * span_width,
(1 - (self.pad_bottom + self.pad_top)) * span_height,
};
},
};
}
};
@@ -544,6 +657,79 @@ test "Constraints" {
);
}
// Cell-span constraints. These are generic target-bound sizing modes used
// by registered glyphs where the render span is defined by terminal cell
// dimensions rather than primary face metrics.
{
const glyph: GlyphSize = .{
.width = 100,
.height = 200,
.x = 0,
.y = 0,
};
const base: RenderOptions.Constraint = .{
.target = .cell_span,
.align_horizontal = .start,
.align_vertical = .start,
};
// Target span is two cells wide by one cell high: 20px x 22px.
var height_constraint = base;
height_constraint.size = .height;
try comparison.expectApproxEqual(
GlyphSize{ .width = 11, .height = 22, .x = 0, .y = 0 },
height_constraint.constrain(glyph, metrics, 2),
);
var width_constraint = base;
width_constraint.size = .width;
try comparison.expectApproxEqual(
GlyphSize{ .width = 20, .height = 40, .x = 0, .y = 0 },
width_constraint.constrain(glyph, metrics, 2),
);
var contain_constraint = base;
contain_constraint.size = .contain;
try comparison.expectApproxEqual(
GlyphSize{ .width = 11, .height = 22, .x = 0, .y = 0 },
contain_constraint.constrain(glyph, metrics, 2),
);
var cover_constraint = base;
cover_constraint.size = .cover_bounds;
try comparison.expectApproxEqual(
GlyphSize{ .width = 20, .height = 40, .x = 0, .y = 0 },
cover_constraint.constrain(glyph, metrics, 2),
);
var stretch_constraint = base;
stretch_constraint.size = .stretch_bounds;
try comparison.expectApproxEqual(
GlyphSize{ .width = 20, .height = 22, .x = 0, .y = 0 },
stretch_constraint.constrain(glyph, metrics, 2),
);
// Baseline alignment places the group's y=0 at the terminal text
// baseline, independent of vertical padding.
try comparison.expectApproxEqual(
GlyphSize{ .width = 100, .height = 200, .x = 0, .y = 5 },
(RenderOptions.Constraint{
.target = .cell_span,
.align_vertical = .baseline,
}).constrain(glyph, metrics, 2),
);
// Cell-span padding is relative to the full two-cell span.
try comparison.expectApproxEqual(
GlyphSize{ .width = 10, .height = 20, .x = 5, .y = 0 },
(RenderOptions.Constraint{
.target = .cell_span,
.size = .width,
.align_horizontal = .start,
.align_vertical = .start,
.pad_left = 0.25,
.pad_right = 0.25,
}).constrain(glyph, metrics, 2),
);
}
// Nerd Font default.
{
const constraint = getConstraint(0xea61).?;

View File

@@ -231,11 +231,12 @@ const Placement = struct {
design: DesignMetrics,
opts: Glyph.RenderOptions,
) Placement {
// Start with protocol-like design units mapped so that the em square
// occupies one cell. This makes units_per_em the scale reference and
// preserves the linked rasterizer's y=0 baseline/bottom behavior.
// Callers can then use RenderOptions.Constraint to fit/cover/stretch/
// align the declared advance/line-height box using existing font logic.
// Start with design units mapped so that the em square occupies one
// cell. This normalizes coordinates into the same cell-relative pixel
// space used by RenderOptions.Constraint. Protocol sizing constraints
// then rescale the declared advance/line-height box so the final
// transform follows the protocol formulas based on advance width and
// line height rather than units-per-em.
const scale = @as(f64, @floatFromInt(opts.grid_metrics.cell_height)) /
@as(f64, @floatFromInt(design.units_per_em));
@@ -823,3 +824,153 @@ test "glyf_rasterize: line height does not change unconstrained em scale" {
try testing.expect(bm.data[2 * bm.width + 10] > 200);
try testing.expect(bm.data[17 * bm.width + 10] > 200);
}
test "glyf_rasterize: cell-span height sizing uses line height" {
const testing = std.testing;
const placement = Placement.init(.{
.x_min = 0,
.y_min = 0,
.x_max = 1000,
.y_max = 1000,
}, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 2000,
}, .{
.grid_metrics = testMetrics(20, 20),
.constraint = .{
.target = .cell_span,
.size = .height,
.align_horizontal = .start,
.align_vertical = .start,
},
});
// Final scale is cell_height / lh = 20 / 2000 = 0.01, so this
// 1000-unit square is 10px high rather than a full 20px em.
try testing.expectApproxEqAbs(@as(f64, 10), placement.width, 0.000001);
try testing.expectApproxEqAbs(@as(f64, 10), placement.height, 0.000001);
}
test "glyf_rasterize: cell-span advance sizing uses registered width" {
const testing = std.testing;
const placement = Placement.init(.{
.x_min = 0,
.y_min = 0,
.x_max = 1000,
.y_max = 1000,
}, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = testMetrics(20, 20),
.constraint_width = 2,
.constraint = .{
.target = .cell_span,
.size = .width,
.align_horizontal = .start,
.align_vertical = .start,
},
});
// Final scale is span_width / aw = 40 / 1000 = 0.04.
try testing.expectApproxEqAbs(@as(f64, 40), placement.width, 0.000001);
try testing.expectApproxEqAbs(@as(f64, 40), placement.height, 0.000001);
}
test "glyf_rasterize: cell-span contain and cover choose different axes" {
const testing = std.testing;
const bounds: Bounds = .{
.x_min = 0,
.y_min = 0,
.x_max = 1000,
.y_max = 1000,
};
const design: DesignMetrics = .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 2000,
};
const base_opts: Glyph.RenderOptions = .{
.grid_metrics = testMetrics(20, 20),
.constraint = .{
.target = .cell_span,
.align_horizontal = .start,
.align_vertical = .start,
},
};
var contain_opts = base_opts;
contain_opts.constraint.size = .contain;
const contain = Placement.init(bounds, design, contain_opts);
var cover_opts = base_opts;
cover_opts.constraint.size = .cover_bounds;
const cover = Placement.init(bounds, design, cover_opts);
try testing.expectApproxEqAbs(@as(f64, 10), contain.width, 0.000001);
try testing.expectApproxEqAbs(@as(f64, 10), contain.height, 0.000001);
try testing.expectApproxEqAbs(@as(f64, 20), cover.width, 0.000001);
try testing.expectApproxEqAbs(@as(f64, 20), cover.height, 0.000001);
}
test "glyf_rasterize: cell-span stretch uses independent axes" {
const testing = std.testing;
const placement = Placement.init(.{
.x_min = 0,
.y_min = 0,
.x_max = 1000,
.y_max = 2000,
}, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 2000,
}, .{
.grid_metrics = testMetrics(20, 20),
.constraint_width = 2,
.constraint = .{
.target = .cell_span,
.size = .stretch_bounds,
.align_horizontal = .start,
.align_vertical = .start,
},
});
try testing.expectApproxEqAbs(@as(f64, 40), placement.width, 0.000001);
try testing.expectApproxEqAbs(@as(f64, 20), placement.height, 0.000001);
}
test "glyf_rasterize: cell-span baseline aligns design y zero" {
const testing = std.testing;
var metrics = testMetrics(20, 20);
metrics.cell_baseline = 5;
const bounds: Bounds = .{
.x_min = 0,
.y_min = -250,
.x_max = 1000,
.y_max = 750,
};
const placement = Placement.init(bounds, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = metrics,
.constraint = .{
.target = .cell_span,
.size = .height,
.align_horizontal = .start,
.align_vertical = .baseline,
},
});
const scale_y = placement.height / bounds.height();
const design_zero_y = placement.y + ((0 - bounds.y_min) * scale_y);
try testing.expectApproxEqAbs(@as(f64, 5), design_zero_y, 0.000001);
}

View File

@@ -96,9 +96,16 @@
//! - `width` — Unicode/wcwidth cell width. Must be `1` or `2`; default `1`.
//! This is authoritative for cursor advance, wrapping, and
//! selection geometry.
//! - `size` — scale policy. Default `height`.
//! - `size` — scale policy. Default `height`. Given the padded render span
//! width `W'`, padded render span height `H'`, authored advance
//! width `aw`, and authored line height `lh`, scale is:
//! `height = H' / lh`, `advance = W' / aw`,
//! `contain = min(W'/aw, H'/lh)`,
//! `cover = max(W'/aw, H'/lh)`, and
//! `stretch = (W'/aw, H'/lh)` independently on each axis.
//! - `align` — horizontal and vertical placement within the render span.
//! Default `center,center`.
//! Default `center,center`. Vertical `baseline` aligns
//! design-space `y=0` to the terminal text baseline.
//! - `pad` — fractional insets from the render span edges. Default
//! `0,0,0,0`; degenerate padding is treated as no padding.
//! - payload — base64-encoded payload for the selected `fmt`.

View File

@@ -209,23 +209,13 @@ pub const Entry = struct {
const pad = req.get(.pad) orelse return error.InvalidOptions;
return .{
.target = .cell_span,
.size = switch (size) {
// The rasterizer's base transform already maps the design em
// to the cell height. That is the closest existing behavior to
// the protocol's default height-driven mode.
.height => .none,
// There is no width-driven, aspect-preserving constraint mode
// today. Leave the base transform intact rather than forcing a
// fit/contain policy that would unexpectedly prevent overflow.
.advance => .none,
// Constraint.cover currently scales preserving aspect ratio to
// the available bounds, which is the best existing match for
// the protocol's contain mode.
.contain => .cover,
// There is no true protocol-cover equivalent that chooses the
// larger axis scale, so use the nearest named renderer policy.
.cover => .cover,
.stretch => .stretch,
.height => .height,
.advance => .width,
.contain => .contain,
.cover => .cover_bounds,
.stretch => .stretch_bounds,
},
.align_horizontal = switch (alignment.horizontal) {
.start => .start,
@@ -236,11 +226,7 @@ pub const Entry = struct {
.start => .start,
.center => .center,
.end => .end,
// The current constraint API has no baseline alignment mode.
// Start is the closest stable default because the glyf
// rasterizer's coordinate model already treats y=0 as the
// baseline/bottom before constraints are applied.
.baseline => .start,
.baseline => .baseline,
},
.pad_top = pad.top,
.pad_right = pad.right,
@@ -308,7 +294,8 @@ test "Entry init decodes glyf payload and applies register fields" {
try testing.expectEqual(@as(u32, 1024), entry.design.advance_width);
try testing.expectEqual(@as(u32, 1536), entry.design.line_height);
try testing.expectEqual(request.Width.wide, entry.width);
try testing.expectEqual(Constraint.Size.stretch, entry.constraint.size);
try testing.expectEqual(Constraint.Target.cell_span, entry.constraint.target);
try testing.expectEqual(Constraint.Size.stretch_bounds, entry.constraint.size);
try testing.expectEqual(Constraint.Align.end, entry.constraint.align_horizontal);
try testing.expectEqual(Constraint.Align.start, entry.constraint.align_vertical);
try testing.expectEqual(@as(f64, 0.1), entry.constraint.pad_top);
@@ -320,6 +307,37 @@ test "Entry init decodes glyf payload and applies register fields" {
try testing.expectEqual(@as(usize, 1), entry.glyph.glyf.contours.len);
}
test "Entry constraintFromRegister maps sizing and baseline exactly" {
const testing = std.testing;
const alloc = testing.allocator;
const cases = .{
.{ "height", Constraint.Size.height },
.{ "advance", Constraint.Size.width },
.{ "contain", Constraint.Size.contain },
.{ "cover", Constraint.Size.cover_bounds },
.{ "stretch", Constraint.Size.stretch_bounds },
};
inline for (cases) |case| {
const data = try std.fmt.allocPrint(
alloc,
"r;cp=e000;size={s};align=center,baseline;{s}",
.{ case[0], test_triangle_glyf_payload },
);
defer alloc.free(data);
const req = try testParseRegister(alloc, data);
defer alloc.free(req.raw);
const constraint = try Entry.constraintFromRegister(req);
try testing.expectEqual(Constraint.Target.cell_span, constraint.target);
try testing.expectEqual(case[1], constraint.size);
try testing.expectEqual(Constraint.Align.center, constraint.align_horizontal);
try testing.expectEqual(Constraint.Align.baseline, constraint.align_vertical);
}
}
test "Entry init rejects invalid register payload" {
const testing = std.testing;
const alloc = testing.allocator;