mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-06-15 08:03:56 +00:00
terminal: Glyph Protocol Glossary and request handler implementation (#12930)
This adds the glossary and request handler logic to the glyph protocol package. We now have a fully spec compliant business-logic part of the glyph protocol. **This doesn't yet hook it up to terminal state.** So it isn't impacting any real-world usage yet. Code was hand-written, tests were AI-assisted and human reviewed.
This commit is contained in:
@@ -24,7 +24,7 @@ const Face = font.Face;
|
||||
const Glyph = font.Glyph;
|
||||
const Library = font.Library;
|
||||
const Presentation = font.Presentation;
|
||||
const RenderOptions = font.face.RenderOptions;
|
||||
const RenderOptions = font.Glyph.RenderOptions;
|
||||
const SpriteFace = font.SpriteFace;
|
||||
const Style = font.Style;
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Glyph is a single loaded glyph for a face.
|
||||
const Glyph = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const Metrics = @import("Metrics.zig");
|
||||
|
||||
/// width of glyph in pixels
|
||||
width: u32,
|
||||
|
||||
@@ -17,3 +20,611 @@ offset_y: i32,
|
||||
/// be normalized to be between 0 and 1 prior to use in shaders.
|
||||
atlas_x: u32,
|
||||
atlas_y: u32,
|
||||
|
||||
/// The size and position of a glyph.
|
||||
pub const Size = struct {
|
||||
width: f64,
|
||||
height: f64,
|
||||
x: f64,
|
||||
y: f64,
|
||||
};
|
||||
|
||||
/// Metrics describing the authored glyph coordinate space.
|
||||
pub const DesignMetrics = struct {
|
||||
/// Units-per-em for outline/design coordinates.
|
||||
units_per_em: u32,
|
||||
|
||||
/// Authored advance width in design units.
|
||||
advance_width: u32,
|
||||
|
||||
/// Authored line height in design units.
|
||||
line_height: u32,
|
||||
};
|
||||
|
||||
/// Additional options for rendering glyphs.
|
||||
pub const RenderOptions = struct {
|
||||
/// The metrics that are defining the grid layout. These are usually
|
||||
/// the metrics of the primary font face. The grid metrics are used
|
||||
/// by the font face to better layout the glyph in situations where
|
||||
/// the font is not exactly the same size as the grid.
|
||||
grid_metrics: Metrics,
|
||||
|
||||
/// The number of grid cells this glyph will take up. This can be used
|
||||
/// optionally by the rasterizer to better layout the glyph.
|
||||
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.
|
||||
/// This is purely an aesthetic setting.
|
||||
///
|
||||
/// This only works with CoreText currently.
|
||||
thicken: bool = false,
|
||||
|
||||
/// "Strength" of the thickening, between `0` and `255`.
|
||||
/// Only has an effect when `thicken` is enabled.
|
||||
///
|
||||
/// `0` does not correspond to *no* thickening,
|
||||
/// just the *lightest* thickening available.
|
||||
///
|
||||
/// CoreText only.
|
||||
thicken_strength: u8 = 255,
|
||||
|
||||
/// See the `constraint` field.
|
||||
pub const Constraint = struct {
|
||||
/// Don't constrain the glyph in any way.
|
||||
pub const none: Constraint = .{};
|
||||
|
||||
/// Sizing rule.
|
||||
size: Constraint.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,
|
||||
|
||||
// Size and bearings of the glyph relative
|
||||
// to the bounding box of its scale group.
|
||||
relative_width: f64 = 1.0,
|
||||
relative_height: f64 = 1.0,
|
||||
relative_x: f64 = 0.0,
|
||||
relative_y: f64 = 0.0,
|
||||
|
||||
/// Maximum aspect ratio (width/height) to allow when stretching.
|
||||
max_xy_ratio: ?f64 = null,
|
||||
|
||||
/// Maximum number of cells horizontally to use.
|
||||
max_constraint_width: u2 = 2,
|
||||
|
||||
/// What to use as the height metric when constraining the glyph and
|
||||
/// the constraint width is 1,
|
||||
height: Height = .cell,
|
||||
|
||||
pub const Size = enum {
|
||||
/// Don't change the size of this glyph.
|
||||
none,
|
||||
/// Scale the glyph down if needed to fit within the bounds,
|
||||
/// preserving aspect ratio.
|
||||
fit,
|
||||
/// Scale the glyph up or down to exactly match the bounds,
|
||||
/// preserving aspect ratio.
|
||||
cover,
|
||||
/// Scale the glyph down if needed to fit within the bounds,
|
||||
/// preserving aspect ratio. If the glyph doesn't cover a
|
||||
/// single cell, scale up. If the glyph exceeds a single
|
||||
/// cell but is within the bounds, do nothing.
|
||||
/// (Nerd Font specific rule.)
|
||||
fit_cover1,
|
||||
/// Stretch the glyph to exactly fit the bounds in both
|
||||
/// directions, disregarding aspect ratio.
|
||||
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,
|
||||
/// Move the glyph so that it is centered on this axis,
|
||||
/// but always with respect to the first cell even for
|
||||
/// multi-cell constraints. (Nerd Font specific rule.)
|
||||
center1,
|
||||
};
|
||||
|
||||
pub const Height = enum {
|
||||
/// Use the full line height of the primary face for
|
||||
/// constraining this glyph.
|
||||
cell,
|
||||
/// Use the icon height from the grid metrics for
|
||||
/// constraining this glyph. Unlike `cell`, the value of
|
||||
/// this height depends on both the constraint width and the
|
||||
/// affected by the `adjust-icon-height` config option.
|
||||
icon,
|
||||
};
|
||||
|
||||
/// Returns true if the constraint does anything. If it doesn't,
|
||||
/// because it neither sizes nor positions the glyph, then this
|
||||
/// returns false.
|
||||
pub inline fn doesAnything(self: Constraint) bool {
|
||||
return self.size != .none or
|
||||
self.align_horizontal != .none or
|
||||
self.align_vertical != .none;
|
||||
}
|
||||
|
||||
/// Apply this constraint to the provided glyph
|
||||
/// size, given the available width and height.
|
||||
pub fn constrain(
|
||||
self: Constraint,
|
||||
glyph: Glyph.Size,
|
||||
metrics: Metrics,
|
||||
/// Number of cells horizontally available for this glyph.
|
||||
constraint_width: u2,
|
||||
) Glyph.Size {
|
||||
if (!self.doesAnything()) return glyph;
|
||||
|
||||
switch (self.size) {
|
||||
.stretch => {
|
||||
// Stretched glyphs are usually meant to align across cell
|
||||
// boundaries, which works best if they're scaled and
|
||||
// aligned to the grid rather than the face. This is most
|
||||
// easily done by inserting this little fib in the metrics.
|
||||
var m = metrics;
|
||||
m.face_width = @floatFromInt(m.cell_width);
|
||||
m.face_height = @floatFromInt(m.cell_height);
|
||||
m.face_y = 0.0;
|
||||
|
||||
// Negative padding for stretched glyphs is a band-aid to
|
||||
// avoid gaps due to pixel rounding, but at the cost of
|
||||
// unsightly overlap artifacts. Since we scale and align to
|
||||
// the grid rather than the face, we don't need it.
|
||||
var c = self;
|
||||
c.pad_bottom = @max(0, c.pad_bottom);
|
||||
c.pad_top = @max(0, c.pad_top);
|
||||
c.pad_left = @max(0, c.pad_left);
|
||||
c.pad_right = @max(0, c.pad_right);
|
||||
|
||||
return c.constrainInner(glyph, m, constraint_width);
|
||||
},
|
||||
else => return self.constrainInner(glyph, metrics, constraint_width),
|
||||
}
|
||||
}
|
||||
|
||||
fn constrainInner(
|
||||
self: Constraint,
|
||||
glyph: Glyph.Size,
|
||||
metrics: Metrics,
|
||||
constraint_width: u2,
|
||||
) 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);
|
||||
|
||||
// The bounding box for the glyph's scale group.
|
||||
// Scaling and alignment rules are calculated for
|
||||
// this box and then applied to the glyph.
|
||||
var group: Glyph.Size = group: {
|
||||
const group_width = glyph.width / self.relative_width;
|
||||
const group_height = glyph.height / self.relative_height;
|
||||
break :group .{
|
||||
.width = group_width,
|
||||
.height = group_height,
|
||||
.x = glyph.x - (group_width * self.relative_x),
|
||||
.y = glyph.y - (group_height * self.relative_y),
|
||||
};
|
||||
};
|
||||
|
||||
// Apply prescribed scaling, preserving the
|
||||
// center bearings of the group bounding box
|
||||
const width_factor, const height_factor = self.scale_factors(group, metrics, min_constraint_width);
|
||||
const center_x = group.x + (group.width / 2);
|
||||
const center_y = group.y + (group.height / 2);
|
||||
group.width *= width_factor;
|
||||
group.height *= height_factor;
|
||||
group.x = center_x - (group.width / 2);
|
||||
group.y = center_y - (group.height / 2);
|
||||
|
||||
// NOTE: font_patcher jumps through a lot of hoops at this
|
||||
// point to ensure that the glyph remains within the target
|
||||
// bounding box after rounding to font definition units.
|
||||
// This is irrelevant here as we're not rounding, we're
|
||||
// staying in f64 and heading straight to rendering.
|
||||
|
||||
// Apply prescribed alignment
|
||||
group.y = self.aligned_y(group, metrics);
|
||||
group.x = self.aligned_x(group, metrics, min_constraint_width);
|
||||
|
||||
// Transfer the scaling and alignment back to the glyph and return.
|
||||
return .{
|
||||
.width = width_factor * glyph.width,
|
||||
.height = height_factor * glyph.height,
|
||||
.x = group.x + (group.width * self.relative_x),
|
||||
.y = group.y + (group.height * self.relative_y),
|
||||
};
|
||||
}
|
||||
|
||||
/// Return width and height scaling factors for this scaling group.
|
||||
fn scale_factors(
|
||||
self: Constraint,
|
||||
group: Glyph.Size,
|
||||
metrics: Metrics,
|
||||
min_constraint_width: u2,
|
||||
) struct { f64, f64 } {
|
||||
if (self.size == .none) {
|
||||
return .{ 1.0, 1.0 };
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
switch (self.size) {
|
||||
.none => unreachable,
|
||||
.fit => {
|
||||
// Scale down to fit if needed
|
||||
height_factor = @min(1, width_factor, height_factor);
|
||||
width_factor = height_factor;
|
||||
},
|
||||
.cover => {
|
||||
// Scale to cover
|
||||
height_factor = @min(width_factor, height_factor);
|
||||
width_factor = height_factor;
|
||||
},
|
||||
.fit_cover1 => {
|
||||
// Scale down to fit or up to cover at least one cell
|
||||
// NOTE: This is similar to font_patcher's "pa" mode,
|
||||
// however, font_patcher will only do the upscaling
|
||||
// part if the constraint width is 1, resulting in
|
||||
// some icons becoming smaller when the constraint
|
||||
// width increases. You'd see icons shrinking when
|
||||
// opening up a space after them. This makes no
|
||||
// sense, so we've fixed the rule such that these
|
||||
// icons are scaled to the same size for multi-cell
|
||||
// constraints as they would be for single-cell.
|
||||
height_factor = @min(width_factor, height_factor);
|
||||
if (multi_cell and (height_factor > 1)) {
|
||||
// Call back into this function with
|
||||
// constraint width 1 to get single-cell scale
|
||||
// factors. We use the height factor as width
|
||||
// could have been modified by max_xy_ratio.
|
||||
_, const single_height_factor = self.scale_factors(group, metrics, 1);
|
||||
height_factor = @max(1, single_height_factor);
|
||||
}
|
||||
width_factor = height_factor;
|
||||
},
|
||||
.stretch => {},
|
||||
}
|
||||
|
||||
// Reduce aspect ratio if required
|
||||
if (self.max_xy_ratio) |ratio| {
|
||||
if (group.width * width_factor > group.height * height_factor * ratio) {
|
||||
width_factor = group.height * height_factor * ratio / group.width;
|
||||
}
|
||||
}
|
||||
|
||||
return .{ width_factor, height_factor };
|
||||
}
|
||||
|
||||
/// Return vertical bearing for aligning this group
|
||||
fn aligned_y(
|
||||
self: Constraint,
|
||||
group: Glyph.Size,
|
||||
metrics: Metrics,
|
||||
) f64 {
|
||||
if ((self.size == .none) and (self.align_vertical == .none)) {
|
||||
// If we don't have any constraints affecting the vertical axis,
|
||||
// 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 center_y = (start_y + end_y) / 2;
|
||||
return switch (self.align_vertical) {
|
||||
// NOTE: Even if there is no prescribed alignment, we ensure
|
||||
// that the group doesn't protrude outside the padded cell,
|
||||
// since this is implied by every available size constraint. If
|
||||
// the group is too high we fall back to centering, though if we
|
||||
// hit the .none prong we always have self.size != .none, so
|
||||
// this should never happen.
|
||||
.none => if (end_y < start_y)
|
||||
center_y
|
||||
else
|
||||
@max(start_y, @min(group.y, end_y)),
|
||||
.start => start_y,
|
||||
.end => end_y,
|
||||
.center, .center1 => center_y,
|
||||
};
|
||||
}
|
||||
|
||||
/// Return horizontal bearing for aligning this group
|
||||
fn aligned_x(
|
||||
self: Constraint,
|
||||
group: Glyph.Size,
|
||||
metrics: Metrics,
|
||||
min_constraint_width: u2,
|
||||
) f64 {
|
||||
if ((self.size == .none) and (self.align_horizontal == .none)) {
|
||||
// If we don't have any constraints affecting the horizontal
|
||||
// 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 start_x = pad_left_dx;
|
||||
const end_x = full_face_span - 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,
|
||||
// since this is implied by every available size constraint. The
|
||||
// left-side bound has priority if the group is too wide, though
|
||||
// if we hit the .none prong we always have self.size != .none,
|
||||
// so this should never happen.
|
||||
.none => @max(start_x, @min(group.x, end_x)),
|
||||
.start => start_x,
|
||||
.end => @max(start_x, end_x),
|
||||
.center => @max(start_x, (start_x + end_x) / 2),
|
||||
// NOTE: .center1 implements the font_patcher rule of centering
|
||||
// in the first cell even for multi-cell constraints. Since glyphs
|
||||
// are not allowed to protrude to the left, this results in the
|
||||
// left-alignment like .start when the glyph is wider than a cell.
|
||||
.center1 => center1: {
|
||||
const end1_x = metrics.face_width - group.width - pad_right_dx;
|
||||
break :center1 @max(start_x, (start_x + end1_x) / 2);
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
test "Constraints" {
|
||||
const comparison = @import("../datastruct/comparison.zig");
|
||||
const getConstraint = @import("nerd_font_attributes.zig").getConstraint;
|
||||
const GlyphSize = Size;
|
||||
|
||||
// Hardcoded data matches metrics from CoreText at size 12 and DPI 96.
|
||||
|
||||
// Define grid metrics (matches font-family = JetBrains Mono)
|
||||
const metrics: Metrics = .{
|
||||
.cell_width = 10,
|
||||
.cell_height = 22,
|
||||
.cell_baseline = 5,
|
||||
.underline_position = 19,
|
||||
.underline_thickness = 1,
|
||||
.strikethrough_position = 12,
|
||||
.strikethrough_thickness = 1,
|
||||
.overline_position = 0,
|
||||
.overline_thickness = 1,
|
||||
.box_thickness = 1,
|
||||
.cursor_thickness = 1,
|
||||
.cursor_height = 22,
|
||||
.icon_height = 21.12,
|
||||
.icon_height_single = 44.48 / 3.0,
|
||||
.face_width = 9.6,
|
||||
.face_height = 21.12,
|
||||
.face_y = 0.2,
|
||||
};
|
||||
|
||||
// ASCII (no constraint).
|
||||
{
|
||||
const constraint: RenderOptions.Constraint = .none;
|
||||
|
||||
// BBox of 'x' from JetBrains Mono.
|
||||
const glyph_x: GlyphSize = .{
|
||||
.width = 6.784,
|
||||
.height = 15.28,
|
||||
.x = 1.408,
|
||||
.y = 4.84,
|
||||
};
|
||||
|
||||
// Any constraint width: do nothing.
|
||||
inline for (.{ 1, 2 }) |constraint_width| {
|
||||
try comparison.expectApproxEqual(
|
||||
glyph_x,
|
||||
constraint.constrain(glyph_x, metrics, constraint_width),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Symbol (same constraint as hardcoded in Renderer.addGlyph).
|
||||
{
|
||||
const constraint: RenderOptions.Constraint = .{ .size = .fit };
|
||||
|
||||
// BBox of '■' (0x25A0 black square) from Iosevka.
|
||||
// NOTE: This glyph is designed to span two cells.
|
||||
const glyph_25A0: GlyphSize = .{
|
||||
.width = 10.272,
|
||||
.height = 10.272,
|
||||
.x = 2.864,
|
||||
.y = 5.304,
|
||||
};
|
||||
|
||||
// Constraint width 1: scale down and shift to fit a single cell.
|
||||
try comparison.expectApproxEqual(
|
||||
GlyphSize{
|
||||
.width = metrics.face_width,
|
||||
.height = metrics.face_width,
|
||||
.x = 0,
|
||||
.y = 5.64,
|
||||
},
|
||||
constraint.constrain(glyph_25A0, metrics, 1),
|
||||
);
|
||||
|
||||
// Constraint width 2: do nothing.
|
||||
try comparison.expectApproxEqual(
|
||||
glyph_25A0,
|
||||
constraint.constrain(glyph_25A0, metrics, 2),
|
||||
);
|
||||
}
|
||||
|
||||
// Emoji (same constraint as hardcoded in SharedGrid.renderGlyph).
|
||||
{
|
||||
const constraint: RenderOptions.Constraint = .{
|
||||
.size = .cover,
|
||||
.align_horizontal = .center,
|
||||
.align_vertical = .center,
|
||||
.pad_left = 0.025,
|
||||
.pad_right = 0.025,
|
||||
};
|
||||
|
||||
// BBox of '🥸' (0x1F978) from Apple Color Emoji.
|
||||
const glyph_1F978: GlyphSize = .{
|
||||
.width = 20,
|
||||
.height = 20,
|
||||
.x = 0.46,
|
||||
.y = 1,
|
||||
};
|
||||
|
||||
// Constraint width 2: scale to cover two cells with padding, center;
|
||||
try comparison.expectApproxEqual(
|
||||
GlyphSize{
|
||||
.width = 18.72,
|
||||
.height = 18.72,
|
||||
.x = 0.44,
|
||||
.y = 1.4,
|
||||
},
|
||||
constraint.constrain(glyph_1F978, metrics, 2),
|
||||
);
|
||||
}
|
||||
|
||||
// Nerd Font default.
|
||||
{
|
||||
const constraint = getConstraint(0xea61).?;
|
||||
|
||||
// Verify that this is the constraint we expect.
|
||||
try std.testing.expectEqual(.fit_cover1, constraint.size);
|
||||
try std.testing.expectEqual(.icon, constraint.height);
|
||||
try std.testing.expectEqual(.center1, constraint.align_horizontal);
|
||||
try std.testing.expectEqual(.center1, constraint.align_vertical);
|
||||
|
||||
// BBox of '' (0xEA61 nf-cod-lightbulb) from Symbols Only.
|
||||
// NOTE: This icon is part of a group, so the
|
||||
// constraint applies to a larger bounding box.
|
||||
const glyph_EA61: GlyphSize = .{
|
||||
.width = 9.015625,
|
||||
.height = 13.015625,
|
||||
.x = 3.015625,
|
||||
.y = 3.76525,
|
||||
};
|
||||
|
||||
// Constraint width 1: scale and shift group to fit a single cell.
|
||||
try comparison.expectApproxEqual(
|
||||
GlyphSize{
|
||||
.width = 7.2125,
|
||||
.height = 10.4125,
|
||||
.x = 0.8125,
|
||||
.y = 5.950695224719102,
|
||||
},
|
||||
constraint.constrain(glyph_EA61, metrics, 1),
|
||||
);
|
||||
|
||||
// Constraint width 2: no scaling; left-align and vertically center group.
|
||||
try comparison.expectApproxEqual(
|
||||
GlyphSize{
|
||||
.width = glyph_EA61.width,
|
||||
.height = glyph_EA61.height,
|
||||
.x = 1.015625,
|
||||
.y = 4.7483690308988775,
|
||||
},
|
||||
constraint.constrain(glyph_EA61, metrics, 2),
|
||||
);
|
||||
}
|
||||
|
||||
// Nerd Font stretch.
|
||||
{
|
||||
const constraint = getConstraint(0xe0c0).?;
|
||||
|
||||
// Verify that this is the constraint we expect.
|
||||
try std.testing.expectEqual(.stretch, constraint.size);
|
||||
try std.testing.expectEqual(.cell, constraint.height);
|
||||
try std.testing.expectEqual(.start, constraint.align_horizontal);
|
||||
try std.testing.expectEqual(.center1, constraint.align_vertical);
|
||||
|
||||
// BBox of ' ' (0xE0C0 nf-ple-flame_thick) from Symbols Only.
|
||||
const glyph_E0C0: GlyphSize = .{
|
||||
.width = 16.796875,
|
||||
.height = 16.46875,
|
||||
.x = -0.796875,
|
||||
.y = 1.7109375,
|
||||
};
|
||||
|
||||
// Constraint width 1: stretch and position to exactly cover one cell.
|
||||
try comparison.expectApproxEqual(
|
||||
GlyphSize{
|
||||
.width = @floatFromInt(metrics.cell_width),
|
||||
.height = @floatFromInt(metrics.cell_height),
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
},
|
||||
constraint.constrain(glyph_E0C0, metrics, 1),
|
||||
);
|
||||
|
||||
// Constraint width 1: stretch and position to exactly cover two cells.
|
||||
try comparison.expectApproxEqual(
|
||||
GlyphSize{
|
||||
.width = @floatFromInt(2 * metrics.cell_width),
|
||||
.height = @floatFromInt(metrics.cell_height),
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
},
|
||||
constraint.constrain(glyph_E0C0, metrics, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,6 +560,8 @@ pub const Modifier = union(enum) {
|
||||
}
|
||||
|
||||
test "formatConfig percent" {
|
||||
if (comptime @import("terminal_options").artifact == .lib) return;
|
||||
|
||||
const configpkg = @import("../config.zig");
|
||||
const testing = std.testing;
|
||||
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
@@ -571,6 +573,8 @@ pub const Modifier = union(enum) {
|
||||
}
|
||||
|
||||
test "formatConfig absolute" {
|
||||
if (comptime @import("terminal_options").artifact == .lib) return;
|
||||
|
||||
const configpkg = @import("../config.zig");
|
||||
const testing = std.testing;
|
||||
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
|
||||
@@ -33,7 +33,7 @@ const Library = font.Library;
|
||||
const Metrics = font.Metrics;
|
||||
const Presentation = font.Presentation;
|
||||
const Style = font.Style;
|
||||
const RenderOptions = font.face.RenderOptions;
|
||||
const RenderOptions = font.Glyph.RenderOptions;
|
||||
|
||||
const log = std.log.scoped(.font_shared_grid);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const build_config = @import("../build_config.zig");
|
||||
const options = @import("main.zig").options;
|
||||
const Metrics = @import("main.zig").Metrics;
|
||||
const config = @import("../config.zig");
|
||||
const freetype = @import("face/freetype.zig");
|
||||
const coretext = @import("face/coretext.zig");
|
||||
@@ -94,407 +93,6 @@ 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
|
||||
/// the metrics of the primary font face. The grid metrics are used
|
||||
/// by the font face to better layout the glyph in situations where
|
||||
/// the font is not exactly the same size as the grid.
|
||||
grid_metrics: Metrics,
|
||||
|
||||
/// The number of grid cells this glyph will take up. This can be used
|
||||
/// optionally by the rasterizer to better layout the glyph.
|
||||
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.
|
||||
/// This is purely an aesthetic setting.
|
||||
///
|
||||
/// This only works with CoreText currently.
|
||||
thicken: bool = false,
|
||||
|
||||
/// "Strength" of the thickening, between `0` and `255`.
|
||||
/// Only has an effect when `thicken` is enabled.
|
||||
///
|
||||
/// `0` does not correspond to *no* thickening,
|
||||
/// just the *lightest* thickening available.
|
||||
///
|
||||
/// CoreText only.
|
||||
thicken_strength: u8 = 255,
|
||||
|
||||
/// See the `constraint` field.
|
||||
pub const Constraint = struct {
|
||||
/// Don't constrain the glyph in any way.
|
||||
pub const none: Constraint = .{};
|
||||
|
||||
/// Sizing rule.
|
||||
size: 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,
|
||||
|
||||
// Size and bearings of the glyph relative
|
||||
// to the bounding box of its scale group.
|
||||
relative_width: f64 = 1.0,
|
||||
relative_height: f64 = 1.0,
|
||||
relative_x: f64 = 0.0,
|
||||
relative_y: f64 = 0.0,
|
||||
|
||||
/// Maximum aspect ratio (width/height) to allow when stretching.
|
||||
max_xy_ratio: ?f64 = null,
|
||||
|
||||
/// Maximum number of cells horizontally to use.
|
||||
max_constraint_width: u2 = 2,
|
||||
|
||||
/// What to use as the height metric when constraining the glyph and
|
||||
/// the constraint width is 1,
|
||||
height: Height = .cell,
|
||||
|
||||
pub const Size = enum {
|
||||
/// Don't change the size of this glyph.
|
||||
none,
|
||||
/// Scale the glyph down if needed to fit within the bounds,
|
||||
/// preserving aspect ratio.
|
||||
fit,
|
||||
/// Scale the glyph up or down to exactly match the bounds,
|
||||
/// preserving aspect ratio.
|
||||
cover,
|
||||
/// Scale the glyph down if needed to fit within the bounds,
|
||||
/// preserving aspect ratio. If the glyph doesn't cover a
|
||||
/// single cell, scale up. If the glyph exceeds a single
|
||||
/// cell but is within the bounds, do nothing.
|
||||
/// (Nerd Font specific rule.)
|
||||
fit_cover1,
|
||||
/// Stretch the glyph to exactly fit the bounds in both
|
||||
/// directions, disregarding aspect ratio.
|
||||
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,
|
||||
/// Move the glyph so that it is centered on this axis,
|
||||
/// but always with respect to the first cell even for
|
||||
/// multi-cell constraints. (Nerd Font specific rule.)
|
||||
center1,
|
||||
};
|
||||
|
||||
pub const Height = enum {
|
||||
/// Use the full line height of the primary face for
|
||||
/// constraining this glyph.
|
||||
cell,
|
||||
/// Use the icon height from the grid metrics for
|
||||
/// constraining this glyph. Unlike `cell`, the value of
|
||||
/// this height depends on both the constraint width and the
|
||||
/// affected by the `adjust-icon-height` config option.
|
||||
icon,
|
||||
};
|
||||
|
||||
/// Returns true if the constraint does anything. If it doesn't,
|
||||
/// because it neither sizes nor positions the glyph, then this
|
||||
/// returns false.
|
||||
pub inline fn doesAnything(self: Constraint) bool {
|
||||
return self.size != .none or
|
||||
self.align_horizontal != .none or
|
||||
self.align_vertical != .none;
|
||||
}
|
||||
|
||||
/// Apply this constraint to the provided glyph
|
||||
/// size, given the available width and height.
|
||||
pub fn constrain(
|
||||
self: Constraint,
|
||||
glyph: GlyphSize,
|
||||
metrics: Metrics,
|
||||
/// Number of cells horizontally available for this glyph.
|
||||
constraint_width: u2,
|
||||
) GlyphSize {
|
||||
if (!self.doesAnything()) return glyph;
|
||||
|
||||
switch (self.size) {
|
||||
.stretch => {
|
||||
// Stretched glyphs are usually meant to align across cell
|
||||
// boundaries, which works best if they're scaled and
|
||||
// aligned to the grid rather than the face. This is most
|
||||
// easily done by inserting this little fib in the metrics.
|
||||
var m = metrics;
|
||||
m.face_width = @floatFromInt(m.cell_width);
|
||||
m.face_height = @floatFromInt(m.cell_height);
|
||||
m.face_y = 0.0;
|
||||
|
||||
// Negative padding for stretched glyphs is a band-aid to
|
||||
// avoid gaps due to pixel rounding, but at the cost of
|
||||
// unsightly overlap artifacts. Since we scale and align to
|
||||
// the grid rather than the face, we don't need it.
|
||||
var c = self;
|
||||
c.pad_bottom = @max(0, c.pad_bottom);
|
||||
c.pad_top = @max(0, c.pad_top);
|
||||
c.pad_left = @max(0, c.pad_left);
|
||||
c.pad_right = @max(0, c.pad_right);
|
||||
|
||||
return c.constrainInner(glyph, m, constraint_width);
|
||||
},
|
||||
else => return self.constrainInner(glyph, metrics, constraint_width),
|
||||
}
|
||||
}
|
||||
|
||||
fn constrainInner(
|
||||
self: Constraint,
|
||||
glyph: GlyphSize,
|
||||
metrics: Metrics,
|
||||
constraint_width: u2,
|
||||
) GlyphSize {
|
||||
// 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);
|
||||
|
||||
// The bounding box for the glyph's scale group.
|
||||
// Scaling and alignment rules are calculated for
|
||||
// this box and then applied to the glyph.
|
||||
var group: GlyphSize = group: {
|
||||
const group_width = glyph.width / self.relative_width;
|
||||
const group_height = glyph.height / self.relative_height;
|
||||
break :group .{
|
||||
.width = group_width,
|
||||
.height = group_height,
|
||||
.x = glyph.x - (group_width * self.relative_x),
|
||||
.y = glyph.y - (group_height * self.relative_y),
|
||||
};
|
||||
};
|
||||
|
||||
// Apply prescribed scaling, preserving the
|
||||
// center bearings of the group bounding box
|
||||
const width_factor, const height_factor = self.scale_factors(group, metrics, min_constraint_width);
|
||||
const center_x = group.x + (group.width / 2);
|
||||
const center_y = group.y + (group.height / 2);
|
||||
group.width *= width_factor;
|
||||
group.height *= height_factor;
|
||||
group.x = center_x - (group.width / 2);
|
||||
group.y = center_y - (group.height / 2);
|
||||
|
||||
// NOTE: font_patcher jumps through a lot of hoops at this
|
||||
// point to ensure that the glyph remains within the target
|
||||
// bounding box after rounding to font definition units.
|
||||
// This is irrelevant here as we're not rounding, we're
|
||||
// staying in f64 and heading straight to rendering.
|
||||
|
||||
// Apply prescribed alignment
|
||||
group.y = self.aligned_y(group, metrics);
|
||||
group.x = self.aligned_x(group, metrics, min_constraint_width);
|
||||
|
||||
// Transfer the scaling and alignment back to the glyph and return.
|
||||
return .{
|
||||
.width = width_factor * glyph.width,
|
||||
.height = height_factor * glyph.height,
|
||||
.x = group.x + (group.width * self.relative_x),
|
||||
.y = group.y + (group.height * self.relative_y),
|
||||
};
|
||||
}
|
||||
|
||||
/// Return width and height scaling factors for this scaling group.
|
||||
fn scale_factors(
|
||||
self: Constraint,
|
||||
group: GlyphSize,
|
||||
metrics: Metrics,
|
||||
min_constraint_width: u2,
|
||||
) struct { f64, f64 } {
|
||||
if (self.size == .none) {
|
||||
return .{ 1.0, 1.0 };
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
switch (self.size) {
|
||||
.none => unreachable,
|
||||
.fit => {
|
||||
// Scale down to fit if needed
|
||||
height_factor = @min(1, width_factor, height_factor);
|
||||
width_factor = height_factor;
|
||||
},
|
||||
.cover => {
|
||||
// Scale to cover
|
||||
height_factor = @min(width_factor, height_factor);
|
||||
width_factor = height_factor;
|
||||
},
|
||||
.fit_cover1 => {
|
||||
// Scale down to fit or up to cover at least one cell
|
||||
// NOTE: This is similar to font_patcher's "pa" mode,
|
||||
// however, font_patcher will only do the upscaling
|
||||
// part if the constraint width is 1, resulting in
|
||||
// some icons becoming smaller when the constraint
|
||||
// width increases. You'd see icons shrinking when
|
||||
// opening up a space after them. This makes no
|
||||
// sense, so we've fixed the rule such that these
|
||||
// icons are scaled to the same size for multi-cell
|
||||
// constraints as they would be for single-cell.
|
||||
height_factor = @min(width_factor, height_factor);
|
||||
if (multi_cell and (height_factor > 1)) {
|
||||
// Call back into this function with
|
||||
// constraint width 1 to get single-cell scale
|
||||
// factors. We use the height factor as width
|
||||
// could have been modified by max_xy_ratio.
|
||||
_, const single_height_factor = self.scale_factors(group, metrics, 1);
|
||||
height_factor = @max(1, single_height_factor);
|
||||
}
|
||||
width_factor = height_factor;
|
||||
},
|
||||
.stretch => {},
|
||||
}
|
||||
|
||||
// Reduce aspect ratio if required
|
||||
if (self.max_xy_ratio) |ratio| {
|
||||
if (group.width * width_factor > group.height * height_factor * ratio) {
|
||||
width_factor = group.height * height_factor * ratio / group.width;
|
||||
}
|
||||
}
|
||||
|
||||
return .{ width_factor, height_factor };
|
||||
}
|
||||
|
||||
/// Return vertical bearing for aligning this group
|
||||
fn aligned_y(
|
||||
self: Constraint,
|
||||
group: GlyphSize,
|
||||
metrics: Metrics,
|
||||
) f64 {
|
||||
if ((self.size == .none) and (self.align_vertical == .none)) {
|
||||
// If we don't have any constraints affecting the vertical axis,
|
||||
// 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 center_y = (start_y + end_y) / 2;
|
||||
return switch (self.align_vertical) {
|
||||
// NOTE: Even if there is no prescribed alignment, we ensure
|
||||
// that the group doesn't protrude outside the padded cell,
|
||||
// since this is implied by every available size constraint. If
|
||||
// the group is too high we fall back to centering, though if we
|
||||
// hit the .none prong we always have self.size != .none, so
|
||||
// this should never happen.
|
||||
.none => if (end_y < start_y)
|
||||
center_y
|
||||
else
|
||||
@max(start_y, @min(group.y, end_y)),
|
||||
.start => start_y,
|
||||
.end => end_y,
|
||||
.center, .center1 => center_y,
|
||||
};
|
||||
}
|
||||
|
||||
/// Return horizontal bearing for aligning this group
|
||||
fn aligned_x(
|
||||
self: Constraint,
|
||||
group: GlyphSize,
|
||||
metrics: Metrics,
|
||||
min_constraint_width: u2,
|
||||
) f64 {
|
||||
if ((self.size == .none) and (self.align_horizontal == .none)) {
|
||||
// If we don't have any constraints affecting the horizontal
|
||||
// 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 start_x = pad_left_dx;
|
||||
const end_x = full_face_span - 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,
|
||||
// since this is implied by every available size constraint. The
|
||||
// left-side bound has priority if the group is too wide, though
|
||||
// if we hit the .none prong we always have self.size != .none,
|
||||
// so this should never happen.
|
||||
.none => @max(start_x, @min(group.x, end_x)),
|
||||
.start => start_x,
|
||||
.end => @max(start_x, end_x),
|
||||
.center => @max(start_x, (start_x + end_x) / 2),
|
||||
// NOTE: .center1 implements the font_patcher rule of centering
|
||||
// in the first cell even for multi-cell constraints. Since glyphs
|
||||
// are not allowed to protrude to the left, this results in the
|
||||
// left-alignment like .start when the glyph is wider than a cell.
|
||||
.center1 => center1: {
|
||||
const end1_x = metrics.face_width - group.width - pad_right_dx;
|
||||
break :center1 @max(start_x, (start_x + end1_x) / 2);
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
test {
|
||||
@import("std").testing.refAllDecls(@This());
|
||||
}
|
||||
@@ -512,197 +110,3 @@ test "Variation.Id: slnt should be 1936486004" {
|
||||
try testing.expectEqual(@as(u32, 1936486004), @as(u32, @bitCast(id)));
|
||||
try testing.expectEqualStrings("slnt", &(id.str()));
|
||||
}
|
||||
|
||||
test "Constraints" {
|
||||
const comparison = @import("../datastruct/comparison.zig");
|
||||
const getConstraint = @import("nerd_font_attributes.zig").getConstraint;
|
||||
|
||||
// Hardcoded data matches metrics from CoreText at size 12 and DPI 96.
|
||||
|
||||
// Define grid metrics (matches font-family = JetBrains Mono)
|
||||
const metrics: Metrics = .{
|
||||
.cell_width = 10,
|
||||
.cell_height = 22,
|
||||
.cell_baseline = 5,
|
||||
.underline_position = 19,
|
||||
.underline_thickness = 1,
|
||||
.strikethrough_position = 12,
|
||||
.strikethrough_thickness = 1,
|
||||
.overline_position = 0,
|
||||
.overline_thickness = 1,
|
||||
.box_thickness = 1,
|
||||
.cursor_thickness = 1,
|
||||
.cursor_height = 22,
|
||||
.icon_height = 21.12,
|
||||
.icon_height_single = 44.48 / 3.0,
|
||||
.face_width = 9.6,
|
||||
.face_height = 21.12,
|
||||
.face_y = 0.2,
|
||||
};
|
||||
|
||||
// ASCII (no constraint).
|
||||
{
|
||||
const constraint: RenderOptions.Constraint = .none;
|
||||
|
||||
// BBox of 'x' from JetBrains Mono.
|
||||
const glyph_x: GlyphSize = .{
|
||||
.width = 6.784,
|
||||
.height = 15.28,
|
||||
.x = 1.408,
|
||||
.y = 4.84,
|
||||
};
|
||||
|
||||
// Any constraint width: do nothing.
|
||||
inline for (.{ 1, 2 }) |constraint_width| {
|
||||
try comparison.expectApproxEqual(
|
||||
glyph_x,
|
||||
constraint.constrain(glyph_x, metrics, constraint_width),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Symbol (same constraint as hardcoded in Renderer.addGlyph).
|
||||
{
|
||||
const constraint: RenderOptions.Constraint = .{ .size = .fit };
|
||||
|
||||
// BBox of '■' (0x25A0 black square) from Iosevka.
|
||||
// NOTE: This glyph is designed to span two cells.
|
||||
const glyph_25A0: GlyphSize = .{
|
||||
.width = 10.272,
|
||||
.height = 10.272,
|
||||
.x = 2.864,
|
||||
.y = 5.304,
|
||||
};
|
||||
|
||||
// Constraint width 1: scale down and shift to fit a single cell.
|
||||
try comparison.expectApproxEqual(
|
||||
GlyphSize{
|
||||
.width = metrics.face_width,
|
||||
.height = metrics.face_width,
|
||||
.x = 0,
|
||||
.y = 5.64,
|
||||
},
|
||||
constraint.constrain(glyph_25A0, metrics, 1),
|
||||
);
|
||||
|
||||
// Constraint width 2: do nothing.
|
||||
try comparison.expectApproxEqual(
|
||||
glyph_25A0,
|
||||
constraint.constrain(glyph_25A0, metrics, 2),
|
||||
);
|
||||
}
|
||||
|
||||
// Emoji (same constraint as hardcoded in SharedGrid.renderGlyph).
|
||||
{
|
||||
const constraint: RenderOptions.Constraint = .{
|
||||
.size = .cover,
|
||||
.align_horizontal = .center,
|
||||
.align_vertical = .center,
|
||||
.pad_left = 0.025,
|
||||
.pad_right = 0.025,
|
||||
};
|
||||
|
||||
// BBox of '🥸' (0x1F978) from Apple Color Emoji.
|
||||
const glyph_1F978: GlyphSize = .{
|
||||
.width = 20,
|
||||
.height = 20,
|
||||
.x = 0.46,
|
||||
.y = 1,
|
||||
};
|
||||
|
||||
// Constraint width 2: scale to cover two cells with padding, center;
|
||||
try comparison.expectApproxEqual(
|
||||
GlyphSize{
|
||||
.width = 18.72,
|
||||
.height = 18.72,
|
||||
.x = 0.44,
|
||||
.y = 1.4,
|
||||
},
|
||||
constraint.constrain(glyph_1F978, metrics, 2),
|
||||
);
|
||||
}
|
||||
|
||||
// Nerd Font default.
|
||||
{
|
||||
const constraint = getConstraint(0xea61).?;
|
||||
|
||||
// Verify that this is the constraint we expect.
|
||||
try std.testing.expectEqual(.fit_cover1, constraint.size);
|
||||
try std.testing.expectEqual(.icon, constraint.height);
|
||||
try std.testing.expectEqual(.center1, constraint.align_horizontal);
|
||||
try std.testing.expectEqual(.center1, constraint.align_vertical);
|
||||
|
||||
// BBox of '' (0xEA61 nf-cod-lightbulb) from Symbols Only.
|
||||
// NOTE: This icon is part of a group, so the
|
||||
// constraint applies to a larger bounding box.
|
||||
const glyph_EA61: GlyphSize = .{
|
||||
.width = 9.015625,
|
||||
.height = 13.015625,
|
||||
.x = 3.015625,
|
||||
.y = 3.76525,
|
||||
};
|
||||
|
||||
// Constraint width 1: scale and shift group to fit a single cell.
|
||||
try comparison.expectApproxEqual(
|
||||
GlyphSize{
|
||||
.width = 7.2125,
|
||||
.height = 10.4125,
|
||||
.x = 0.8125,
|
||||
.y = 5.950695224719102,
|
||||
},
|
||||
constraint.constrain(glyph_EA61, metrics, 1),
|
||||
);
|
||||
|
||||
// Constraint width 2: no scaling; left-align and vertically center group.
|
||||
try comparison.expectApproxEqual(
|
||||
GlyphSize{
|
||||
.width = glyph_EA61.width,
|
||||
.height = glyph_EA61.height,
|
||||
.x = 1.015625,
|
||||
.y = 4.7483690308988775,
|
||||
},
|
||||
constraint.constrain(glyph_EA61, metrics, 2),
|
||||
);
|
||||
}
|
||||
|
||||
// Nerd Font stretch.
|
||||
{
|
||||
const constraint = getConstraint(0xe0c0).?;
|
||||
|
||||
// Verify that this is the constraint we expect.
|
||||
try std.testing.expectEqual(.stretch, constraint.size);
|
||||
try std.testing.expectEqual(.cell, constraint.height);
|
||||
try std.testing.expectEqual(.start, constraint.align_horizontal);
|
||||
try std.testing.expectEqual(.center1, constraint.align_vertical);
|
||||
|
||||
// BBox of ' ' (0xE0C0 nf-ple-flame_thick) from Symbols Only.
|
||||
const glyph_E0C0: GlyphSize = .{
|
||||
.width = 16.796875,
|
||||
.height = 16.46875,
|
||||
.x = -0.796875,
|
||||
.y = 1.7109375,
|
||||
};
|
||||
|
||||
// Constraint width 1: stretch and position to exactly cover one cell.
|
||||
try comparison.expectApproxEqual(
|
||||
GlyphSize{
|
||||
.width = @floatFromInt(metrics.cell_width),
|
||||
.height = @floatFromInt(metrics.cell_height),
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
},
|
||||
constraint.constrain(glyph_E0C0, metrics, 1),
|
||||
);
|
||||
|
||||
// Constraint width 1: stretch and position to exactly cover two cells.
|
||||
try comparison.expectApproxEqual(
|
||||
GlyphSize{
|
||||
.width = @floatFromInt(2 * metrics.cell_width),
|
||||
.height = @floatFromInt(metrics.cell_height),
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
},
|
||||
constraint.constrain(glyph_E0C0, metrics, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ pub const Face = struct {
|
||||
alloc: Allocator,
|
||||
atlas: *font.Atlas,
|
||||
glyph_index: u32,
|
||||
opts: font.face.RenderOptions,
|
||||
opts: font.Glyph.RenderOptions,
|
||||
) !font.Glyph {
|
||||
var glyphs = [_]macos.graphics.Glyph{@intCast(glyph_index)};
|
||||
|
||||
|
||||
@@ -393,7 +393,7 @@ pub const Face = struct {
|
||||
}
|
||||
|
||||
/// Get a rect that represents the position and size of the loaded glyph.
|
||||
fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.face.GlyphSize {
|
||||
fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.Glyph.Size {
|
||||
// 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
|
||||
@@ -427,7 +427,7 @@ pub const Face = struct {
|
||||
alloc: Allocator,
|
||||
atlas: *font.Atlas,
|
||||
glyph_index: u32,
|
||||
opts: font.face.RenderOptions,
|
||||
opts: font.Glyph.RenderOptions,
|
||||
) !Glyph {
|
||||
self.ft_mutex.lock();
|
||||
defer self.ft_mutex.unlock();
|
||||
|
||||
@@ -189,7 +189,7 @@ pub const Face = struct {
|
||||
alloc: Allocator,
|
||||
atlas: *font.Atlas,
|
||||
glyph_index: u32,
|
||||
opts: font.face.RenderOptions,
|
||||
opts: font.Glyph.RenderOptions,
|
||||
) !font.Glyph {
|
||||
_ = opts;
|
||||
|
||||
|
||||
@@ -9,21 +9,10 @@ const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const z2d = @import("z2d");
|
||||
|
||||
const face = @import("face.zig");
|
||||
const Glyph = @import("Glyph.zig");
|
||||
const glyf = @import("opentype/glyf.zig");
|
||||
|
||||
/// Metrics describing the authored glyf coordinate space, since
|
||||
/// a glyf table doesn't contain this on its own.
|
||||
pub const DesignMetrics = struct {
|
||||
/// Units-per-em for outline/design coordinates.
|
||||
units_per_em: u32,
|
||||
|
||||
/// Authored advance width in design units.
|
||||
advance_width: u32,
|
||||
|
||||
/// Authored line height in design units.
|
||||
line_height: u32,
|
||||
};
|
||||
const DesignMetrics = Glyph.DesignMetrics;
|
||||
|
||||
/// An owned, tightly packed alpha8 bitmap.
|
||||
pub const Bitmap = struct {
|
||||
@@ -52,7 +41,7 @@ pub const Error = Allocator.Error || z2d.Path.Error || z2d.painter.FillError;
|
||||
///
|
||||
/// The returned bitmap is always `grid_metrics.cell_width * cell_width` by
|
||||
/// `grid_metrics.cell_height`. `opts.constraint` is applied using the same
|
||||
/// `face.RenderOptions.Constraint` machinery used by the platform font
|
||||
/// `RenderOptions.Constraint` machinery used by the platform font
|
||||
/// backends.
|
||||
///
|
||||
/// The caller owns the returned bitmap.
|
||||
@@ -60,7 +49,7 @@ pub fn rasterize(
|
||||
alloc: Allocator,
|
||||
outline: glyf.Glyf.Outline,
|
||||
design: DesignMetrics,
|
||||
opts: face.RenderOptions,
|
||||
opts: Glyph.RenderOptions,
|
||||
) Error!Bitmap {
|
||||
assert(design.units_per_em > 0);
|
||||
assert(design.advance_width > 0);
|
||||
@@ -207,16 +196,16 @@ const Placement = struct {
|
||||
|
||||
/// Bottom edge of the rasterized outline bounds in bitmap pixels, measured
|
||||
/// from the bitmap's bottom edge. This matches the cell-relative y axis
|
||||
/// used by font.face.GlyphSize and is converted to z2d's y-down axis when
|
||||
/// used by font.Glyph.Size and is converted to z2d's y-down axis when
|
||||
/// points are transformed.
|
||||
y: f64,
|
||||
|
||||
/// Width of the rasterized outline bounds in bitmap pixels after applying
|
||||
/// font.face.RenderOptions.Constraint.
|
||||
/// font.Glyph.RenderOptions.Constraint.
|
||||
width: f64,
|
||||
|
||||
/// Height of the rasterized outline bounds in bitmap pixels after applying
|
||||
/// font.face.RenderOptions.Constraint.
|
||||
/// font.Glyph.RenderOptions.Constraint.
|
||||
height: f64,
|
||||
|
||||
/// Full bitmap height in pixels, used to convert cell-relative y-up-ish
|
||||
@@ -240,7 +229,7 @@ const Placement = struct {
|
||||
fn init(
|
||||
bounds: Bounds,
|
||||
design: DesignMetrics,
|
||||
opts: face.RenderOptions,
|
||||
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
|
||||
@@ -253,7 +242,7 @@ const Placement = struct {
|
||||
// Convert the decoded point bounds into the same pixel coordinate space
|
||||
// expected by RenderOptions.Constraint. This rectangle is the visible
|
||||
// outline bounds, not the full advance/line-height layout box.
|
||||
const glyph: face.GlyphSize = .{
|
||||
const glyph: Glyph.Size = .{
|
||||
.width = bounds.width() * scale,
|
||||
.height = bounds.height() * scale,
|
||||
.x = bounds.x_min * scale,
|
||||
@@ -268,7 +257,7 @@ const Placement = struct {
|
||||
// Apply the same fit/cover/stretch/alignment/padding rules used by
|
||||
// normal font rendering. The result is still the outline bounds, but
|
||||
// placed as if its containing advance/line-height box was constrained.
|
||||
const constraint: face.RenderOptions.Constraint = constraint: {
|
||||
const constraint: Glyph.RenderOptions.Constraint = constraint: {
|
||||
var constraint = opts.constraint;
|
||||
if (group_width > 0 and group_height > 0) {
|
||||
// Tell Constraint that `glyph` is a sub-rectangle of the
|
||||
|
||||
2
src/font/nerd_font_attributes.zig
generated
2
src/font/nerd_font_attributes.zig
generated
@@ -4,7 +4,7 @@
|
||||
//! This file provides info extracted from the nerd fonts patcher script,
|
||||
//! specifying the scaling/positioning attributes of various glyphs.
|
||||
|
||||
const Constraint = @import("face.zig").RenderOptions.Constraint;
|
||||
const Constraint = @import("Glyph.zig").RenderOptions.Constraint;
|
||||
|
||||
/// Get the constraints for the provided codepoint.
|
||||
pub fn getConstraint(cp: u21) ?Constraint {
|
||||
|
||||
@@ -672,6 +672,9 @@ fn testAppendHeader(
|
||||
}
|
||||
|
||||
test "glyf" {
|
||||
// lib-vt source archives intentionally exclude full Ghostty font fixtures.
|
||||
if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest;
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
// Cozette because it doesn't have any hinting.
|
||||
@@ -874,6 +877,9 @@ test "glyf: decode contour ending at max point index" {
|
||||
}
|
||||
|
||||
test "glyf: reject glyphs with instructions and composite glyphs" {
|
||||
// lib-vt source archives intentionally exclude full Ghostty font fixtures.
|
||||
if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest;
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
const test_font = @import("../embedded.zig").jetbrains_mono;
|
||||
@@ -908,6 +914,9 @@ test "glyf: reject glyphs with instructions and composite glyphs" {
|
||||
}
|
||||
|
||||
test "glyf: reject truncated" {
|
||||
// lib-vt source archives intentionally exclude full Ghostty font fixtures.
|
||||
if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest;
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
// Cozette because it doesn't have any hinting.
|
||||
@@ -926,6 +935,9 @@ test "glyf: reject truncated" {
|
||||
}
|
||||
|
||||
test "glyf: reject endpoints out of order" {
|
||||
// lib-vt source archives intentionally exclude full Ghostty font fixtures.
|
||||
if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest;
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
// Cozette because it doesn't have any hinting.
|
||||
@@ -952,6 +964,9 @@ test "glyf: reject endpoints out of order" {
|
||||
}
|
||||
|
||||
test "glyf: reject too many points" {
|
||||
// lib-vt source archives intentionally exclude full Ghostty font fixtures.
|
||||
if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest;
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
// Cozette because it doesn't have any hinting.
|
||||
|
||||
@@ -285,6 +285,9 @@ pub const SFNT = struct {
|
||||
const native_endian = @import("builtin").target.cpu.arch.endian();
|
||||
|
||||
test "parse font" {
|
||||
// lib-vt source archives intentionally exclude full Ghostty font fixtures.
|
||||
if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest;
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
@@ -298,6 +301,9 @@ test "parse font" {
|
||||
}
|
||||
|
||||
test "get table" {
|
||||
// lib-vt source archives intentionally exclude full Ghostty font fixtures.
|
||||
if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest;
|
||||
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ pub fn renderGlyph(
|
||||
alloc: Allocator,
|
||||
atlas: *font.Atlas,
|
||||
cp: u32,
|
||||
opts: font.face.RenderOptions,
|
||||
opts: font.Glyph.RenderOptions,
|
||||
) !font.Glyph {
|
||||
if (std.debug.runtime_safety) {
|
||||
if (!self.hasCodepoint(cp, null)) {
|
||||
|
||||
@@ -152,7 +152,13 @@ const std = @import("std");
|
||||
|
||||
pub const request = @import("glyph/request.zig");
|
||||
pub const response = @import("glyph/response.zig");
|
||||
pub const execute = @import("glyph/execute.zig").execute;
|
||||
|
||||
pub const CommandParser = request.CommandParser;
|
||||
pub const Request = request.Request;
|
||||
pub const Response = response.Response;
|
||||
pub const Glossary = @import("glyph/Glossary.zig");
|
||||
|
||||
test {
|
||||
std.testing.refAllDecls(@This());
|
||||
}
|
||||
|
||||
9
src/terminal/apc/glyph/AGENTS.md
Normal file
9
src/terminal/apc/glyph/AGENTS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Glyph Protocol
|
||||
|
||||
- The specification source of truth is:
|
||||
<https://github.com/raphamorim/rio/blob/main/specs/glyph-protocol.md>
|
||||
- A summary of the specification is available in
|
||||
`src/terminal/apc/glyph.zig` at the top.
|
||||
- Reference the specification whenever any changes are made to
|
||||
this folder. Prefer the local specification over fetching the
|
||||
latest, unless it is lacking information.
|
||||
444
src/terminal/apc/glyph/Glossary.zig
Normal file
444
src/terminal/apc/glyph/Glossary.zig
Normal file
@@ -0,0 +1,444 @@
|
||||
/// Glossary is the per-terminal storage for Glyph Protocol
|
||||
/// codepoints. We use the word Glossary to match up with the spec which
|
||||
/// also uses this word.
|
||||
const Glossary = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const CircBuf = @import("../../../datastruct/circ_buf.zig").CircBuf;
|
||||
const FontGlyph = @import("../../../font/Glyph.zig");
|
||||
const Glyf = @import("../../../font/opentype/glyf.zig").Glyf;
|
||||
|
||||
const request = @import("request.zig");
|
||||
const RegisterReq = request.Request.Register;
|
||||
|
||||
const DesignMetrics = FontGlyph.DesignMetrics;
|
||||
const Constraint = FontGlyph.RenderOptions.Constraint;
|
||||
|
||||
/// Maximum entries allowed in the glossary before eviction.
|
||||
/// Defined by the specification.
|
||||
pub const max_entries = 1024;
|
||||
|
||||
/// An empty glossary with no registered glyphs.
|
||||
pub const empty: Glossary = .{ .entries = .empty };
|
||||
|
||||
/// Errors that can occur while registering a glossary entry.
|
||||
pub const RegisterError = Allocator.Error || error{OutOfNamespace};
|
||||
|
||||
/// Errors that can occur while clearing glossary entries.
|
||||
pub const ClearError = error{OutOfNamespace};
|
||||
|
||||
/// The set of entries in the glossary keyed by the codepoint.
|
||||
///
|
||||
/// The array hash map preserves insertion order and has O(N)
|
||||
/// orderedRemove, so we use it as a FIFO too for eviction when
|
||||
/// the glossary is full. Since the specification limits the protocol
|
||||
/// to 1024 maximum entries, ordered removal should never be that
|
||||
/// expensive.
|
||||
///
|
||||
/// I'm also operating under the assumption that full glossaries
|
||||
/// for a session will be rare, so the eviction cost shouldn't
|
||||
/// happen regularly.
|
||||
entries: std.AutoArrayHashMapUnmanaged(u21, Entry),
|
||||
|
||||
/// Release all glyph entries and hash map storage owned by the glossary.
|
||||
pub fn deinit(self: *Glossary, alloc: Allocator) void {
|
||||
for (self.entries.values()) |*entry| entry.deinit(alloc);
|
||||
self.entries.deinit(alloc);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Register the given glyph entry.
|
||||
///
|
||||
/// This will act according to the glyph specification
|
||||
pub fn register(
|
||||
self: *Glossary,
|
||||
alloc: Allocator,
|
||||
cp: u21,
|
||||
entry: Entry,
|
||||
) RegisterError!void {
|
||||
// Validate codepoint according to spec.
|
||||
if (!isPrivateUse(cp)) return error.OutOfNamespace;
|
||||
|
||||
const gop = try self.entries.getOrPut(alloc, cp);
|
||||
if (gop.found_existing) {
|
||||
// Found an existing entry, we need to shift the FIFO so
|
||||
// that this is now the most recent (at the end). This is
|
||||
// O(N) but N is usually small and max N is bounded by the spec.
|
||||
gop.value_ptr.*.deinit(alloc);
|
||||
assert(self.entries.orderedRemove(cp));
|
||||
|
||||
// We already had enough capacity for this key before removing it, so
|
||||
// reinserting the replacement cannot require another allocation.
|
||||
self.entries.putAssumeCapacity(cp, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
// Array hash maps preserve insertion order so always immediately insert.
|
||||
gop.value_ptr.* = entry;
|
||||
|
||||
// Fast, typical path: we fit within the glossary, just return.
|
||||
if (self.entries.count() <= max_entries) return;
|
||||
|
||||
// Slow path: we need to evict.
|
||||
self.entries.values()[0].deinit(alloc);
|
||||
self.entries.orderedRemoveAt(0);
|
||||
}
|
||||
|
||||
/// Delete a single entry from the glossary. If the entry doesn't exist,
|
||||
/// then this does nothing and is safe.
|
||||
pub fn delete(
|
||||
self: *Glossary,
|
||||
alloc: Allocator,
|
||||
cp: u21,
|
||||
) ClearError!void {
|
||||
if (!isPrivateUse(cp)) return error.OutOfNamespace;
|
||||
const kv = self.entries.fetchOrderedRemove(cp) orelse return;
|
||||
var entry = kv.value;
|
||||
entry.deinit(alloc);
|
||||
}
|
||||
|
||||
/// Clear all entries from the glossary and free up any underlying
|
||||
/// storage.
|
||||
pub fn clearAndFree(self: *Glossary, alloc: Allocator) void {
|
||||
for (self.entries.values()) |*entry| entry.deinit(alloc);
|
||||
self.entries.deinit(alloc);
|
||||
self.entries = .empty;
|
||||
}
|
||||
|
||||
/// Contains returns true if the codepoint is covered by the glossary.
|
||||
pub fn contains(self: *Glossary, cp: u21) bool {
|
||||
return self.entries.contains(cp);
|
||||
}
|
||||
|
||||
/// A single glyph registration entry.
|
||||
pub const Entry = struct {
|
||||
/// Stored glyph payload variants.
|
||||
pub const Glyph = union(enum) {
|
||||
glyf: Glyf.Outline,
|
||||
};
|
||||
|
||||
/// The glyph itself. The tagged union only has glyf right now but
|
||||
/// will eventually expand to support COLR and maybe other formats.
|
||||
/// These are stored as raw outlines; rasterization is delayed to
|
||||
/// renderers. The outlines have been validated.
|
||||
glyph: Glyph,
|
||||
|
||||
/// Authored metrics for the glyph's design coordinate space.
|
||||
design: DesignMetrics,
|
||||
|
||||
/// Unicode cell width requested by the registration.
|
||||
width: request.Width,
|
||||
|
||||
/// Normalized scale, alignment, and padding behavior for rasterization.
|
||||
constraint: Constraint,
|
||||
|
||||
/// Errors that can occur while constructing a glossary entry from a
|
||||
/// register request.
|
||||
pub const InitError = RegisterReq.DecodeError || error{
|
||||
/// The register request is missing a required option or has an invalid
|
||||
/// explicitly-provided option value.
|
||||
InvalidOptions,
|
||||
|
||||
/// The requested payload format is not supported by this glossary.
|
||||
UnsupportedFormat,
|
||||
};
|
||||
|
||||
/// Initialize a glossary entry from a register request.
|
||||
///
|
||||
/// This validates the request fields needed to construct the entry,
|
||||
/// decodes the base64 glyph payload, and stores the decoded outline. The
|
||||
/// returned entry owns decoded glyph memory and must be released with
|
||||
/// `deinit`.
|
||||
pub fn init(alloc: Allocator, req: RegisterReq) Entry.InitError!Entry {
|
||||
// Validate format
|
||||
const fmt = req.get(.fmt) orelse return error.InvalidOptions;
|
||||
const design: DesignMetrics = .{
|
||||
.units_per_em = req.get(.upm) orelse return error.InvalidOptions,
|
||||
.advance_width = req.get(.aw) orelse return error.InvalidOptions,
|
||||
.line_height = req.get(.lh) orelse return error.InvalidOptions,
|
||||
};
|
||||
if (design.units_per_em == 0 or
|
||||
design.advance_width == 0 or
|
||||
design.line_height == 0) return error.InvalidOptions;
|
||||
const width = req.get(.width) orelse return error.InvalidOptions;
|
||||
|
||||
// Get our constraints
|
||||
const constraint = try constraintFromRegister(req);
|
||||
|
||||
// Decode the payload into some usable glyph format for
|
||||
// future rasterization.
|
||||
const glyph: Glyph = switch (fmt) {
|
||||
.glyf => .{ .glyf = try req.decodeGlyfPayload(alloc) },
|
||||
.colrv0, .colrv1 => return error.UnsupportedFormat,
|
||||
};
|
||||
|
||||
// No more errors, since we never do glyph cleanup above.
|
||||
errdefer comptime unreachable;
|
||||
|
||||
return .{
|
||||
.glyph = glyph,
|
||||
.design = design,
|
||||
.width = width,
|
||||
.constraint = constraint,
|
||||
};
|
||||
}
|
||||
|
||||
/// Release memory owned by this entry.
|
||||
pub fn deinit(self: *Entry, alloc: Allocator) void {
|
||||
switch (self.glyph) {
|
||||
.glyf => |*outline| outline.deinit(alloc),
|
||||
}
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Return the renderer constraint for a register request.
|
||||
///
|
||||
/// Glyph Protocol §8.5 defines sizing, alignment, and padding in terms of
|
||||
/// the authored extent and render span. This function is the single
|
||||
/// normalization point for how protocol sizing choices map to the
|
||||
/// renderer-neutral constraint stored here.
|
||||
fn constraintFromRegister(
|
||||
req: RegisterReq,
|
||||
) error{InvalidOptions}!Constraint {
|
||||
// Register.get applies the Glyph Protocol §6.1 defaults when options
|
||||
// are omitted: size=height, align=center,center, and pad=0,0,0,0.
|
||||
const size = req.get(.size) orelse return error.InvalidOptions;
|
||||
const alignment = req.get(.@"align") orelse return error.InvalidOptions;
|
||||
const pad = req.get(.pad) orelse return error.InvalidOptions;
|
||||
|
||||
return .{
|
||||
.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,
|
||||
},
|
||||
.align_horizontal = switch (alignment.horizontal) {
|
||||
.start => .start,
|
||||
.center => .center,
|
||||
.end => .end,
|
||||
},
|
||||
.align_vertical = switch (alignment.vertical) {
|
||||
.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,
|
||||
},
|
||||
.pad_top = pad.top,
|
||||
.pad_right = pad.right,
|
||||
.pad_bottom = pad.bottom,
|
||||
.pad_left = pad.left,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Return true if `cp` is in one of the Unicode Private Use Areas.
|
||||
fn isPrivateUse(cp: u21) bool {
|
||||
return (cp >= 0xE000 and cp <= 0xF8FF) or
|
||||
(cp >= 0xF0000 and cp <= 0xFFFFD) or
|
||||
(cp >= 0x100000 and cp <= 0x10FFFD);
|
||||
}
|
||||
|
||||
fn testParseRegister(alloc: Allocator, data: []const u8) !RegisterReq {
|
||||
const raw = try alloc.dupe(u8, data);
|
||||
errdefer alloc.free(raw);
|
||||
|
||||
const req = try request.Request.parse(alloc, raw);
|
||||
switch (req) {
|
||||
.register => |reg| return reg,
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
// Base64-encoded glyf payload from the "glyf: decode triangle" test in
|
||||
// font/opentype/glyf.zig. This is a real simple-glyph record with one contour
|
||||
// and three on-curve points.
|
||||
const test_triangle_glyf_payload = "AAEAZABkA4QDhAACAAABAQEB9P5wAyADhPzgAAA=";
|
||||
|
||||
fn testRegisterReq(alloc: Allocator, cp: u21) !RegisterReq {
|
||||
const data = try std.fmt.allocPrint(
|
||||
alloc,
|
||||
"r;cp={x};upm=2048;aw=1024;lh=1536;width=2;size=stretch;align=end,start;pad=0.1,0.2,0.3,0.4;{s}",
|
||||
.{ cp, test_triangle_glyf_payload },
|
||||
);
|
||||
errdefer alloc.free(data);
|
||||
|
||||
const req = try request.Request.parse(alloc, data);
|
||||
switch (req) {
|
||||
.register => |reg| return reg,
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
fn testRegisterEntry(alloc: Allocator, cp: u21) !Entry {
|
||||
const req = try testRegisterReq(alloc, cp);
|
||||
defer alloc.free(req.raw);
|
||||
return try Entry.init(alloc, req);
|
||||
}
|
||||
|
||||
test "Entry init decodes glyf payload and applies register fields" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const req = try testRegisterReq(alloc, 0xE000);
|
||||
defer alloc.free(req.raw);
|
||||
|
||||
var entry = try Entry.init(alloc, req);
|
||||
defer entry.deinit(alloc);
|
||||
|
||||
try testing.expectEqual(@as(u32, 2048), entry.design.units_per_em);
|
||||
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.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);
|
||||
try testing.expectEqual(@as(f64, 0.2), entry.constraint.pad_right);
|
||||
try testing.expectEqual(@as(f64, 0.3), entry.constraint.pad_bottom);
|
||||
try testing.expectEqual(@as(f64, 0.4), entry.constraint.pad_left);
|
||||
|
||||
try testing.expectEqual(@as(usize, 3), entry.glyph.glyf.points.len);
|
||||
try testing.expectEqual(@as(usize, 1), entry.glyph.glyf.contours.len);
|
||||
}
|
||||
|
||||
test "Entry init rejects invalid register payload" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const req = try testParseRegister(alloc, "r;cp=e000;%%%not-base64%%%");
|
||||
defer alloc.free(req.raw);
|
||||
|
||||
try testing.expectError(error.MalformedPayload, Entry.init(alloc, req));
|
||||
}
|
||||
|
||||
test "Glossary register overwrites and moves entry to newest position" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000));
|
||||
try glossary.register(alloc, 0xE001, try testRegisterEntry(alloc, 0xE001));
|
||||
try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000));
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), glossary.entries.count());
|
||||
try testing.expectEqual(@as(u21, 0xE001), glossary.entries.keys()[0]);
|
||||
try testing.expectEqual(@as(u21, 0xE000), glossary.entries.keys()[1]);
|
||||
}
|
||||
|
||||
test "Glossary register evicts oldest entry" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
for (0..max_entries + 1) |i| {
|
||||
const cp: u21 = @intCast(0xE000 + i);
|
||||
try glossary.register(alloc, cp, try testRegisterEntry(alloc, cp));
|
||||
}
|
||||
|
||||
try testing.expectEqual(@as(usize, max_entries), glossary.entries.count());
|
||||
try testing.expect(!glossary.entries.contains(0xE000));
|
||||
try testing.expect(glossary.entries.contains(0xE001));
|
||||
try testing.expect(glossary.entries.contains(0xE000 + max_entries));
|
||||
}
|
||||
|
||||
test "Glossary register rejects non-PUA codepoint" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
var entry = try testRegisterEntry(alloc, 0xE000);
|
||||
defer entry.deinit(alloc);
|
||||
try testing.expectError(error.OutOfNamespace, glossary.register(alloc, 'A', entry));
|
||||
}
|
||||
|
||||
test "Glossary delete removes one PUA slot and ignores empty PUA slot" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000));
|
||||
try glossary.register(alloc, 0xE001, try testRegisterEntry(alloc, 0xE001));
|
||||
|
||||
try glossary.delete(alloc, 0xE000);
|
||||
try testing.expectEqual(@as(usize, 1), glossary.entries.count());
|
||||
try testing.expect(!glossary.contains(0xE000));
|
||||
try testing.expect(glossary.contains(0xE001));
|
||||
|
||||
try glossary.delete(alloc, 0xE000);
|
||||
try testing.expectEqual(@as(usize, 1), glossary.entries.count());
|
||||
try testing.expect(glossary.contains(0xE001));
|
||||
}
|
||||
|
||||
test "Glossary delete rejects non-PUA codepoint" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000));
|
||||
try testing.expectError(error.OutOfNamespace, glossary.delete(alloc, 'A'));
|
||||
try testing.expectEqual(@as(usize, 1), glossary.entries.count());
|
||||
try testing.expect(glossary.contains(0xE000));
|
||||
}
|
||||
|
||||
test "Glossary clearAndFree removes all slots and remains reusable" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000));
|
||||
try glossary.register(alloc, 0xE001, try testRegisterEntry(alloc, 0xE001));
|
||||
|
||||
glossary.clearAndFree(alloc);
|
||||
try testing.expectEqual(@as(usize, 0), glossary.entries.count());
|
||||
try testing.expect(!glossary.contains(0xE000));
|
||||
try testing.expect(!glossary.contains(0xE001));
|
||||
|
||||
try glossary.register(alloc, 0xE002, try testRegisterEntry(alloc, 0xE002));
|
||||
try testing.expectEqual(@as(usize, 1), glossary.entries.count());
|
||||
try testing.expect(glossary.contains(0xE002));
|
||||
}
|
||||
|
||||
test "Glossary contains reports registered slots" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
try testing.expect(!glossary.contains(0xE000));
|
||||
|
||||
try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000));
|
||||
try testing.expect(glossary.contains(0xE000));
|
||||
try testing.expect(!glossary.contains(0xE001));
|
||||
}
|
||||
367
src/terminal/apc/glyph/execute.zig
Normal file
367
src/terminal/apc/glyph/execute.zig
Normal file
@@ -0,0 +1,367 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const request = @import("request.zig");
|
||||
const response = @import("response.zig");
|
||||
const Glossary = @import("Glossary.zig");
|
||||
const Request = request.Request;
|
||||
const Response = response.Response;
|
||||
|
||||
const log = std.log.scoped(.glyph);
|
||||
|
||||
/// Payload formats we support. Hardcoded because the support is
|
||||
/// fixed.
|
||||
pub const supported_formats: response.Response.Support.Formats = .{
|
||||
.glyf = true,
|
||||
};
|
||||
|
||||
/// Execute a Glyph protocol request against the given state.
|
||||
///
|
||||
/// This will never fail, but the response may indiciate an error and
|
||||
/// the terminal state may not be updated to reflect the command. This will
|
||||
/// never put the terminal in a corrupt or non-recoverable state.
|
||||
///
|
||||
/// For example, allocation errors can happen, but they're wrapped up in
|
||||
/// an out of memory response.
|
||||
///
|
||||
/// Query responses only report glossary coverage. Callers that can determine
|
||||
/// system font coverage must update the returned query response before sending
|
||||
/// it to the client.
|
||||
pub fn execute(
|
||||
alloc: Allocator,
|
||||
glossary: *Glossary,
|
||||
req: *const Request,
|
||||
) ?Response {
|
||||
log.debug("executing glyph protocol request: {t}", .{req.*});
|
||||
return switch (req.*) {
|
||||
.support => .{ .support = .{ .fmt = supported_formats } },
|
||||
.query => |qry| query(glossary, qry),
|
||||
.register => |reg| register(alloc, glossary, reg),
|
||||
.clear => |clr| clear(alloc, glossary, clr),
|
||||
};
|
||||
}
|
||||
|
||||
fn query(
|
||||
glossary: *Glossary,
|
||||
qry: Request.Query,
|
||||
) ?Response {
|
||||
const cp = qry.get(.cp) orelse return null;
|
||||
return .{ .query = .{
|
||||
.cp = cp,
|
||||
.status = .{
|
||||
.glossary = glossary.contains(cp),
|
||||
},
|
||||
} };
|
||||
}
|
||||
|
||||
fn register(
|
||||
alloc: Allocator,
|
||||
glossary: *Glossary,
|
||||
reg: Request.Register,
|
||||
) ?Response {
|
||||
const reply = reg.get(.reply) orelse .all;
|
||||
const cp = registerFallible(alloc, glossary, reg) catch |err| return switch (reply) {
|
||||
.none => null,
|
||||
.all, .failures => .{ .register = .{
|
||||
.cp = reg.get(.cp) orelse 0,
|
||||
.status = .err,
|
||||
.reason = switch (err) {
|
||||
error.OutOfMemory => .{ .other = "out_of_memory" },
|
||||
error.OutOfNamespace => .out_of_namespace,
|
||||
error.PayloadTooLarge => .payload_too_large,
|
||||
error.MalformedPayload => .malformed_payload,
|
||||
error.CompositeUnsupported => .composite_unsupported,
|
||||
error.HintingUnsupported => .hinting_unsupported,
|
||||
error.InvalidOptions,
|
||||
error.UnsupportedFormat,
|
||||
=> .malformed_payload,
|
||||
},
|
||||
} },
|
||||
};
|
||||
|
||||
return switch (reply) {
|
||||
.none, .failures => null,
|
||||
.all => .{ .register = .{ .cp = cp } },
|
||||
};
|
||||
}
|
||||
|
||||
fn registerFallible(
|
||||
alloc: Allocator,
|
||||
glossary: *Glossary,
|
||||
reg: Request.Register,
|
||||
) (Glossary.Entry.InitError || Glossary.RegisterError)!u21 {
|
||||
const cp = reg.get(.cp) orelse
|
||||
return error.MalformedPayload;
|
||||
|
||||
var entry = try Glossary.Entry.init(alloc, reg);
|
||||
errdefer entry.deinit(alloc);
|
||||
|
||||
try glossary.register(alloc, cp, entry);
|
||||
return cp;
|
||||
}
|
||||
|
||||
fn clear(
|
||||
alloc: Allocator,
|
||||
glossary: *Glossary,
|
||||
clr: Request.Clear,
|
||||
) ?Response {
|
||||
if (clr.get(.cp)) |cp| {
|
||||
glossary.delete(alloc, cp) catch |err| return .{ .clear = .{
|
||||
.status = .err,
|
||||
.reason = switch (err) {
|
||||
error.OutOfNamespace => "out_of_namespace",
|
||||
},
|
||||
} };
|
||||
} else if (clr.has(.cp)) {
|
||||
return .{ .clear = .{
|
||||
.status = .err,
|
||||
.reason = "malformed_payload",
|
||||
} };
|
||||
} else {
|
||||
glossary.clearAndFree(alloc);
|
||||
}
|
||||
|
||||
return .{ .clear = .{} };
|
||||
}
|
||||
|
||||
fn testParse(alloc: Allocator, data: []const u8) !Request {
|
||||
var parser = request.CommandParser.init(alloc, 1024 * 1024);
|
||||
defer parser.deinit();
|
||||
for (data) |byte| try parser.feed(byte);
|
||||
return try parser.complete(alloc);
|
||||
}
|
||||
|
||||
fn testExecute(alloc: Allocator, glossary: *Glossary, req: *const Request) ?Response {
|
||||
return execute(alloc, glossary, req);
|
||||
}
|
||||
|
||||
test "execute register stores glyph and returns success" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
var req = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA==");
|
||||
defer req.deinit(alloc);
|
||||
|
||||
try testing.expectEqual(Response{
|
||||
.register = .{ .cp = 0xE0A0 },
|
||||
}, testExecute(alloc, &glossary, &req).?);
|
||||
try testing.expect(glossary.contains(0xE0A0));
|
||||
}
|
||||
|
||||
test "execute register reply failures suppresses success" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
var req = try testParse(alloc, "r;cp=e0a0;reply=2;AAAAAAAAAAAAAA==");
|
||||
defer req.deinit(alloc);
|
||||
|
||||
try testing.expect(testExecute(alloc, &glossary, &req) == null);
|
||||
try testing.expect(glossary.contains(0xE0A0));
|
||||
}
|
||||
|
||||
test "execute register reply none suppresses failure" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
var req = try testParse(alloc, "r;cp=41;reply=0;%%%not-base64%%%");
|
||||
defer req.deinit(alloc);
|
||||
|
||||
try testing.expect(testExecute(alloc, &glossary, &req) == null);
|
||||
try testing.expect(!glossary.contains('A'));
|
||||
}
|
||||
|
||||
test "execute register rejects non-PUA" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
var req = try testParse(alloc, "r;cp=41;AAAAAAAAAAAAAA==");
|
||||
defer req.deinit(alloc);
|
||||
|
||||
try testing.expectEqual(Response{
|
||||
.register = .{
|
||||
.cp = 'A',
|
||||
.status = .err,
|
||||
.reason = .out_of_namespace,
|
||||
},
|
||||
}, testExecute(alloc, &glossary, &req).?);
|
||||
try testing.expect(!glossary.contains('A'));
|
||||
}
|
||||
|
||||
test "execute register reports malformed payload" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
var req = try testParse(alloc, "r;cp=e0a0;%%%not-base64%%%");
|
||||
defer req.deinit(alloc);
|
||||
|
||||
try testing.expectEqual(Response{
|
||||
.register = .{
|
||||
.cp = 0xE0A0,
|
||||
.status = .err,
|
||||
.reason = .malformed_payload,
|
||||
},
|
||||
}, testExecute(alloc, &glossary, &req).?);
|
||||
try testing.expect(!glossary.contains(0xE0A0));
|
||||
}
|
||||
|
||||
test "execute clear removes all glyphs" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA==");
|
||||
defer reg1.deinit(alloc);
|
||||
_ = testExecute(alloc, &glossary, ®1);
|
||||
|
||||
var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA==");
|
||||
defer reg2.deinit(alloc);
|
||||
_ = testExecute(alloc, &glossary, ®2);
|
||||
|
||||
var req = try testParse(alloc, "c");
|
||||
defer req.deinit(alloc);
|
||||
|
||||
try testing.expectEqual(Response{ .clear = .{} }, testExecute(alloc, &glossary, &req).?);
|
||||
try testing.expect(!glossary.contains(0xE0A0));
|
||||
try testing.expect(!glossary.contains(0xE0A1));
|
||||
}
|
||||
|
||||
test "execute clear removes one glyph" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA==");
|
||||
defer reg1.deinit(alloc);
|
||||
_ = testExecute(alloc, &glossary, ®1);
|
||||
|
||||
var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA==");
|
||||
defer reg2.deinit(alloc);
|
||||
_ = testExecute(alloc, &glossary, ®2);
|
||||
|
||||
var req = try testParse(alloc, "c;cp=e0a0");
|
||||
defer req.deinit(alloc);
|
||||
|
||||
try testing.expectEqual(Response{ .clear = .{} }, testExecute(alloc, &glossary, &req).?);
|
||||
try testing.expect(!glossary.contains(0xE0A0));
|
||||
try testing.expect(glossary.contains(0xE0A1));
|
||||
}
|
||||
|
||||
test "execute clear rejects non-PUA" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
var req = try testParse(alloc, "c;cp=41");
|
||||
defer req.deinit(alloc);
|
||||
|
||||
try testing.expectEqual(Response{
|
||||
.clear = .{
|
||||
.status = .err,
|
||||
.reason = "out_of_namespace",
|
||||
},
|
||||
}, testExecute(alloc, &glossary, &req).?);
|
||||
}
|
||||
|
||||
test "execute clear rejects malformed cp without clearing glossary" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA==");
|
||||
defer reg1.deinit(alloc);
|
||||
_ = testExecute(alloc, &glossary, ®1);
|
||||
|
||||
var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA==");
|
||||
defer reg2.deinit(alloc);
|
||||
_ = testExecute(alloc, &glossary, ®2);
|
||||
|
||||
for ([_][]const u8{ "c;cp=zz", "c;cp=", "c;cp=200000" }) |data| {
|
||||
var req = try testParse(alloc, data);
|
||||
defer req.deinit(alloc);
|
||||
|
||||
try testing.expectEqual(Response{
|
||||
.clear = .{
|
||||
.status = .err,
|
||||
.reason = "malformed_payload",
|
||||
},
|
||||
}, testExecute(alloc, &glossary, &req).?);
|
||||
try testing.expect(glossary.contains(0xE0A0));
|
||||
try testing.expect(glossary.contains(0xE0A1));
|
||||
}
|
||||
}
|
||||
|
||||
test "execute query reports no coverage" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
var req = try testParse(alloc, "q;cp=e0a0");
|
||||
defer req.deinit(alloc);
|
||||
|
||||
try testing.expectEqual(Response{
|
||||
.query = .{
|
||||
.cp = 0xE0A0,
|
||||
.status = .{},
|
||||
},
|
||||
}, testExecute(alloc, &glossary, &req).?);
|
||||
}
|
||||
|
||||
test "execute query reports glossary coverage" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
var reg = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA==");
|
||||
defer reg.deinit(alloc);
|
||||
_ = testExecute(alloc, &glossary, ®);
|
||||
|
||||
var req = try testParse(alloc, "q;cp=e0a0");
|
||||
defer req.deinit(alloc);
|
||||
|
||||
try testing.expectEqual(Response{
|
||||
.query = .{
|
||||
.cp = 0xE0A0,
|
||||
.status = .{ .glossary = true },
|
||||
},
|
||||
}, testExecute(alloc, &glossary, &req).?);
|
||||
}
|
||||
|
||||
test "execute query without cp returns no response" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var glossary: Glossary = .empty;
|
||||
defer glossary.deinit(alloc);
|
||||
|
||||
var req = try testParse(alloc, "q;foo=bar");
|
||||
defer req.deinit(alloc);
|
||||
|
||||
try testing.expect(testExecute(alloc, &glossary, &req) == null);
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const assert = std.debug.assert;
|
||||
const Glyf = @import("../../../font/opentype/glyf.zig").Glyf;
|
||||
|
||||
/// Maximum decoded glyph payload size accepted by the protocol.
|
||||
/// This is documented in the spec.
|
||||
const max_payload_size = 64 * 1024; // 64 KiB
|
||||
|
||||
/// Stateful parser for a single glyph APC payload after the `25a1;` prefix.
|
||||
pub const CommandParser = struct {
|
||||
@@ -114,12 +119,12 @@ pub const Request = union(enum) {
|
||||
payload_idx: usize,
|
||||
|
||||
/// Initialize a register command from owned raw command bytes.
|
||||
pub fn init(raw: []const u8) Register {
|
||||
assert(raw.len >= 2);
|
||||
assert(raw[0] == 'r');
|
||||
assert(raw[1] == ';');
|
||||
const payload_idx = std.mem.lastIndexOfScalar(u8, raw, ';').?;
|
||||
assert(payload_idx > 1);
|
||||
pub fn init(raw: []const u8) ?Register {
|
||||
if (raw.len < 2) return null;
|
||||
if (raw[0] != 'r') return null;
|
||||
if (raw[1] != ';') return null;
|
||||
const payload_idx = std.mem.lastIndexOfScalar(u8, raw, ';') orelse return null;
|
||||
if (payload_idx <= 1) return null;
|
||||
|
||||
return .{
|
||||
.raw = raw,
|
||||
@@ -242,6 +247,65 @@ pub const Request = union(enum) {
|
||||
self.raw[self.payload_idx + 1 ..];
|
||||
}
|
||||
|
||||
/// Errors that can occur while decoding a register glyph payload.
|
||||
pub const DecodeError = Allocator.Error || error{
|
||||
/// The decoded payload exceeds the protocol limit.
|
||||
PayloadTooLarge,
|
||||
|
||||
/// The payload could not be decoded or parsed as the declared format.
|
||||
MalformedPayload,
|
||||
|
||||
/// The glyf payload is composite, which the protocol forbids.
|
||||
CompositeUnsupported,
|
||||
|
||||
/// The glyf payload contains hinting instructions, which the
|
||||
/// protocol forbids.
|
||||
HintingUnsupported,
|
||||
};
|
||||
|
||||
/// Decode this request's base64 glyf payload into an owned outline.
|
||||
pub fn decodeGlyfPayload(self: Register, alloc: Allocator) DecodeError!Glyf.Outline {
|
||||
// Prep base64 decoding, initial validation.
|
||||
const Decoder = std.base64.standard.Decoder;
|
||||
const payload_bytes = self.payload();
|
||||
const size = Decoder.calcSizeForSlice(payload_bytes) catch
|
||||
return error.MalformedPayload;
|
||||
if (size > max_payload_size) return error.PayloadTooLarge;
|
||||
|
||||
// Max payload size is reasonable for stack and its likely
|
||||
// we'll have stack space. We don't use much stack space in
|
||||
// the future function calls either, so try a stack allocator
|
||||
// here and fallback to heap as necessary.
|
||||
var data_stack = std.heap.stackFallback(
|
||||
max_payload_size,
|
||||
alloc,
|
||||
);
|
||||
const data_alloc = data_stack.get();
|
||||
const data = try data_alloc.alloc(u8, size);
|
||||
defer data_alloc.free(data);
|
||||
|
||||
// Base64 decode
|
||||
Decoder.decode(data, payload_bytes) catch
|
||||
return error.MalformedPayload;
|
||||
|
||||
// Glyf.Entry borrows from `data`, but only for the duration of the
|
||||
// decode call below. Glyf.Entry.decode returns an owned Outline, so
|
||||
// it is safe to free `data` before returning that outline.
|
||||
const glyf_entry = Glyf.Entry.init(data) catch return error.MalformedPayload;
|
||||
return glyf_entry.decode(alloc) catch |err| switch (err) {
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
// Unsupported fields
|
||||
error.CompositeNotSupported => error.CompositeUnsupported,
|
||||
error.InstructionsNotSupported => error.HintingUnsupported,
|
||||
// Various semantic issues
|
||||
error.EndOfStream,
|
||||
error.EndPointsOutOfOrder,
|
||||
error.TooManyPoints,
|
||||
error.CoordinateOverflow,
|
||||
=> error.MalformedPayload,
|
||||
};
|
||||
}
|
||||
|
||||
/// Return the raw option portion of a valid register command.
|
||||
fn rawOptions(self: Register) []const u8 {
|
||||
assert(self.raw.len >= 2);
|
||||
@@ -286,6 +350,12 @@ pub const Request = union(enum) {
|
||||
.cp => std.fmt.parseInt(u21, value, 16) catch null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Return whether the option is present in the raw option string,
|
||||
/// independent of whether its value can be decoded.
|
||||
pub fn present(comptime self: Option, raw: []const u8) bool {
|
||||
return optionValue(raw, self.key()) != null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Lazily decode a clear option on demand.
|
||||
@@ -293,6 +363,11 @@ pub const Request = union(enum) {
|
||||
return option.read(self.rawOptions());
|
||||
}
|
||||
|
||||
/// Return whether a clear option was provided, even if malformed.
|
||||
pub fn has(self: Clear, comptime option: Option) bool {
|
||||
return option.present(self.rawOptions());
|
||||
}
|
||||
|
||||
/// Return the raw option portion of a valid clear command.
|
||||
fn rawOptions(self: Clear) []const u8 {
|
||||
assert(self.raw.len >= 2);
|
||||
@@ -320,7 +395,7 @@ pub const Request = union(enum) {
|
||||
return .support;
|
||||
},
|
||||
'q' => .{ .query = .init(raw) },
|
||||
'r' => .{ .register = .init(raw) },
|
||||
'r' => .{ .register = Register.init(raw) orelse return error.InvalidFormat },
|
||||
'c' => .{ .clear = .init(raw) },
|
||||
else => error.InvalidFormat,
|
||||
};
|
||||
@@ -690,6 +765,39 @@ test "register command with invalid payload" {
|
||||
try testing.expectEqualStrings("%%%not-base64%%%", cmd.register.payload());
|
||||
}
|
||||
|
||||
test "register command rejects missing payload separator" {
|
||||
const testing = std.testing;
|
||||
|
||||
for ([_][]const u8{ "r", "r;cp=e0a0", "r;foo" }) |data| {
|
||||
try testing.expectError(
|
||||
error.InvalidFormat,
|
||||
testParse(testing.allocator, data),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "register decodes glyf payload" {
|
||||
const testing = std.testing;
|
||||
|
||||
var cmd = try testParse(testing.allocator, "r;cp=e0a0;AAAAAAAAAAAAAA==");
|
||||
defer cmd.deinit(testing.allocator);
|
||||
|
||||
var outline = try cmd.register.decodeGlyfPayload(testing.allocator);
|
||||
defer outline.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), outline.points.len);
|
||||
try testing.expectEqual(@as(usize, 0), outline.contours.len);
|
||||
}
|
||||
|
||||
test "register rejects malformed glyf payload" {
|
||||
const testing = std.testing;
|
||||
|
||||
var cmd = try testParse(testing.allocator, "r;cp=e0a0;%%%not-base64%%%");
|
||||
defer cmd.deinit(testing.allocator);
|
||||
|
||||
try testing.expectError(error.MalformedPayload, cmd.register.decodeGlyfPayload(testing.allocator));
|
||||
}
|
||||
|
||||
test "register response without payload" {
|
||||
const testing = std.testing;
|
||||
|
||||
@@ -713,9 +821,23 @@ test "clear command" {
|
||||
defer cmd.deinit(testing.allocator);
|
||||
|
||||
try testing.expect(cmd == .clear);
|
||||
try testing.expect(cmd.clear.has(.cp));
|
||||
try testing.expectEqual(@as(u21, 0xE0A0), cmd.clear.get(.cp).?);
|
||||
}
|
||||
|
||||
test "clear command tracks malformed cp presence" {
|
||||
const testing = std.testing;
|
||||
|
||||
for ([_][]const u8{ "c;cp=zz", "c;cp=", "c;cp=200000" }) |data| {
|
||||
var cmd = try testParse(testing.allocator, data);
|
||||
defer cmd.deinit(testing.allocator);
|
||||
|
||||
try testing.expect(cmd == .clear);
|
||||
try testing.expect(cmd.clear.has(.cp));
|
||||
try testing.expect(cmd.clear.get(.cp) == null);
|
||||
}
|
||||
}
|
||||
|
||||
test "invalid command" {
|
||||
const testing = std.testing;
|
||||
|
||||
|
||||
@@ -303,11 +303,11 @@ test "register reason names" {
|
||||
const testing = std.testing;
|
||||
const Reason = Response.Register.Reason;
|
||||
|
||||
try testing.expectEqualStrings("out_of_namespace", Reason.out_of_namespace.name());
|
||||
try testing.expectEqualStrings("composite_unsupported", Reason.composite_unsupported.name());
|
||||
try testing.expectEqualStrings("hinting_unsupported", Reason.hinting_unsupported.name());
|
||||
try testing.expectEqualStrings("malformed_payload", Reason.malformed_payload.name());
|
||||
try testing.expectEqualStrings("payload_too_large", Reason.payload_too_large.name());
|
||||
try testing.expectEqualStrings("out_of_namespace", (Reason{ .out_of_namespace = {} }).name());
|
||||
try testing.expectEqualStrings("composite_unsupported", (Reason{ .composite_unsupported = {} }).name());
|
||||
try testing.expectEqualStrings("hinting_unsupported", (Reason{ .hinting_unsupported = {} }).name());
|
||||
try testing.expectEqualStrings("malformed_payload", (Reason{ .malformed_payload = {} }).name());
|
||||
try testing.expectEqualStrings("payload_too_large", (Reason{ .payload_too_large = {} }).name());
|
||||
try testing.expectEqualStrings("future_reason", (Reason{ .other = "future_reason" }).name());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user