diff --git a/src/font/CodepointResolver.zig b/src/font/CodepointResolver.zig index a4f13c290..90fe2491c 100644 --- a/src/font/CodepointResolver.zig +++ b/src/font/CodepointResolver.zig @@ -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; diff --git a/src/font/Glyph.zig b/src/font/Glyph.zig index f99370271..62c44e227 100644 --- a/src/font/Glyph.zig +++ b/src/font/Glyph.zig @@ -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), + ); + } +} diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index a72cb7bee..60a300b0c 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -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); diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 5fd729b30..18870aaba 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -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); diff --git a/src/font/face.zig b/src/font/face.zig index d77253adf..aeb7280c7 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -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), - ); - } -} diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 1d1333882..cd8bc7d8b 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -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)}; diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 528f72d52..d04fe1809 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -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(); diff --git a/src/font/face/web_canvas.zig b/src/font/face/web_canvas.zig index b4f9f5d5d..a36a482dd 100644 --- a/src/font/face/web_canvas.zig +++ b/src/font/face/web_canvas.zig @@ -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; diff --git a/src/font/glyf_rasterize.zig b/src/font/glyf_rasterize.zig index 43dde026f..f321214bc 100644 --- a/src/font/glyf_rasterize.zig +++ b/src/font/glyf_rasterize.zig @@ -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 diff --git a/src/font/nerd_font_attributes.zig b/src/font/nerd_font_attributes.zig index f4a19d963..fc9884964 100644 --- a/src/font/nerd_font_attributes.zig +++ b/src/font/nerd_font_attributes.zig @@ -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 { diff --git a/src/font/opentype/glyf.zig b/src/font/opentype/glyf.zig index 194385345..912b5fe84 100644 --- a/src/font/opentype/glyf.zig +++ b/src/font/opentype/glyf.zig @@ -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. diff --git a/src/font/opentype/sfnt.zig b/src/font/opentype/sfnt.zig index 9373cda03..55a9a0bdf 100644 --- a/src/font/opentype/sfnt.zig +++ b/src/font/opentype/sfnt.zig @@ -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; diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index 596a92044..f5df2d421 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -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)) { diff --git a/src/terminal/apc/glyph.zig b/src/terminal/apc/glyph.zig index 67eb5163f..727e1ab4d 100644 --- a/src/terminal/apc/glyph.zig +++ b/src/terminal/apc/glyph.zig @@ -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()); +} diff --git a/src/terminal/apc/glyph/AGENTS.md b/src/terminal/apc/glyph/AGENTS.md new file mode 100644 index 000000000..c81029c0f --- /dev/null +++ b/src/terminal/apc/glyph/AGENTS.md @@ -0,0 +1,9 @@ +# Glyph Protocol + +- The specification source of truth is: + +- 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. diff --git a/src/terminal/apc/glyph/Glossary.zig b/src/terminal/apc/glyph/Glossary.zig new file mode 100644 index 000000000..28458dbb2 --- /dev/null +++ b/src/terminal/apc/glyph/Glossary.zig @@ -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)); +} diff --git a/src/terminal/apc/glyph/execute.zig b/src/terminal/apc/glyph/execute.zig new file mode 100644 index 000000000..059029a62 --- /dev/null +++ b/src/terminal/apc/glyph/execute.zig @@ -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); +} diff --git a/src/terminal/apc/glyph/request.zig b/src/terminal/apc/glyph/request.zig index 4c50525fc..b33a28413 100644 --- a/src/terminal/apc/glyph/request.zig +++ b/src/terminal/apc/glyph/request.zig @@ -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; diff --git a/src/terminal/apc/glyph/response.zig b/src/terminal/apc/glyph/response.zig index 4ed52b0b2..5b72495d7 100644 --- a/src/terminal/apc/glyph/response.zig +++ b/src/terminal/apc/glyph/response.zig @@ -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()); }