From d8f56b790e2cce1dd42908a94655d9242a813892 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Jun 2026 13:34:00 -0700 Subject: [PATCH] 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)); }