diff --git a/src/font/glyf_rasterize.zig b/src/font/glyf_rasterize.zig index f321214bc..579b24e79 100644 --- a/src/font/glyf_rasterize.zig +++ b/src/font/glyf_rasterize.zig @@ -18,15 +18,33 @@ const DesignMetrics = Glyph.DesignMetrics; pub const Bitmap = struct { width: u32, height: u32, + + /// Horizontal bearing, in pixels, from the nominal bitmap origin to the + /// returned bitmap origin. This is usually zero, but can be negative when + /// `Placement.x` is negative and the returned bitmap grows leftward to keep + /// the placed outline from being clipped. The renderer passes this through + /// as the glyph's `offset_x`. + offset_x: i32, + data: []u8, // An empty 0x0 bitmap. - pub const empty: Bitmap = .{ .width = 0, .height = 0, .data = "" }; + pub const empty: Bitmap = .{ + .width = 0, + .height = 0, + .offset_x = 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 }; + return .{ + .width = width, + .height = height, + .offset_x = 0, + .data = data, + }; } pub fn deinit(self: *Bitmap, alloc: Allocator) void { @@ -37,12 +55,14 @@ pub const Bitmap = struct { pub const Error = Allocator.Error || z2d.Path.Error || z2d.painter.FillError; -/// Rasterize a decoded glyf outline to a full-cell alpha bitmap. +/// Rasterize a decoded glyf outline to an 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 nominal bitmap rectangle starts as +/// `grid_metrics.cell_width * cell_width` by `grid_metrics.cell_height`, but the +/// returned bitmap may be wider if `Placement` puts the outline outside that +/// rectangle. `Bitmap.offset_x` places the returned bitmap relative to the +/// nominal bitmap origin. `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( @@ -55,20 +75,23 @@ pub fn rasterize( assert(design.advance_width > 0); assert(design.line_height > 0); - // Calculate our final width/height. - const width: u32 = std.math.mul( + // Calculate the nominal bitmap rectangle. Placement is resolved relative to + // this rectangle, but this is not necessarily the final bitmap size: if the + // resulting Placement extends horizontally outside this rectangle, we grow + // the returned bitmap and preserve that overflow with Bitmap.offset_x. + const nominal_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); + const nominal_height = opts.grid_metrics.cell_height; + assert(nominal_width > 0 and nominal_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); + if (outline.contours.len == 0 or outline.points.len == 0) return Bitmap.initEmpty(alloc, nominal_width, nominal_height); // Glyf entries have a header bounding box, but this rasterizer operates on // the decoded Outline only. Recompute bounds from the decoded coordinate @@ -97,21 +120,52 @@ pub fn rasterize( // 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); + if (bounds.width() == 0 or bounds.height() == 0) return Bitmap.initEmpty(alloc, nominal_width, nominal_height); + + // `placement` is in nominal bitmap coordinates: x=0 is the nominal left + // edge and x=nominal_width is the nominal right edge. The z2d surface we draw + // into, however, has its own bitmap coordinate system whose left edge is + // always x=0. If the placed outline extends outside the nominal rectangle, + // grow the returned bitmap just enough to include both: + // + // * the whole nominal rectangle, so empty/transparent in-cell pixels are + // still represented; and + // * the whole placed outline bounds, so Placement overflow is not clipped + // away before the renderer sees it. + // + // When overflow extends to the left, `left` is negative. We shift + // `placement.x` right by `-left` before drawing into the bitmap, then return + // `offset_x = left` so the renderer shifts the bitmap back to its intended + // position relative to the nominal origin. Example: a 20px-wide Placement + // centered in a 10px nominal rectangle has left=-5, bitmap_width=20, + // placement.x=0 in bitmap coordinates, and offset_x=-5 at render time. + var placement: Placement = .init(bounds, design, opts); + const left = @min(@as(f64, 0), @floor(placement.x)); + const right = @max( + @as(f64, @floatFromInt(nominal_width)), + @ceil(placement.x + placement.width), + ); + assert(right > left); + + const bitmap_width: u32 = @intFromFloat(@min( + @as(f64, @floatFromInt(std.math.maxInt(u32))), + right - left, + )); + const offset_x: i32 = @intFromFloat(left); + placement.x -= left; // 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), + @intCast(bitmap_width), + @intCast(nominal_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, @@ -131,8 +185,9 @@ pub fn rasterize( ); return .{ - .width = width, - .height = height, + .width = bitmap_width, + .height = nominal_height, + .offset_x = offset_x, .data = try alloc.dupe(u8, std.mem.sliceAsBytes(sfc.image_surface_alpha8.buf)), }; } @@ -152,8 +207,7 @@ const Bounds = struct { } }; -/// Cell-relative pixel rectangle where the decoded outline bounds should be -/// rasterized within the output bitmap. +/// Pixel rectangle where the decoded outline bounds should be rasterized. /// /// This is deliberately the placement of the outline's computed point bounds, /// not the full declared advance/line-height box. `advance_width` and @@ -165,7 +219,7 @@ const Bounds = struct { /// into this rectangle. /// /// ```text -/// output bitmap / terminal cell +/// nominal bitmap rectangle /// ╭────────────────────────────────────────────────────────────────────────╮ top /// │ │ /// │ declared advance/line-height box │ @@ -181,7 +235,10 @@ const Bounds = struct { /// │ ╰─────────────────▲──────────────────────────────────────────────╯ │ /// │ │ y │ /// ╰────────────────────────────────────────────────────────────────────────╯ bottom -/// x is measured from the bitmap left to the Placement left. +/// x is measured from the nominal rectangle's left edge to the +/// Placement left edge. It may be negative when constraints place +/// the outline before the nominal origin; rasterize grows the +/// returned bitmap and reports that as Bitmap.offset_x. /// y is measured from the bitmap bottom to the Placement bottom. /// bitmap_height is the full top-to-bottom bitmap height. /// ``` @@ -213,29 +270,29 @@ const Placement = struct { bitmap_height: f64, /// Calculate where the decoded point bounds should land in the output - /// bitmap. + /// bitmap coordinate space. /// - /// 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. + /// `design` 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 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. + // Start with design units mapped so that the em square occupies one + // cell height. This makes units_per_em the scale reference and + // preserves the 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)); @@ -270,12 +327,57 @@ const Placement = struct { } break :constraint constraint; }; - const constrained = constraint.constrain( + var constrained = constraint.constrain( glyph, opts.grid_metrics, opts.constraint_width, ); + // `RenderOptions.Constraint` is shared with normal font rendering and + // intentionally clamps oversized glyphs so they do not protrude before + // the cell origin. Placement supports a looser invariant: `center` + // aligns midpoints and `end` aligns trailing edges, even when the + // scaled layout box is wider than the nominal bitmap rectangle. In + // those cases overflow before x=0 is a valid Placement value and must + // be preserved by the wider bitmap/negative-bearing logic above. + // + // When constraint sizing is `.none`, the base transform has already + // chosen the scale. Re-apply only the alignment/padding part here in + // nominal bitmap coordinates, without the font helper's clamping. + if (constraint.size == .none) { + const nominal_width = @as(f64, @floatFromInt(opts.grid_metrics.cell_width)) * + @as(f64, @floatFromInt(opts.constraint_width)); + const nominal_height: f64 = @floatFromInt(opts.grid_metrics.cell_height); + + const group: Glyph.Size = .{ + .width = group_width, + .height = group_height, + .x = glyph.x - (group_width * constraint.relative_x), + .y = glyph.y - (group_height * constraint.relative_y), + }; + + const start_x = constraint.pad_left * nominal_width; + const end_x = nominal_width * (1 - constraint.pad_right) - group.width; + const aligned_group_x = switch (constraint.align_horizontal) { + .none => group.x, + .start, .center1 => start_x, + .center => (start_x + end_x) / 2, + .end => end_x, + }; + + const start_y = constraint.pad_bottom * nominal_height; + const end_y = nominal_height * (1 - constraint.pad_top) - group.height; + const aligned_group_y = switch (constraint.align_vertical) { + .none => group.y, + .start => start_y, + .center, .center1 => (start_y + end_y) / 2, + .end => end_y, + }; + + constrained.x = aligned_group_x + (group.width * constraint.relative_x); + constrained.y = aligned_group_y + (group.height * constraint.relative_y); + } + // 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 .{ @@ -503,6 +605,44 @@ test "glyf_rasterize: square fills bitmap center" { try testing.expect(bm.data[10 * bm.width + 10] > 200); } +test "glyf_rasterize: centered Placement preserves horizontal overflow" { + 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(10, 20), + .constraint = .{ + .align_horizontal = .center, + .align_vertical = .center, + }, + }); + defer bm.deinit(alloc); + + // The nominal bitmap rectangle is 10x20. The 1000x1000 design box maps to + // a 20x20 Placement, and center alignment places that Placement at x=-5. + // The returned bitmap keeps the full 20px outline instead of clipping to + // the nominal rectangle, and reports the left overflow as offset_x. + try testing.expectEqual(@as(u32, 20), bm.width); + try testing.expectEqual(@as(u32, 20), bm.height); + try testing.expectEqual(@as(i32, -5), bm.offset_x); + try testing.expect(bm.data[10 * bm.width + 1] > 200); + try testing.expect(bm.data[10 * bm.width + 18] > 200); +} + test "glyf_rasterize: quadratic contour renders" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/renderer/glyph_protocol.zig b/src/renderer/glyph_protocol.zig index 7b2d55561..65a606611 100644 --- a/src/renderer/glyph_protocol.zig +++ b/src/renderer/glyph_protocol.zig @@ -171,13 +171,16 @@ pub const State = struct { }; grid.atlas_grayscale.set(region, bitmap.data); - // A Glyph Protocol glyf bitmap is rasterized as a full render-span - // bitmap. A top bearing equal to bitmap height places the top of the - // quad at the top of the cell in the existing text shader convention. + // The glyf rasterizer returns an atlas-ready bitmap plus bearings in + // the same coordinate convention as normal font glyphs. `offset_x` may + // be negative when the protocol sizing rules allow horizontal overflow + // before the cell origin. A top bearing equal to bitmap height places + // the top of the quad at the top of the cell in the existing text + // shader convention. codepoint.rasterized = .{ .width = bitmap.width, .height = bitmap.height, - .offset_x = 0, + .offset_x = bitmap.offset_x, .offset_y = @intCast(bitmap.height), .atlas_x = region.x, .atlas_y = region.y,