From d8f56b790e2cce1dd42908a94655d9242a813892 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Jun 2026 13:34:00 -0700 Subject: [PATCH 1/3] font: add glyf entry decoder to outline Add Glyf.Outline for decoding the contours and points of a Glyf. --- src/font/opentype/glyf.zig | 446 +++++++++++++++++++++++++++++++++++++ 1 file changed, 446 insertions(+) diff --git a/src/font/opentype/glyf.zig b/src/font/opentype/glyf.zig index 7392729a6..fb9621221 100644 --- a/src/font/opentype/glyf.zig +++ b/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 `countours` 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: []sfnt.uint16, + + /// The backing storage of all points in the entry. + points: []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) []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,6 +644,33 @@ 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" { const testing = std.testing; const alloc = testing.allocator; @@ -475,12 +695,184 @@ 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" { const testing = std.testing; const alloc = testing.allocator; @@ -496,6 +888,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,6 +901,10 @@ 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" { @@ -522,6 +922,7 @@ 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" { @@ -544,6 +945,10 @@ 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" { @@ -568,10 +973,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 +989,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)); } From 8eff74ef7680f40bbcc03d634ad1ad11417c29f3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Jun 2026 19:44:10 -0700 Subject: [PATCH 2/3] font: add glyf rasterizer --- src/font/glyf_rasterize.zig | 832 ++++++++++++++++++++++++++++++++++++ src/font/main.zig | 1 + src/font/opentype/glyf.zig | 6 +- 3 files changed, 836 insertions(+), 3 deletions(-) create mode 100644 src/font/glyf_rasterize.zig diff --git a/src/font/glyf_rasterize.zig b/src/font/glyf_rasterize.zig new file mode 100644 index 000000000..f1b1eb3e7 --- /dev/null +++ b/src/font/glyf_rasterize.zig @@ -0,0 +1,832 @@ +//! 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 face = @import("face.zig"); +const glyf = @import("opentype/glyf.zig"); + +/// Metrics describing the authored glyf coordinate space, since +/// a glyf table doesn't contain this on its own. +pub const DesignMetrics = struct { + /// Units-per-em for outline/design coordinates. + units_per_em: u32, + + /// Authored advance width in design units. + advance_width: u32, + + /// Authored line height in design units. + line_height: u32, +}; + +/// 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 +/// `face.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: face.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.face.GlyphSize and is converted to z2d's y-down axis when + /// points are transformed. + y: f64, + + /// Width of the rasterized outline bounds in bitmap pixels after applying + /// font.face.RenderOptions.Constraint. + width: f64, + + /// Height of the rasterized outline bounds in bitmap pixels after applying + /// font.face.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: face.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: face.GlyphSize = .{ + .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: face.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 "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/src/font/main.zig b/src/font/main.zig index a8522afe1..0c218bbda 100644 --- a/src/font/main.zig +++ b/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/src/font/opentype/glyf.zig b/src/font/opentype/glyf.zig index fb9621221..6f94264c8 100644 --- a/src/font/opentype/glyf.zig +++ b/src/font/opentype/glyf.zig @@ -25,10 +25,10 @@ pub const Glyf = struct { pub const Outline = struct { /// List of contour end points. Calculate the full list of /// points using points[prev...this+1] - contours: []sfnt.uint16, + contours: []const sfnt.uint16, /// The backing storage of all points in the entry. - points: []Point, + points: []const Point, /// A single decoded point in a simple glyph contour. pub const Point = struct { @@ -41,7 +41,7 @@ pub const Glyf = struct { /// /// The returned slice references `points` and is invalidated when /// this outline is deinitialized. - pub fn contour(self: Outline, index: usize) []Point { + pub fn contour(self: Outline, index: usize) []const Point { const start = if (index == 0) 0 else From 51995a7822de65adfbd1f7c3208d9522500ee58c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Jun 2026 20:42:41 -0700 Subject: [PATCH 3/3] font: glyf rasterization png comparison --- src/font/glyf_rasterize.zig | 4 + src/font/glyf_rasterize_png_test.zig | 301 +++++++++++++++++++++++++++ src/font/opentype/glyf.zig | 2 +- src/font/testdata/glyf_rasterize.png | Bin 0 -> 1215 bytes 4 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/font/glyf_rasterize_png_test.zig create mode 100644 src/font/testdata/glyf_rasterize.png diff --git a/src/font/glyf_rasterize.zig b/src/font/glyf_rasterize.zig index f1b1eb3e7..43dde026f 100644 --- a/src/font/glyf_rasterize.zig +++ b/src/font/glyf_rasterize.zig @@ -465,6 +465,10 @@ fn testMetrics(width: u32, height: u32) @import("Metrics.zig") { }; } +test { + _ = @import("glyf_rasterize_png_test.zig"); +} + test "glyf_rasterize: empty outline returns empty bitmap" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/font/glyf_rasterize_png_test.zig b/src/font/glyf_rasterize_png_test.zig new file mode 100644 index 000000000..e8ad70fdf --- /dev/null +++ b/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/src/font/opentype/glyf.zig b/src/font/opentype/glyf.zig index 6f94264c8..194385345 100644 --- a/src/font/opentype/glyf.zig +++ b/src/font/opentype/glyf.zig @@ -18,7 +18,7 @@ pub const Glyf = struct { /// A decoded glyph outline. /// - /// The `countours` slice is the list of end point indices and + /// 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. diff --git a/src/font/testdata/glyf_rasterize.png b/src/font/testdata/glyf_rasterize.png new file mode 100644 index 0000000000000000000000000000000000000000..f5f8fb81be51d29dc7bfc1ab791a2d765d79ff44 GIT binary patch literal 1215 zcmeAS@N?(olHy`uVBq!ia0y~yU^v0Rz!1Q}z`(#z$(EYKz`)Yx>EaktG3U+9#I0+z zB-%0^Sd=9gr6d|T+!UGwdlFcbBo1=8{CF4SC|gmu!`uA$=Kepk{;n1Oa?`kj-{7aW z)MTgZZ#S2u1XkDddis8uGFy{7Nb}$F{9imvR`(zDI%*YHg-6u7#Hf!_}_I_ zVedTo8&_Sf>F{q4Qt07#X_N21sW9JvQ)=$VxkrEPUd`PkvFO|83bsq!if6*CC+#p@ zth`uS(IWfHj&pmQ6q+;yCIwIUxmwto~~jayuDcFTdhglH93ZoG7+BeX=fh`dOY~1(OxUxJAFGl1Fs?bkxCn;&O35X7rbBm z(davaUc(C(k2l|@A3J@Ad51j9j=xJmq6;?1?=mR(eYySkH7Sd@cXA9R-?SA@yk(!@ zys!Ux&^;Jm^n~Jl_s37)F*>C0H@i7es9EMiEzhLw>0F%k5c;m z{c`#5`-5)OIN$mI;nMFF(lsI-k1BUoJML-`e=~pY-t@R93Gd7Am>hqfBG|F$n03?ETie_lh0t-pD>rs`l>{zxA8*MAG}`<`V1F zCm-AS*taexip4VFedotHdrzOe=kJgf{^!%=&g&J7#X8%{XE1;3JO25b-qP*gcRL0J ziQW9Y^y8`<_qZF6eB(1`-0YZCZO^mC4<}>=0k7QJ~l+XG6P0zgAzoDXfzhp~l z0}EK?KgLbl_usl#KXLQ^=Wouh+WY*?eZ5cfHaCB~S6n8-bG`5ToV!o&6@N=ocyIZ; z`rJFmd&S?5q|~3kDLlD0_r2xs&