diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:28:05 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:29:28 +0300 |
| commit | 4a3302d19a2baa3a33e910e0088c24e780905b90 (patch) | |
| tree | dcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/font | |
| parent | 550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff) | |
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/font')
18 files changed, 2338 insertions, 609 deletions
diff --git a/vendor/libghostty-vt/src/font/CodepointResolver.zig b/vendor/libghostty-vt/src/font/CodepointResolver.zig index a4f13c29..90fe2491 100644 --- a/vendor/libghostty-vt/src/font/CodepointResolver.zig +++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/font/Glyph.zig b/vendor/libghostty-vt/src/font/Glyph.zig index f9937027..62c44e22 100644 --- a/vendor/libghostty-vt/src/font/Glyph.zig +++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/font/Metrics.zig b/vendor/libghostty-vt/src/font/Metrics.zig index a72cb7be..60a300b0 100644 --- a/vendor/libghostty-vt/src/font/Metrics.zig +++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/font/SharedGrid.zig b/vendor/libghostty-vt/src/font/SharedGrid.zig index 5fd729b3..18870aab 100644 --- a/vendor/libghostty-vt/src/font/SharedGrid.zig +++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/font/face.zig b/vendor/libghostty-vt/src/font/face.zig index d77253ad..aeb7280c 100644 --- a/vendor/libghostty-vt/src/font/face.zig +++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/font/face/coretext.zig b/vendor/libghostty-vt/src/font/face/coretext.zig index 1d133388..cd8bc7d8 100644 --- a/vendor/libghostty-vt/src/font/face/coretext.zig +++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/font/face/freetype.zig b/vendor/libghostty-vt/src/font/face/freetype.zig index 528f72d5..d04fe180 100644 --- a/vendor/libghostty-vt/src/font/face/freetype.zig +++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/font/face/web_canvas.zig b/vendor/libghostty-vt/src/font/face/web_canvas.zig index b4f9f5d5..a36a482d 100644 --- a/vendor/libghostty-vt/src/font/face/web_canvas.zig +++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/font/glyf_rasterize.zig b/vendor/libghostty-vt/src/font/glyf_rasterize.zig new file mode 100644 index 00000000..f321214b --- /dev/null +++ b/vendor/libghostty-vt/src/font/glyf_rasterize.zig @@ -0,0 +1,825 @@ +//! Rasterization for OpenType glyf outlines. +//! +//! This module intentionally lives in `font` rather than `font/opentype` +//! because I wanted to keep `font/opentype` dependency free on the font +//! package. + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const z2d = @import("z2d"); + +const Glyph = @import("Glyph.zig"); +const glyf = @import("opentype/glyf.zig"); + +const DesignMetrics = Glyph.DesignMetrics; + +/// An owned, tightly packed alpha8 bitmap. +pub const Bitmap = struct { + width: u32, + height: u32, + data: []u8, + + // An empty 0x0 bitmap. + pub const empty: Bitmap = .{ .width = 0, .height = 0, .data = "" }; + + pub fn initEmpty(alloc: Allocator, width: u32, height: u32) Allocator.Error!Bitmap { + const data = try alloc.alloc(u8, @as(usize, width) * @as(usize, height)); + @memset(data, 0); + return .{ .width = width, .height = height, .data = data }; + } + + pub fn deinit(self: *Bitmap, alloc: Allocator) void { + alloc.free(self.data); + self.* = undefined; + } +}; + +pub const Error = Allocator.Error || z2d.Path.Error || z2d.painter.FillError; + +/// Rasterize a decoded glyf outline to a full-cell alpha bitmap. +/// +/// The returned bitmap is always `grid_metrics.cell_width * cell_width` by +/// `grid_metrics.cell_height`. `opts.constraint` is applied using the same +/// `RenderOptions.Constraint` machinery used by the platform font +/// backends. +/// +/// The caller owns the returned bitmap. +pub fn rasterize( + alloc: Allocator, + outline: glyf.Glyf.Outline, + design: DesignMetrics, + opts: Glyph.RenderOptions, +) Error!Bitmap { + assert(design.units_per_em > 0); + assert(design.advance_width > 0); + assert(design.line_height > 0); + + // Calculate our final width/height. + const width: u32 = std.math.mul( + u32, + opts.grid_metrics.cell_width, + opts.cell_width orelse 1, + ) catch std.math.maxInt(u32); + const height = opts.grid_metrics.cell_height; + assert(width > 0 and height > 0); + + // If we have no contours or points then we have no drawable shape, but the + // caller still asked for a cell-sized bitmap. Return that full bitmap with + // zero coverage so downstream atlas/upload code doesn't need a separate + // size contract for empty glyphs. + if (outline.contours.len == 0 or outline.points.len == 0) return Bitmap.initEmpty(alloc, width, height); + + // Glyf entries have a header bounding box, but this rasterizer operates on + // the decoded Outline only. Recompute bounds from the decoded coordinate + // data so placement and scaling follow the geometry we actually draw. If a + // source glyf header disagrees with its points, the point data is the safer + // source of truth for rasterization; the header belongs in decode-time + // validation/metadata, not this font-level drawing API. + const bounds: Bounds = bounds: { + var bounds: Bounds = .{ + .x_min = @floatFromInt(outline.points[0].x), + .y_min = @floatFromInt(outline.points[0].y), + .x_max = @floatFromInt(outline.points[0].x), + .y_max = @floatFromInt(outline.points[0].y), + }; + for (outline.points[1..]) |p| { + const x: f64 = @floatFromInt(p.x); + const y: f64 = @floatFromInt(p.y); + bounds.x_min = @min(bounds.x_min, x); + bounds.y_min = @min(bounds.y_min, y); + bounds.x_max = @max(bounds.x_max, x); + bounds.y_max = @max(bounds.y_max, y); + } + break :bounds bounds; + }; + + // Degenerate point bounds can't produce filled area and would make the + // point-to-bitmap transform divide by zero, so return a full transparent + // bitmap just like an empty outline. + if (bounds.width() == 0 or bounds.height() == 0) return Bitmap.initEmpty(alloc, width, height); + + // Build the surface we'll draw on. This is a simple alpha8 drawing. + var sfc: z2d.Surface = try .init( + .image_surface_alpha8, + alloc, + @intCast(width), + @intCast(height), + ); + defer sfc.deinit(alloc); + + var path: z2d.Path = .empty; + defer path.deinit(alloc); + + const placement: Placement = .init(bounds, design, opts); + for (0..outline.contours.len) |i| try appendContourPath( + alloc, + &path, + outline.contour(i), + bounds, + placement, + ); + + try z2d.painter.fill( + alloc, + &sfc, + &.{ .opaque_pattern = .{ + .pixel = .{ .alpha8 = .{ .a = 255 } }, + } }, + path.nodes.items, + .{}, + ); + + return .{ + .width = width, + .height = height, + .data = try alloc.dupe(u8, std.mem.sliceAsBytes(sfc.image_surface_alpha8.buf)), + }; +} + +const Bounds = struct { + x_min: f64, + y_min: f64, + x_max: f64, + y_max: f64, + + fn width(self: Bounds) f64 { + return self.x_max - self.x_min; + } + + fn height(self: Bounds) f64 { + return self.y_max - self.y_min; + } +}; + +/// Cell-relative pixel rectangle where the decoded outline bounds should be +/// rasterized within the output bitmap. +/// +/// This is deliberately the placement of the outline's computed point bounds, +/// not the full declared advance/line-height box. `advance_width` and +/// `line_height` describe the design-space layout box the outline was drawn +/// within: they include intentional bearings and whitespace around the visible +/// points. We use that declared box when applying `RenderOptions.Constraint` so +/// sizing and alignment preserve those bearings consistently with other font +/// backends; once that is resolved, we rasterize only the actual outline bounds +/// into this rectangle. +/// +/// ```text +/// output bitmap / terminal cell +/// ╭────────────────────────────────────────────────────────────────────────╮ top +/// │ │ +/// │ declared advance/line-height box │ +/// │ (outer layout box used for constraints) │ +/// │ ╭────────────────────────────────────────────────────────────────╮ │ +/// │ │ │ │ +/// │◀──────── x ────────▶╭────────── width ──────────╮ │ │ +/// │ │ │ Placement │ ▲ height │ │ +/// │ │ │ outline point bounds │ │ │ │ +/// │ │ │ pixels to draw │ │ │ │ +/// │ │ │ │ │ │ │ +/// │ │ ╰───────────────────────────╯ ▼ │ │ +/// │ ╰─────────────────▲──────────────────────────────────────────────╯ │ +/// │ │ y │ +/// ╰────────────────────────────────────────────────────────────────────────╯ bottom +/// x is measured from the bitmap left to the Placement left. +/// y is measured from the bitmap bottom to the Placement bottom. +/// bitmap_height is the full top-to-bottom bitmap height. +/// ``` +/// +/// Constraints are applied to the outer box so the whitespace remains part of +/// alignment decisions. `Placement` is the inner rectangle after that outer box +/// has been constrained. +const Placement = struct { + /// Left edge of the rasterized outline bounds in bitmap pixels, measured + /// from the bitmap's left edge. + x: f64, + + /// 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.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.Glyph.RenderOptions.Constraint. + width: f64, + + /// Height of the rasterized outline bounds in bitmap pixels after applying + /// font.Glyph.RenderOptions.Constraint. + height: f64, + + /// Full bitmap height in pixels, used to convert cell-relative y-up-ish + /// placement into the y-down coordinate system used by z2d surfaces. + bitmap_height: f64, + + /// Calculate where the decoded point bounds should land in the output + /// bitmap. + /// + /// The glyf protocol supplies declared metrics (`units_per_em`, + /// `advance_width`, and `line_height`) in design units, while Ghostty's + /// font constraint code works in cell-relative pixels. We first map the em + /// square to one cell height, matching the linked glyph rasterizer's + /// baseline model where design-space `y=0` is the bottom/baseline of the em + /// and `y=units_per_em` is its top. Then we describe the actual outline + /// bounds as a relative sub-rectangle of the declared advance/line-height + /// box. That declared box includes any intentional side bearings or + /// vertical whitespace around the outline; constraints should apply to that + /// layout box rather than to the tight point bounds alone. This returns the + /// final pixel rectangle for only the outline bounds that we will rasterize. + fn init( + bounds: Bounds, + design: DesignMetrics, + opts: Glyph.RenderOptions, + ) Placement { + // Start with protocol-like design units mapped so that the em square + // occupies one cell. This makes units_per_em the scale reference and + // preserves the linked rasterizer's y=0 baseline/bottom behavior. + // Callers can then use RenderOptions.Constraint to fit/cover/stretch/ + // align the declared advance/line-height box using existing font logic. + const scale = @as(f64, @floatFromInt(opts.grid_metrics.cell_height)) / + @as(f64, @floatFromInt(design.units_per_em)); + + // 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: Glyph.Size = .{ + .width = bounds.width() * scale, + .height = bounds.height() * scale, + .x = bounds.x_min * scale, + .y = bounds.y_min * scale, + }; + + // Convert the declared layout box to pixels. This is the box that + // carries intentional bearings/whitespace and should be constrained. + const group_width = @as(f64, @floatFromInt(design.advance_width)) * scale; + const group_height = @as(f64, @floatFromInt(design.line_height)) * scale; + + // 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: 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 + // declared layout box. Constraint will size/align the outer box + // and then return the corresponding transformed inner box. + constraint.relative_width = glyph.width / group_width; + constraint.relative_height = glyph.height / group_height; + constraint.relative_x = glyph.x / group_width; + constraint.relative_y = glyph.y / group_height; + } + break :constraint constraint; + }; + const constrained = constraint.constrain( + glyph, + opts.grid_metrics, + opts.constraint_width, + ); + + // Store the final outline placement plus the full bitmap height needed + // later to flip from cell-relative y to z2d's y-down surface space. + return .{ + .x = constrained.x, + .y = constrained.y, + .width = constrained.width, + .height = constrained.height, + .bitmap_height = @floatFromInt(opts.grid_metrics.cell_height), + }; + } +}; + +const Point = struct { + x: f64, + y: f64, +}; + +/// Append one contour to a z2d path. +/// +/// Glyf contours are quadratic outlines with explicit on-curve points and +/// off-curve control points. Consecutive off-curve points imply an on-curve +/// point halfway between them, and a contour may begin with an off-curve point. +/// This normalizes those cases while walking the closed contour and emits z2d +/// line/cubic-curve operations in bitmap coordinates. +fn appendContourPath( + alloc: Allocator, + path: *z2d.Path, + contour: []const glyf.Glyf.Outline.Point, + bounds: Bounds, + placement: Placement, +) Error!void { + if (contour.len == 0) return; + + const first = contour[0]; + const last = contour[contour.len - 1]; + + var current: Point = undefined; + var i: usize = 0; + + // Choose the starting on-curve point for this closed contour. If the first + // point is off-curve then the contour logically starts either at the final + // on-curve point, or at the implied midpoint between the final and first + // off-curve points. + if (first.on_curve) { + i = 1; + current = transformPoint( + first, + bounds, + placement, + ); + } else if (last.on_curve) { + current = transformPoint( + last, + bounds, + placement, + ); + } else { + current = midpoint( + transformPoint(last, bounds, placement), + transformPoint(first, bounds, placement), + ); + } + + // Move to the beginning + try path.moveTo(alloc, current.x, current.y); + + // Go through the points and connect em! + while (i < contour.len) { + const p = contour[i]; + + // On-curve points connect to the current point with a straight line. + if (p.on_curve) { + current = transformPoint(p, bounds, placement); + try path.lineTo(alloc, current.x, current.y); + i += 1; + continue; + } + + // Off-curve points are quadratic control points. The following point is + // either the curve endpoint or, if it is also off-curve, contributes an + // implied on-curve endpoint halfway between the two controls. + const control = transformPoint(p, bounds, placement); + const next = contour[(i + 1) % contour.len]; + const end = if (next.on_curve) transformPoint( + next, + bounds, + placement, + ) else midpoint( + control, + transformPoint(next, bounds, placement), + ); + + // z2d paths only expose cubic curves, so convert the TrueType + // quadratic segment to an equivalent cubic segment before appending it. + const c1 = Point{ + .x = current.x + ((2.0 / 3.0) * (control.x - current.x)), + .y = current.y + ((2.0 / 3.0) * (control.y - current.y)), + }; + const c2 = Point{ + .x = end.x + ((2.0 / 3.0) * (control.x - end.x)), + .y = end.y + ((2.0 / 3.0) * (control.y - end.y)), + }; + try path.curveTo( + alloc, + c1.x, + c1.y, + c2.x, + c2.y, + end.x, + end.y, + ); + + current = end; + + // If we consumed an explicit on-curve endpoint then skip it; otherwise + // the next off-curve point still needs to be used as the control point + // for the following quadratic segment. + i += if (next.on_curve) 2 else 1; + } + + try path.close(alloc); +} + +/// Convert a decoded glyf point from design-space coordinates to z2d bitmap +/// coordinates. +/// +/// `bounds` describes the decoded outline's point/control bounds in glyf +/// design units. `placement` describes where those bounds should land in the +/// output bitmap after constraints are applied. Glyf coordinates are y-up; z2d +/// surfaces are y-down, so this also flips the y axis using +/// `placement.bitmap_height`. +fn transformPoint( + p: glyf.Glyf.Outline.Point, + bounds: Bounds, + placement: Placement, +) Point { + const scale_x = placement.width / bounds.width(); + const scale_y = placement.height / bounds.height(); + const x_design: f64 = @floatFromInt(p.x); + const y_design: f64 = @floatFromInt(p.y); + return .{ + .x = placement.x + ((x_design - bounds.x_min) * scale_x), + .y = placement.bitmap_height - placement.y - + ((y_design - bounds.y_min) * scale_y), + }; +} + +/// Return the implied on-curve point between two off-curve TrueType control +/// points. +fn midpoint(a: Point, b: Point) Point { + return .{ + .x = (a.x + b.x) / 2.0, + .y = (a.y + b.y) / 2.0, + }; +} + +fn testMetrics(width: u32, height: u32) @import("Metrics.zig") { + return .{ + .cell_width = width, + .cell_height = height, + .cell_baseline = 0, + .underline_position = height, + .underline_thickness = 1, + .strikethrough_position = height / 2, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_thickness = 1, + .cursor_height = height, + .icon_height = @floatFromInt(height), + .icon_height_single = @floatFromInt(height), + .face_width = @floatFromInt(width), + .face_height = @floatFromInt(height), + .face_y = 0, + }; +} + +test { + _ = @import("glyf_rasterize_png_test.zig"); +} + +test "glyf_rasterize: empty outline returns empty bitmap" { + const testing = std.testing; + const alloc = testing.allocator; + + var bm = try rasterize(alloc, .{ .points = &.{}, .contours = &.{} }, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u32, 20), bm.width); + try testing.expectEqual(@as(u32, 20), bm.height); + try testing.expectEqual(@as(usize, 20 * 20), bm.data.len); + for (bm.data) |v| try testing.expectEqual(@as(u8, 0), v); +} + +test "glyf_rasterize: square fills bitmap center" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expect(bm.data[10 * bm.width + 10] > 200); +} + +test "glyf_rasterize: quadratic contour renders" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 500, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + }, + .contours = &.{2}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: consecutive off-curve points render" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 250, .y = 1000, .on_curve = false }, + .{ .x = 750, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: units per em controls baseline scale" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 2000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + // With a 2000-unit em in a 20px cell, this 1000-unit square occupies the + // bottom half of the cell. This matches the linked rasterizer's y=0 + // baseline/bottom behavior and proves units_per_em is the scale reference. + try testing.expect(bm.data[15 * bm.width + 5] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[5 * bm.width + 5]); +} + +test "glyf_rasterize: degenerate outline returns full empty bitmap" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 500, .y = 0, .on_curve = true }, + }, + .contours = &.{2}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u32, 20), bm.width); + try testing.expectEqual(@as(u32, 20), bm.height); + try testing.expectEqual(@as(usize, 20 * 20), bm.data.len); + for (bm.data) |v| try testing.expectEqual(@as(u8, 0), v); +} + +test "glyf_rasterize: contour can start off curve with final on curve point" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 500, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 0, .y = 0, .on_curve = true }, + }, + .contours = &.{2}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: contour can start with implied midpoint" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 250, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 750, .y = 1000, .on_curve = false }, + .{ .x = 0, .y = 0, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: multiple contours render independently" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 400, .y = 0, .on_curve = true }, + .{ .x = 400, .y = 400, .on_curve = true }, + .{ .x = 0, .y = 400, .on_curve = true }, + .{ .x = 600, .y = 600, .on_curve = true }, + .{ .x = 1000, .y = 600, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 600, .y = 1000, .on_curve = true }, + }, + .contours = &.{ 3, 7 }, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expect(bm.data[16 * bm.width + 4] > 200); + try testing.expect(bm.data[4 * bm.width + 16] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 10]); +} + +test "glyf_rasterize: non-zero bearings preserve declared whitespace" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 250, .y = 0, .on_curve = true }, + .{ .x = 750, .y = 0, .on_curve = true }, + .{ .x = 750, .y = 1000, .on_curve = true }, + .{ .x = 250, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 2]); + try testing.expect(bm.data[10 * bm.width + 10] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 17]); +} + +test "glyf_rasterize: negative y coordinates descend below baseline" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = -250, .on_curve = true }, + .{ .x = 1000, .y = -250, .on_curve = true }, + .{ .x = 1000, .y = 750, .on_curve = true }, + .{ .x = 0, .y = 750, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u8, 0), bm.data[2 * bm.width + 10]); + try testing.expect(bm.data[10 * bm.width + 10] > 200); + try testing.expect(bm.data[18 * bm.width + 10] > 200); +} + +test "glyf_rasterize: two-cell bitmap and constraint render within width" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + .cell_width = 2, + .constraint_width = 2, + .constraint = .{ + .size = .cover, + .align_horizontal = .center, + .align_vertical = .center, + }, + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u32, 40), bm.width); + try testing.expectEqual(@as(u32, 20), bm.height); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 2]); + try testing.expect(bm.data[10 * bm.width + 20] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 37]); +} + +test "glyf_rasterize: line height does not change unconstrained em scale" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 2000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expect(bm.data[10 * bm.width + 10] > 200); + try testing.expect(bm.data[2 * bm.width + 10] > 200); + try testing.expect(bm.data[17 * bm.width + 10] > 200); +} diff --git a/vendor/libghostty-vt/src/font/glyf_rasterize_png_test.zig b/vendor/libghostty-vt/src/font/glyf_rasterize_png_test.zig new file mode 100644 index 00000000..e8ad70fd --- /dev/null +++ b/vendor/libghostty-vt/src/font/glyf_rasterize_png_test.zig @@ -0,0 +1,301 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const wuffs = @import("wuffs"); +const z2d = @import("z2d"); + +const glyf_rasterize = @import("glyf_rasterize.zig"); +const glyf = @import("opentype/glyf.zig"); + +const log = std.log.scoped(.glyf_rasterize); + +const test_glyf_payloads = [_][]const u8{ + // Nerd Font branch, folder, home, heart, and Rust cog outlines from: + // https://github.com/raphamorim/glyph-protocol-examples/blob/main/bubbletea/main.go + "AAIARv8zAhIDnQAZAB0AABcjNTQ3Njc3Njc2NTUjNxcjFRQGBwcGBwYVEQcRM82HJxs3SyoUE2aPjmY0NCUvDxSHh83rVzgoIzAbKSZAoaenvF5kIhkfHSM9AVdXAn8=", + "AAEAAP/UA5wC/AAVAAAXIiY1ETQ2MzMyFxcWMyEyFgcRFgYjcy9ERC/nOCQjER0BITBEAQFEMCxELwJBMEQvLhdEL/4yL0Q=", + "AAEAAP+aBBEDNgA+AAABFAYjIxMUBxUUBisFIiY9AjQmIyMiBh0CFAYrAiIiJwYiIyMiJjc1MjQ1NSMiJjQ3ATYzMhcBFgQOJBY5AQEqHh0GBzsrHioiGHMYIioeLDkBBAMBBAIcHiwBAToYIhIBzg4aFw8BzBcBahgi/t4KBB4eKioeLHQYIiIYdCweKgICKh7KBAJ+IDISAZQODP5qFA==", + "AAEAAP/dA5sC+QAZAAATJjU1NDY3NhYXFzc2NhcWFhUVFAcBBiMiJ1ZWel09eCwWFSx4PV16Vv66FB0eFAEhUHULXpAQCiYsFhYsJgoQkF4LdVD+zxMT", + "AAoAAP/YAyEC+AENARcBWgFlAW8BfQGIAbsBxAHNAAAAMhYXFhYyNjc2MzIXFhcWFxY3NjMyFhcWFxY3NjMyFxcHBxcWNzYXFgcGFxYzMhcWBw4CFAcUFQcUFxYWFRQGFhcWFgcGFBcWFxYGBwYUFxYGBw4CFhYXFgcGBwYVFBcWBwYjIgYXFgYnJgcGFxcHBiInJgYHBiMiJyYHBgcGBiMiJyYiBwYiJyYmBwYGJyYmJyYHBiImJyYmBwYiJjc3JyYHBicmNzYmIwYmNzY2Nzc0JyYmNTQ3NiYnJiY3NjQnJiY0Njc2NjQnJicmJjY3NjYnJjc3Mjc2NScmJyYnJjYXMjY1NCYmJyc3NhcWNzcnJjYzMhcWNjc2NjMyFxY3Njc2NjMyFxYyNzY3FyIHBhYzMjYnJgczBwYHBhUUMzIXFhYHBgcOAhUGFQciFhcWFxYXFhcWFjc2NzY3NjMzNzYvAiYnJjU0NzcnJicmJicnBwYGJyYnBwYGFxYyNzYmJyYFIgcGFBcWNicmBRcWBwYPAgYXFzM1NRcVMzY3NjU0JyYjBxUzMhcWFQcjIhUGFjMyNzYyFxYXFhUWFhcWNzY/AjY2Fxc2NjQjIiYnJicmJyYnJiMGIgcGFjMyNiclIgcGFjc2JyYBjQQICgcECAgIEQMJBgUCAwUGEBMDBgQEAwUDFBIFAwQEAQEEBRIZBQUGBQMCFxUFBwwBAgICARgSChoEFBcEExEUDwQECBATERMEGAoGCAQEBhEHAxUZCAsGAxcYBAUGChoVAgMBAQQEBhQSCgMECgQRFAUEBgcGBAUQEAgLDQwOCwoODQgFBg4CBRUPDAQEBAgTFAYIAQEEBREaBAYGBQQYGQYKAQQCARgSCg0NBBQXBBMQERIEBg4KCAIDCg0ECBEUBA0QBwQFEBcBAQICAgsIGRgCAgIBBAMFGhIFBAEBCAMFEhMIBAQEBgUREQQGCAcEBgQQDwoLCQQFCQcMDBARCg0HPgEPQjEfoJ8NJzACAykCBAQCAQEEAgIDGAgEAwggCg0BAQMCDBABAgIBHRsDBw4PAx8xFkQTCRQSEAkHEvoMDgcHGAgEBAcFAjAHBA0OFBQTBf3wBAsIAh0cAQMKBFOENjcIERsLLzEkJAECAXl5ARgBBBQZEAYEBQYBRCEnKiYSBgYGECAcARg/NhQLDgkLBQkQBimQEAQPChESCA4BVRAGCigLChEFAvgEEQsIBgkREA8FCAIBDA0KERYCAgkIBAQWFwIDBQYFBBkVAwIDBhkDBAQEAQEBAQcEAwQHBSQICAgMEREICwoFBggKDAgQEQwJBAQCCAgHGAYDAwQIAxAXBwMGFBkKBgQCAxYWBAQJCAQVHAwOAwQQEwYREBMVFBMCEA0GAgQmAgQPDAoSFgQJCQgXFgICBAYEBhgVBgEMFgQKAwMGBAQEBgQTEQgJCQwQEAgLCwQMBAkHBAoDAwkLDAgGCAgSFwcEAwQHAgIGBQQXDAEEBQEGCgQTBAcGBAICFxYICAkEFhEKDA0BARcSBBEPEw8ERQcKHiAKBScEESgaBAIDCjghJh4BBAIBAQEBBAECAhUjEQMJAgcIGBMBAQURFgcNDAMHCgYgIQY0IxAcBAETEwgDBBObARgMCwwIFAQEAgIIHAYKKAsDAwkYDQQNDRAjLg9eXgE4AQQHDhQHA4g0AgQqKwICGgUECAkVHAEHFAMDCAcKAx4iCgcGARwCBAwPJjAHDgQCwgMJIiIJAg0UFBIRDgQ=", +}; + +/// Return deterministic font metrics for the PNG reference test. +/// +/// These are intentionally minimal: the rasterizer only needs cell geometry, +/// face geometry, and icon heights for constraint calculations. +fn testMetrics(width: u32, height: u32) @import("Metrics.zig") { + return .{ + .cell_width = width, + .cell_height = height, + .cell_baseline = 0, + .underline_position = height, + .underline_thickness = 1, + .strikethrough_position = height / 2, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_thickness = 1, + .cursor_height = height, + .icon_height = @floatFromInt(height), + .icon_height_single = @floatFromInt(height), + .face_width = @floatFromInt(width), + .face_height = @floatFromInt(height), + .face_y = 0, + }; +} + +/// Decode a base64-encoded glyf protocol payload into an owned outline. +/// +/// The payload is a complete simple-glyph `glyf` table entry. The returned +/// outline owns decoded point and contour storage and must be deinitialized by +/// the caller. +fn decodeGlyfPayload(alloc: Allocator, payload: []const u8) !glyf.Glyf.Outline { + const decoder = std.base64.standard.Decoder; + const size = try decoder.calcSizeForSlice(payload); + const data = try alloc.alloc(u8, size); + defer alloc.free(data); + + try decoder.decode(data, payload); + const entry = try glyf.Glyf.Entry.init(data); + return try entry.decode(alloc); +} + +/// Copy a tightly packed alpha bitmap into the alpha atlas at `dst_x`, `dst_y`. +/// +/// The destination rectangle must fit inside the atlas. This is a test helper, +/// so it trusts the hardcoded atlas layout rather than clipping. +fn blitBitmap(atlas: *z2d.Surface, bm: glyf_rasterize.Bitmap, dst_x: usize, dst_y: usize) void { + const dst_width: usize = @intCast(atlas.getWidth()); + const dst = std.mem.sliceAsBytes(atlas.image_surface_alpha8.buf); + for (0..bm.height) |y| { + const src_start = y * bm.width; + const src_end = src_start + bm.width; + const dst_start = (dst_y + y) * dst_width + dst_x; + @memcpy(dst[dst_start .. dst_start + bm.width], bm.data[src_start..src_end]); + } +} + +/// Draw faint terminal-cell outlines into one row of the alpha atlas. +/// +/// The boxes make cell advance and placement behavior visible in the reference +/// PNG without overpowering the rendered glyph coverage. +fn drawCellBoxes(atlas: *z2d.Surface, y: usize, cell_width: usize, cell_height: usize) void { + const width: usize = @intCast(atlas.getWidth()); + const dst = std.mem.sliceAsBytes(atlas.image_surface_alpha8.buf); + const alpha = 64; + + var x: usize = 0; + while (x < width) : (x += cell_width) { + const right = @min(x + cell_width - 1, width - 1); + const bottom = y + cell_height - 1; + + for (x..right + 1) |px| { + dst[y * width + px] = @max(dst[y * width + px], alpha); + dst[bottom * width + px] = @max(dst[bottom * width + px], alpha); + } + for (y..bottom + 1) |py| { + dst[py * width + x] = @max(dst[py * width + x], alpha); + dst[py * width + right] = @max(dst[py * width + right], alpha); + } + } +} + +/// Compare a generated atlas PNG against the checked-in reference image. +/// +/// On missing reference or mismatch, copy the generated PNG into the workspace +/// as `glyf_rasterize_test.png`. On pixel mismatch, also write +/// `glyf_rasterize_diff.png`, where red is reference-only coverage and green is +/// newly generated coverage. Returns true when a difference was found. +fn diffAtlas( + alloc: Allocator, + atlas: *z2d.Surface, + generated_path: []const u8, +) !bool { + const ref_path = "src/font/testdata/glyf_rasterize.png"; + + const generated_file = try std.fs.openFileAbsolute(generated_path, .{ .mode = .read_only }); + defer generated_file.close(); + const generated_bytes = try generated_file.readToEndAlloc(alloc, std.math.maxInt(usize)); + defer alloc.free(generated_bytes); + + const cwd_absolute = try std.fs.cwd().realpathAlloc(alloc, "."); + defer alloc.free(cwd_absolute); + + const ref_file = std.fs.cwd().openFile(ref_path, .{ .mode = .read_only }) catch |err| { + log.err("Can't open reference file {s}: {}", .{ ref_path, err }); + + const test_path = try std.fmt.allocPrint(alloc, "{s}/glyf_rasterize_test.png", .{cwd_absolute}); + defer alloc.free(test_path); + try std.fs.copyFileAbsolute(generated_path, test_path, .{}); + return true; + }; + defer ref_file.close(); + const ref_bytes = try ref_file.readToEndAlloc(alloc, std.math.maxInt(usize)); + defer alloc.free(ref_bytes); + + if (std.mem.eql(u8, generated_bytes, ref_bytes)) return false; + + const test_path = try std.fmt.allocPrint(alloc, "{s}/glyf_rasterize_test.png", .{cwd_absolute}); + defer alloc.free(test_path); + try std.fs.copyFileAbsolute(generated_path, test_path, .{}); + + const ref_rgba = try wuffs.png.decode(alloc, ref_bytes); + defer alloc.free(ref_rgba.data); + + if (ref_rgba.width != atlas.getWidth() or ref_rgba.height != atlas.getHeight()) { + log.err( + "glyf rasterize visual output dimensions differ from reference: " ++ + "test={s} ({d}x{d}), reference={s} ({d}x{d})", + .{ test_path, atlas.getWidth(), atlas.getHeight(), ref_path, ref_rgba.width, ref_rgba.height }, + ); + return true; + } + + var diff = try z2d.Surface.init( + .image_surface_rgb, + alloc, + atlas.getWidth(), + atlas.getHeight(), + ); + defer diff.deinit(alloc); + + const test_gray = std.mem.sliceAsBytes(atlas.image_surface_alpha8.buf); + const diff_pix = diff.image_surface_rgb.buf; + var differs = false; + for (test_gray, 0..) |t, i| { + const r = ref_rgba.data[i * 4]; + if (t == r) { + diff_pix[i].r = t / 3; + diff_pix[i].g = t / 3; + diff_pix[i].b = t / 3; + } else { + differs = true; + diff_pix[i].r = r; + diff_pix[i].g = t; + } + } + + if (!differs) { + log.err( + "generated glyf rasterize PNG bytes differ from reference but pixels match; " ++ + "test={s}, reference={s}", + .{ test_path, ref_path }, + ); + return true; + } + + const diff_path = "./glyf_rasterize_diff.png"; + try z2d.png_exporter.writeToPNGFile(diff, diff_path, .{}); + log.err( + "glyf rasterize visual output differs from reference: test={s}, reference={s}, diff={s}", + .{ test_path, ref_path, diff_path }, + ); + + return true; +} + +test "glyf_rasterize: bubbletea glyph protocol examples match reference image" { + const testing = std.testing; + const alloc = testing.allocator; + + // The generated PNG is a visual atlas for reading placement behavior. + // Each column below is one terminal cell. The five payloads are rendered in + // order: branch, folder, home, heart, rust. + // + // ```text + // columns: 0 1 2 3 4 5 6 7 8 9 + // ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ + // row 0 │B│F│H│♥│R│ │ │ │ │ │ narrow/default: one-cell bitmap stride + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ + // row 1 │B │F │H │♥ │R │ width=2: same glyphs, two-cell bitmaps + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ + // row 2 │ B│ F│ H│ ♥│ R│ width=2 + horizontal center alignment + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ + // row 3 │B │F │H │♥ │R │ advance_width=2000: wider design box + // └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ + // ``` + // + // The faint grid lines in the PNG are these cell boundaries. Rows 2 and 3 + // are the design-metric/placement regression checks: row 2 centers a + // one-cell-wide design box inside two cells, while row 3 centers a two-cell + // design box so the visible glyph returns to the start of each span. + const cell_width = 20; + const cell_height = 20; + const columns = test_glyf_payloads.len; + const narrow_stride_x = cell_width; + const wide_stride_x = cell_width * 2; + const row_count = 4; + + var atlas = try z2d.Surface.init( + .image_surface_alpha8, + alloc, + @intCast(wide_stride_x * columns), + cell_height * row_count, + ); + defer atlas.deinit(alloc); + + for (test_glyf_payloads, 0..) |payload, i| { + var outline = try decodeGlyfPayload(alloc, payload); + defer outline.deinit(alloc); + + var narrow = try glyf_rasterize.rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(cell_width, cell_height), + }); + defer narrow.deinit(alloc); + blitBitmap(&atlas, narrow, i * narrow_stride_x, 0); + + var wide = try glyf_rasterize.rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(cell_width, cell_height), + .cell_width = 2, + }); + defer wide.deinit(alloc); + blitBitmap(&atlas, wide, i * wide_stride_x, cell_height); + + var centered = try glyf_rasterize.rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(cell_width, cell_height), + .cell_width = 2, + .constraint_width = 2, + .constraint = .{ .align_horizontal = .center }, + }); + defer centered.deinit(alloc); + blitBitmap(&atlas, centered, i * wide_stride_x, cell_height * 2); + + var designed_wide = try glyf_rasterize.rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 2000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(cell_width, cell_height), + .cell_width = 2, + .constraint_width = 2, + .constraint = .{ .align_horizontal = .center }, + }); + defer designed_wide.deinit(alloc); + blitBitmap(&atlas, designed_wide, i * wide_stride_x, cell_height * 3); + } + + for (0..row_count) |row| drawCellBoxes(&atlas, row * cell_height, cell_width, cell_height); + + var dir = testing.tmpDir(.{}); + defer dir.cleanup(); + const tmp_dir = try dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(tmp_dir); + + const generated_path = try std.fmt.allocPrint(alloc, "{s}/glyf_rasterize.png", .{tmp_dir}); + defer alloc.free(generated_path); + try z2d.png_exporter.writeToPNGFile(atlas, generated_path, .{}); + + try testing.expect(!try diffAtlas(alloc, &atlas, generated_path)); +} diff --git a/vendor/libghostty-vt/src/font/main.zig b/vendor/libghostty-vt/src/font/main.zig index a8522afe..0c218bbd 100644 --- a/vendor/libghostty-vt/src/font/main.zig +++ b/vendor/libghostty-vt/src/font/main.zig @@ -15,6 +15,7 @@ pub const Collection = @import("Collection.zig"); pub const DeferredFace = @import("DeferredFace.zig"); pub const Face = face.Face; pub const Glyph = @import("Glyph.zig"); +pub const glyf_rasterize = @import("glyf_rasterize.zig"); pub const Metrics = @import("Metrics.zig"); pub const opentype = @import("opentype.zig"); pub const shape = @import("shape.zig"); diff --git a/vendor/libghostty-vt/src/font/nerd_font_attributes.zig b/vendor/libghostty-vt/src/font/nerd_font_attributes.zig index f4a19d96..fc988496 100644 --- a/vendor/libghostty-vt/src/font/nerd_font_attributes.zig +++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/font/opentype/glyf.zig b/vendor/libghostty-vt/src/font/opentype/glyf.zig index 7392729a..912b5fe8 100644 --- a/vendor/libghostty-vt/src/font/opentype/glyf.zig +++ b/vendor/libghostty-vt/src/font/opentype/glyf.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; const sfnt = @import("sfnt.zig"); /// Glyph Data Table @@ -15,6 +16,49 @@ const sfnt = @import("sfnt.zig"); pub const Glyf = struct { data: []const u8, + /// A decoded glyph outline. + /// + /// The `contours` slice is the list of end point indices and + /// `points` owns all the points. Glyf guarantees that contour + /// points are sequential so we can just store the end and calculate + /// the points that way. Use the helpers to make it ergonomic. + pub const Outline = struct { + /// List of contour end points. Calculate the full list of + /// points using points[prev...this+1] + contours: []const sfnt.uint16, + + /// The backing storage of all points in the entry. + points: []const Point, + + /// A single decoded point in a simple glyph contour. + pub const Point = struct { + x: i32, + y: i32, + on_curve: bool, + }; + + /// Return the point slice for the contour at `index`. + /// + /// The returned slice references `points` and is invalidated when + /// this outline is deinitialized. + pub fn contour(self: Outline, index: usize) []const Point { + const start = if (index == 0) + 0 + else + @as(usize, self.contours[index - 1]) + 1; + const end = @as(usize, self.contours[index]) + 1; + return self.points[start..end]; + } + + /// Free all memory owned by this outline. Pass in the same + /// allocator used for decoding. + pub fn deinit(self: *Outline, alloc: Allocator) void { + alloc.free(self.contours); + alloc.free(self.points); + self.* = undefined; + } + }; + /// https://learn.microsoft.com/en-us/typography/opentype/spec/glyf#table-organization pub const Entry = struct { header: Header, @@ -241,6 +285,12 @@ pub const Glyf = struct { TooManyPoints, }; + /// Errors that can be returned from `Entry.decode()`. + pub const DecodeError = SizeError || Allocator.Error || error{ + /// Coordinate delta accumulation overflowed. + CoordinateOverflow, + }; + /// Determines the size (in bytes) of this entry. /// /// If the entry is valid, returns the number of bytes @@ -393,6 +443,149 @@ pub const Glyf = struct { // No issues found, the glyf entry is valid, return its length. return @sizeOf(Header) + fbs.pos; } + + /// Decode this simple glyph entry into an owned outline. + /// + /// NOTE: Currently produces errors when given composite glyphs + /// or any glyphs that have hinting instructions included. + pub fn decode(self: Entry, alloc: Allocator) DecodeError!Glyf.Outline { + // We only support simple glyphs. + switch (self.entryType()) { + .simple => {}, + .composite => return error.CompositeNotSupported, + } + + var fbs = std.io.fixedBufferStream(self.data); + const reader = fbs.reader(); + + // A zero-contour glyph may be header-only. See size for the + // reason for the hardcoded 2 here. + const num_contours: usize = @intCast(self.header.numberOfContours); + if (num_contours == 0 and self.data.len < 2) return .{ + .points = &.{}, + .contours = &.{}, + }; + + // We now know our full amount of contour ending points. + const end_points = try alloc.alloc(sfnt.uint16, num_contours); + errdefer alloc.free(end_points); + + // If we have no contours, then the only possible remaining + // field is instructionLength. Instructions are not supported. + if (num_contours == 0) { + const instructions_length = try reader.readInt(sfnt.uint16, .big); + if (instructions_length > 0) return error.InstructionsNotSupported; + return .{ .points = &.{}, .contours = end_points }; + } + + // The number of points is determined by the final end point + // entry since the entries have to be monotonic (something + // we verify below). + const point_count: usize = point_count: { + var prev_end_point: isize = -1; + + // Go through the end points array and update our end_points + // with the valid index. The final endpoint tells us our point + // count, since endpoints are stored as inclusive point indices. + for (0..end_points.len) |i| { + const index = try reader.readInt(sfnt.uint16, .big); + if (index <= prev_end_point) return error.EndPointsOutOfOrder; + prev_end_point = index; + end_points[i] = index; + } + + // The final point tells us our point count. + break :point_count @as(usize, end_points[end_points.len - 1]) + 1; + }; + + // Instructions are not supported. + const instructions_length = try reader.readInt(sfnt.uint16, .big); + if (instructions_length > 0) return error.InstructionsNotSupported; + + // Allocate our points right away even though the next entries + // are flags. We want to do this so that if the allocator is + // a bump allocator, the flags free will actually free it. + const points = try alloc.alloc(Glyf.Outline.Point, point_count); + errdefer alloc.free(points); + + // This is EXTREMELY annoying but all the flags are separate + // from the points so we have to do some allocation here since + // its a dynamic amount and we need to save the values for later. + // + // Typical glyphs have small point counts, so use stack storage + // first while still falling back to the caller's allocator for + // unusually large outlines. + var flags_stack = std.heap.stackFallback(4096, alloc); + const flags_alloc = flags_stack.get(); + const flags = try flags_alloc.alloc(SimpleFlags, point_count); + defer flags_alloc.free(flags); + { + var point_i: usize = 0; + while (point_i < point_count) { + const flag: SimpleFlags = @bitCast(try reader.readByte()); + flags[point_i] = flag; + point_i += 1; + + if (flag.repeat) { + const repeat_count: usize = try reader.readByte(); + if (point_i + repeat_count > point_count) return error.TooManyPoints; + + for (0..repeat_count) |_| { + flags[point_i] = flag; + point_i += 1; + } + } + } + } + + // Go through x coordinate deltas + var x: i32 = 0; + for (flags, points) |flag, *point| { + const dx: i32 = if (flag.x_short) short: { + break :short if (flag.x_repeat_or_sign) + @as(i32, try reader.readByte()) + else + -@as(i32, try reader.readByte()); + } else if (!flag.x_repeat_or_sign) + @as(i32, try reader.readInt(sfnt.int16, .big)) + else + 0; + + x = std.math.add( + i32, + x, + dx, + ) catch return error.CoordinateOverflow; + point.x = x; + } + + // Go through y coordinate deltas + var y: i32 = 0; + for (flags, points) |flag, *point| { + const dy: i32 = if (flag.y_short) short: { + break :short if (flag.y_repeat_or_sign) + @as(i32, try reader.readByte()) + else + -@as(i32, try reader.readByte()); + } else if (!flag.y_repeat_or_sign) + @as(i32, try reader.readInt(sfnt.int16, .big)) + else + 0; + + y = std.math.add( + i32, + y, + dy, + ) catch return error.CoordinateOverflow; + point.y = y; + point.on_curve = flag.on_curve; + } + + return .{ + .points = points, + .contours = end_points, + }; + } }; /// Initialize the table from the provided data. @@ -451,7 +644,37 @@ pub fn getGlyph(font: sfnt.SFNT, index: usize) !struct { usize, Glyf.Entry } { return .{ end_offset - start_offset, try glyf.entry(start_offset) }; } +fn testAppendInt( + buf: *std.ArrayList(u8), + alloc: Allocator, + comptime T: type, + value: T, +) !void { + var bytes: [@sizeOf(T)]u8 = undefined; + std.mem.writeInt(T, &bytes, value, .big); + try buf.appendSlice(alloc, &bytes); +} + +fn testAppendHeader( + buf: *std.ArrayList(u8), + alloc: Allocator, + number_of_contours: i16, + x_min: i16, + y_min: i16, + x_max: i16, + y_max: i16, +) !void { + try testAppendInt(buf, alloc, i16, number_of_contours); + try testAppendInt(buf, alloc, i16, x_min); + try testAppendInt(buf, alloc, i16, y_min); + try testAppendInt(buf, alloc, i16, x_max); + try testAppendInt(buf, alloc, i16, y_max); +} + 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. @@ -475,13 +698,188 @@ test "glyf" { try testing.expect(glyph_A.entryType() == .simple); try testing.expect(len_A >= try glyph_A.size()); + var outline_A = try glyph_A.decode(alloc); + defer outline_A.deinit(alloc); + try testing.expectEqual(@as(usize, @intCast(glyph_A.header.numberOfContours)), outline_A.contours.len); + try testing.expect(outline_A.points.len > 0); + // Glyph "Ĩ" is at index 265. const len_Itilde, const glyph_Itilde = try getGlyph(font, 265); try testing.expect(glyph_Itilde.entryType() == .simple); try testing.expect(len_Itilde >= try glyph_Itilde.size()); } +test "glyf: decode triangle" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 100, 100, 900, 900); + try testAppendInt(&buf, alloc, u16, 2); // endPtsOfContours[0] + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + try buf.append(alloc, 0x01); // on curve + try buf.append(alloc, 0x01); // on curve + try buf.append(alloc, 0x01); // on curve + try testAppendInt(&buf, alloc, i16, 500); + try testAppendInt(&buf, alloc, i16, -400); + try testAppendInt(&buf, alloc, i16, 800); + try testAppendInt(&buf, alloc, i16, 900); + try testAppendInt(&buf, alloc, i16, -800); + try testAppendInt(&buf, alloc, i16, 0); + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(i16, 100), glyph.header.xMin); + try testing.expectEqual(@as(i16, 900), glyph.header.xMax); + try testing.expectEqual(@as(usize, 1), outline.contours.len); + try testing.expectEqual(@as(usize, 3), outline.points.len); + const contour = outline.contour(0); + try testing.expectEqual(@as(usize, 3), contour.len); + try testing.expectEqual(Glyf.Outline.Point{ .x = 500, .y = 900, .on_curve = true }, contour[0]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 100, .y = 100, .on_curve = true }, contour[1]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 900, .y = 100, .on_curve = true }, contour[2]); +} + +test "glyf: decode multiple contours" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 2, 0, 0, 30, 10); + try testAppendInt(&buf, alloc, u16, 1); // first contour ends at point 1 + try testAppendInt(&buf, alloc, u16, 3); // second contour ends at point 3 + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + for (0..4) |_| try buf.append(alloc, 0x01); // on curve + for ([_]i16{ 0, 10, 10, 10 }) |dx| try testAppendInt(&buf, alloc, i16, dx); + for ([_]i16{ 0, 0, 10, 0 }) |dy| try testAppendInt(&buf, alloc, i16, dy); + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(usize, 2), outline.contours.len); + try testing.expectEqual(@as(usize, 4), outline.points.len); + try testing.expectEqual(@as(u16, 1), outline.contours[0]); + try testing.expectEqual(@as(u16, 3), outline.contours[1]); + try testing.expectEqual(@as(usize, 2), outline.contour(0).len); + try testing.expectEqual(@as(usize, 2), outline.contour(1).len); + try testing.expectEqual(outline.points[0..2].ptr, outline.contour(0).ptr); + try testing.expectEqual(outline.points[2..4].ptr, outline.contour(1).ptr); +} + +test "glyf: decode repeat and short vector flags" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 0, -16, 16, 0); + try testAppendInt(&buf, alloc, u16, 3); // four points + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + try buf.append(alloc, 0x01 | 0x02 | 0x04 | 0x08 | 0x10); // on, x short positive, y short negative, repeat + try buf.append(alloc, 3); // repeat for the next three points + for ([_]u8{ 1, 2, 4, 8 }) |dx| try buf.append(alloc, dx); + for ([_]u8{ 1, 2, 4, 8 }) |dy| try buf.append(alloc, dy); + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(usize, 4), outline.points.len); + try testing.expectEqual(Glyf.Outline.Point{ .x = 1, .y = -1, .on_curve = true }, outline.points[0]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 3, .y = -3, .on_curve = true }, outline.points[1]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 7, .y = -7, .on_curve = true }, outline.points[2]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 15, .y = -15, .on_curve = true }, outline.points[3]); +} + +test "glyf: decode off curve and same coordinate flags" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 0, 0, 7, 9); + try testAppendInt(&buf, alloc, u16, 1); // two points + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + try buf.append(alloc, 0x10 | 0x20); // off curve, x same, y same + try buf.append(alloc, 0x01 | 0x02 | 0x04 | 0x10 | 0x20); // on curve, short positive x/y + try buf.append(alloc, 7); // x delta + try buf.append(alloc, 9); // y delta + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(Glyf.Outline.Point{ .x = 0, .y = 0, .on_curve = false }, outline.points[0]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 7, .y = 9, .on_curve = true }, outline.points[1]); +} + +test "glyf: decode one-point contour" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 0, 0, 0, 0); + try testAppendInt(&buf, alloc, u16, 0); // endPtsOfContours[0] + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + try buf.append(alloc, 0x01 | 0x10 | 0x20); // on curve, x same, y same + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(usize, 1), outline.points.len); + try testing.expectEqual(@as(usize, 1), outline.contours.len); + try testing.expectEqual(@as(u16, 0), outline.contours[0]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 0, .y = 0, .on_curve = true }, outline.contour(0)[0]); +} + +test "glyf: decode contour ending at max point index" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 0, 0, 0, 0); + try testAppendInt(&buf, alloc, u16, std.math.maxInt(u16)); // 65536 points + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + + const flag = 0x01 | 0x10 | 0x20; // on curve, x same, y same + var remaining: usize = @as(usize, std.math.maxInt(u16)) + 1; + while (remaining > 0) { + const run = @min(remaining, 256); + if (run == 1) { + try buf.append(alloc, flag); + } else { + try buf.append(alloc, flag | 0x08); // repeat + try buf.append(alloc, @intCast(run - 1)); + } + remaining -= run; + } + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(usize, 65536), outline.points.len); + try testing.expectEqual(@as(usize, 65536), outline.contour(0).len); +} + 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; @@ -496,6 +894,10 @@ test "glyf: reject glyphs with instructions and composite glyphs" { Glyf.Entry.SizeError.InstructionsNotSupported, glyph_notdef.size(), ); + try testing.expectError( + Glyf.Entry.DecodeError.InstructionsNotSupported, + glyph_notdef.decode(alloc), + ); // Glyph "Á" is at index 2. const len_Aacute, const glyph_Aacute = try getGlyph(font, 2); @@ -505,9 +907,16 @@ test "glyf: reject glyphs with instructions and composite glyphs" { Glyf.Entry.SizeError.CompositeNotSupported, glyph_Aacute.size(), ); + try testing.expectError( + Glyf.Entry.DecodeError.CompositeNotSupported, + glyph_Aacute.decode(alloc), + ); } 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. @@ -522,9 +931,13 @@ test "glyf: reject truncated" { // it before the full length (which is 228 bytes). glyph_nul.data = glyph_nul.data[0 .. 227 - @sizeOf(Glyf.Entry.Header)]; try testing.expectError(Glyf.Entry.SizeError.EndOfStream, glyph_nul.size()); + try testing.expectError(Glyf.Entry.DecodeError.EndOfStream, glyph_nul.decode(alloc)); } 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. @@ -544,9 +957,16 @@ test "glyf: reject endpoints out of order" { // copied, we can just const cast it back to mutable lol. std.mem.bytesAsSlice(u16, @as([]u8, @constCast(glyph_nul.data)))[3] = 0; try testing.expectError(Glyf.Entry.SizeError.EndPointsOutOfOrder, glyph_nul.size()); + try testing.expectError( + Glyf.Entry.DecodeError.EndPointsOutOfOrder, + glyph_nul.decode(alloc), + ); } 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. @@ -568,10 +988,12 @@ test "glyf: reject too many points" { @as([]u8, @constCast(glyph_nul.data))[107] |= 0x08; @as([]u8, @constCast(glyph_nul.data))[108] = 0xFF; try testing.expectError(Glyf.Entry.SizeError.TooManyPoints, glyph_nul.size()); + try testing.expectError(Glyf.Entry.DecodeError.TooManyPoints, glyph_nul.decode(alloc)); } test "glyf: zero-contour glyph can be header-only" { const testing = std.testing; + const alloc = testing.allocator; const header: Glyf.Entry.Header = .{ .numberOfContours = 0, @@ -582,4 +1004,43 @@ test "glyf: zero-contour glyph can be header-only" { }; const glyph = try Glyf.Entry.init(std.mem.asBytes(&header)); try testing.expectEqual(@sizeOf(Glyf.Entry.Header), try glyph.size()); + + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + try testing.expectEqual(@as(usize, 0), outline.points.len); + try testing.expectEqual(@as(usize, 0), outline.contours.len); +} + +test "glyf: zero-contour glyph can include instruction length" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 0, 0, 0, 0, 0); + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + + const glyph = try Glyf.Entry.init(buf.items); + try testing.expectEqual(@sizeOf(Glyf.Entry.Header) + 2, try glyph.size()); + + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + try testing.expectEqual(@as(usize, 0), outline.points.len); + try testing.expectEqual(@as(usize, 0), outline.contours.len); +} + +test "glyf: zero-contour glyph rejects instructions" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 0, 0, 0, 0, 0); + try testAppendInt(&buf, alloc, u16, 1); // instructionLength + + const glyph = try Glyf.Entry.init(buf.items); + try testing.expectError(Glyf.Entry.SizeError.InstructionsNotSupported, glyph.size()); + try testing.expectError(Glyf.Entry.DecodeError.InstructionsNotSupported, glyph.decode(alloc)); } diff --git a/vendor/libghostty-vt/src/font/opentype/sfnt.zig b/vendor/libghostty-vt/src/font/opentype/sfnt.zig index 9373cda0..55a9a0bd 100644 --- a/vendor/libghostty-vt/src/font/opentype/sfnt.zig +++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/font/shaper/coretext.zig b/vendor/libghostty-vt/src/font/shaper/coretext.zig index 3f69af6d..8a9374bd 100644 --- a/vendor/libghostty-vt/src/font/shaper/coretext.zig +++ b/vendor/libghostty-vt/src/font/shaper/coretext.zig @@ -1333,7 +1333,7 @@ test "shape emoji width long" { var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); defer t.deinit(alloc); - var page = t.screens.active.pages.pages.first.?.data; + var page = t.screens.active.pages.pages.first.?.page(); var row = page.getRow(1); const cell = &row.cells.ptr(page.memory)[0]; cell.* = .{ diff --git a/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig b/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig index a400ecaf..996aabff 100644 --- a/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig +++ b/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig @@ -811,7 +811,7 @@ test "shape emoji width long" { ); defer t.deinit(alloc); - var page = t.screens.active.pages.pages.first.?.data; + var page = t.screens.active.pages.pages.first.?.page(); var row = page.getRow(1); const cell = &row.cells.ptr(page.memory)[0]; cell.* = .{ diff --git a/vendor/libghostty-vt/src/font/sprite/Face.zig b/vendor/libghostty-vt/src/font/sprite/Face.zig index 596a9204..fd029d13 100644 --- a/vendor/libghostty-vt/src/font/sprite/Face.zig +++ b/vendor/libghostty-vt/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)) { @@ -205,7 +205,16 @@ pub fn renderGlyph( else => |width| metrics.cell_width * width, }; - const height = metrics.cell_height; + // Sprite glyphs generally get the full cell height, but cursor glyphs need + // to be affected by `adjust-cursor-height`, so we use `cursor_height` for + // the height if it's one of the full-height cursors. + const height = switch (cp) { + @intFromEnum(Sprite.cursor_rect), + @intFromEnum(Sprite.cursor_hollow_rect), + @intFromEnum(Sprite.cursor_bar), + => metrics.cursor_height, + else => metrics.cell_height, + }; const padding_x = width / 4; const padding_y = height / 4; @@ -219,11 +228,32 @@ pub fn renderGlyph( // Write the drawing to the atlas const region = try canvas.writeAtlas(alloc, atlas); + // The X offset is the displacement from the left edge of the cell that our + // drawn sprite will be drawn in the grid. That's the same as the distance + // we have calculated from the left of the canvas, minus the padding, since + // the padding represents extra pixels to the left of the cell. + const offset_x: i32 = + @as(i32, @intCast(canvas.clip_left)) - + @as(i32, @intCast(padding_x)); + // Similar logic applies for the Y offset, but with the additional factor + // that we want to re-center cursor glyphs in the cell if they were drawn + // taller or shorter than a normal cell is; this only applies to cursors + // currently, but could conceivably apply to other things in the future. + const offset_y: i32 = + @as(i32, @intCast(region.height +| canvas.clip_bottom)) - + @as(i32, @intCast(padding_y)) + + @divTrunc( + // By adding half the difference between the cell height and the + // height we passed to the draw function, we center it in the cell. + @as(i32, @intCast(metrics.cell_height)) - @as(i32, @intCast(height)), + 2, + ); + return .{ .width = region.width, .height = region.height, - .offset_x = @as(i32, @intCast(canvas.clip_left)) - @as(i32, @intCast(padding_x)), - .offset_y = @as(i32, @intCast(region.height +| canvas.clip_bottom)) - @as(i32, @intCast(padding_y)), + .offset_x = offset_x, + .offset_y = offset_y, .atlas_x = region.x, .atlas_y = region.y, }; @@ -535,6 +565,92 @@ test "sprite face render all sprites" { try std.testing.expect(!diff); // There should be no diffs from reference. } +test "full height cursor sprites respect cursor height metric" { + const testing = std.testing; + const alloc = testing.allocator; + + var atlas: font.Atlas = try .init(alloc, 128, .grayscale); + defer atlas.deinit(alloc); + + // face with a 8x16 cell dimension. + var face: Face = .{ + .metrics = .calc(.{ + // Fudged number, not used in anything we care about here. + .px_per_em = 16, + + .cell_width = 8.0, + .ascent = 12.0, + .descent = -4.0, + .line_gap = 0.0, + }), + }; + + try testing.expectEqual(16, face.metrics.cell_height); + + // --- smaller than cell height --- + face.metrics.cursor_height = 12; + // bar + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_bar), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(12, glyph.height); + try testing.expectEqual(14, glyph.offset_y); + } + // rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(12, glyph.height); + try testing.expectEqual(14, glyph.offset_y); + } + // hollow rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_hollow_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(12, glyph.height); + try testing.expectEqual(14, glyph.offset_y); + } + + // --- equal to the cell height --- + face.metrics.cursor_height = 16; + // bar + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_bar), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(16, glyph.height); + try testing.expectEqual(16, glyph.offset_y); + } + // rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(16, glyph.height); + try testing.expectEqual(16, glyph.offset_y); + } + // hollow rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_hollow_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(16, glyph.height); + try testing.expectEqual(16, glyph.offset_y); + } + + // --- greater than the cell height --- + face.metrics.cursor_height = 20; + // bar + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_bar), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(20, glyph.height); + try testing.expectEqual(18, glyph.offset_y); + } + // rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(20, glyph.height); + try testing.expectEqual(18, glyph.offset_y); + } + // hollow rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_hollow_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(20, glyph.height); + try testing.expectEqual(18, glyph.offset_y); + } +} + // test "sprite face print all sprites" { // std.debug.print("\n\n", .{}); // inline for (ranges) |range| { diff --git a/vendor/libghostty-vt/src/font/testdata/glyf_rasterize.png b/vendor/libghostty-vt/src/font/testdata/glyf_rasterize.png Binary files differnew file mode 100644 index 00000000..f5f8fb81 --- /dev/null +++ b/vendor/libghostty-vt/src/font/testdata/glyf_rasterize.png |
