pull/12893/merge
Mitchell Hashimoto 2026-06-03 12:27:01 -07:00 committed by GitHub
commit 3ff3daa268
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1584 additions and 0 deletions

836
src/font/glyf_rasterize.zig Normal file
View File

@ -0,0 +1,836 @@
//! 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 {
_ = @import("glyf_rasterize_png_test.zig");
}
test "glyf_rasterize: empty outline returns empty bitmap" {
const testing = std.testing;
const alloc = testing.allocator;
var bm = try rasterize(alloc, .{ .points = &.{}, .contours = &.{} }, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = testMetrics(20, 20),
});
defer bm.deinit(alloc);
try testing.expectEqual(@as(u32, 20), bm.width);
try testing.expectEqual(@as(u32, 20), bm.height);
try testing.expectEqual(@as(usize, 20 * 20), bm.data.len);
for (bm.data) |v| try testing.expectEqual(@as(u8, 0), v);
}
test "glyf_rasterize: square fills bitmap center" {
const testing = std.testing;
const alloc = testing.allocator;
const outline: glyf.Glyf.Outline = .{
.points = &.{
.{ .x = 0, .y = 0, .on_curve = true },
.{ .x = 1000, .y = 0, .on_curve = true },
.{ .x = 1000, .y = 1000, .on_curve = true },
.{ .x = 0, .y = 1000, .on_curve = true },
},
.contours = &.{3},
};
var bm = try rasterize(alloc, outline, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = testMetrics(20, 20),
});
defer bm.deinit(alloc);
try testing.expect(bm.data[10 * bm.width + 10] > 200);
}
test "glyf_rasterize: quadratic contour renders" {
const testing = std.testing;
const alloc = testing.allocator;
const outline: glyf.Glyf.Outline = .{
.points = &.{
.{ .x = 0, .y = 0, .on_curve = true },
.{ .x = 500, .y = 1000, .on_curve = false },
.{ .x = 1000, .y = 0, .on_curve = true },
},
.contours = &.{2},
};
var bm = try rasterize(alloc, outline, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = testMetrics(20, 20),
});
defer bm.deinit(alloc);
var nonzero = false;
for (bm.data) |v| nonzero = nonzero or v != 0;
try testing.expect(nonzero);
}
test "glyf_rasterize: consecutive off-curve points render" {
const testing = std.testing;
const alloc = testing.allocator;
const outline: glyf.Glyf.Outline = .{
.points = &.{
.{ .x = 0, .y = 0, .on_curve = true },
.{ .x = 250, .y = 1000, .on_curve = false },
.{ .x = 750, .y = 1000, .on_curve = false },
.{ .x = 1000, .y = 0, .on_curve = true },
},
.contours = &.{3},
};
var bm = try rasterize(alloc, outline, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = testMetrics(20, 20),
});
defer bm.deinit(alloc);
var nonzero = false;
for (bm.data) |v| nonzero = nonzero or v != 0;
try testing.expect(nonzero);
}
test "glyf_rasterize: units per em controls baseline scale" {
const testing = std.testing;
const alloc = testing.allocator;
const outline: glyf.Glyf.Outline = .{
.points = &.{
.{ .x = 0, .y = 0, .on_curve = true },
.{ .x = 1000, .y = 0, .on_curve = true },
.{ .x = 1000, .y = 1000, .on_curve = true },
.{ .x = 0, .y = 1000, .on_curve = true },
},
.contours = &.{3},
};
var bm = try rasterize(alloc, outline, .{
.units_per_em = 2000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = testMetrics(20, 20),
});
defer bm.deinit(alloc);
// With a 2000-unit em in a 20px cell, this 1000-unit square occupies the
// bottom half of the cell. This matches the linked rasterizer's y=0
// baseline/bottom behavior and proves units_per_em is the scale reference.
try testing.expect(bm.data[15 * bm.width + 5] > 200);
try testing.expectEqual(@as(u8, 0), bm.data[5 * bm.width + 5]);
}
test "glyf_rasterize: degenerate outline returns full empty bitmap" {
const testing = std.testing;
const alloc = testing.allocator;
const outline: glyf.Glyf.Outline = .{
.points = &.{
.{ .x = 0, .y = 0, .on_curve = true },
.{ .x = 1000, .y = 0, .on_curve = true },
.{ .x = 500, .y = 0, .on_curve = true },
},
.contours = &.{2},
};
var bm = try rasterize(alloc, outline, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = testMetrics(20, 20),
});
defer bm.deinit(alloc);
try testing.expectEqual(@as(u32, 20), bm.width);
try testing.expectEqual(@as(u32, 20), bm.height);
try testing.expectEqual(@as(usize, 20 * 20), bm.data.len);
for (bm.data) |v| try testing.expectEqual(@as(u8, 0), v);
}
test "glyf_rasterize: contour can start off curve with final on curve point" {
const testing = std.testing;
const alloc = testing.allocator;
const outline: glyf.Glyf.Outline = .{
.points = &.{
.{ .x = 500, .y = 1000, .on_curve = false },
.{ .x = 1000, .y = 0, .on_curve = true },
.{ .x = 0, .y = 0, .on_curve = true },
},
.contours = &.{2},
};
var bm = try rasterize(alloc, outline, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = testMetrics(20, 20),
});
defer bm.deinit(alloc);
var nonzero = false;
for (bm.data) |v| nonzero = nonzero or v != 0;
try testing.expect(nonzero);
}
test "glyf_rasterize: contour can start with implied midpoint" {
const testing = std.testing;
const alloc = testing.allocator;
const outline: glyf.Glyf.Outline = .{
.points = &.{
.{ .x = 250, .y = 1000, .on_curve = false },
.{ .x = 1000, .y = 0, .on_curve = true },
.{ .x = 750, .y = 1000, .on_curve = false },
.{ .x = 0, .y = 0, .on_curve = true },
},
.contours = &.{3},
};
var bm = try rasterize(alloc, outline, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = testMetrics(20, 20),
});
defer bm.deinit(alloc);
var nonzero = false;
for (bm.data) |v| nonzero = nonzero or v != 0;
try testing.expect(nonzero);
}
test "glyf_rasterize: multiple contours render independently" {
const testing = std.testing;
const alloc = testing.allocator;
const outline: glyf.Glyf.Outline = .{
.points = &.{
.{ .x = 0, .y = 0, .on_curve = true },
.{ .x = 400, .y = 0, .on_curve = true },
.{ .x = 400, .y = 400, .on_curve = true },
.{ .x = 0, .y = 400, .on_curve = true },
.{ .x = 600, .y = 600, .on_curve = true },
.{ .x = 1000, .y = 600, .on_curve = true },
.{ .x = 1000, .y = 1000, .on_curve = true },
.{ .x = 600, .y = 1000, .on_curve = true },
},
.contours = &.{ 3, 7 },
};
var bm = try rasterize(alloc, outline, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = testMetrics(20, 20),
});
defer bm.deinit(alloc);
try testing.expect(bm.data[16 * bm.width + 4] > 200);
try testing.expect(bm.data[4 * bm.width + 16] > 200);
try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 10]);
}
test "glyf_rasterize: non-zero bearings preserve declared whitespace" {
const testing = std.testing;
const alloc = testing.allocator;
const outline: glyf.Glyf.Outline = .{
.points = &.{
.{ .x = 250, .y = 0, .on_curve = true },
.{ .x = 750, .y = 0, .on_curve = true },
.{ .x = 750, .y = 1000, .on_curve = true },
.{ .x = 250, .y = 1000, .on_curve = true },
},
.contours = &.{3},
};
var bm = try rasterize(alloc, outline, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = testMetrics(20, 20),
});
defer bm.deinit(alloc);
try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 2]);
try testing.expect(bm.data[10 * bm.width + 10] > 200);
try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 17]);
}
test "glyf_rasterize: negative y coordinates descend below baseline" {
const testing = std.testing;
const alloc = testing.allocator;
const outline: glyf.Glyf.Outline = .{
.points = &.{
.{ .x = 0, .y = -250, .on_curve = true },
.{ .x = 1000, .y = -250, .on_curve = true },
.{ .x = 1000, .y = 750, .on_curve = true },
.{ .x = 0, .y = 750, .on_curve = true },
},
.contours = &.{3},
};
var bm = try rasterize(alloc, outline, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = testMetrics(20, 20),
});
defer bm.deinit(alloc);
try testing.expectEqual(@as(u8, 0), bm.data[2 * bm.width + 10]);
try testing.expect(bm.data[10 * bm.width + 10] > 200);
try testing.expect(bm.data[18 * bm.width + 10] > 200);
}
test "glyf_rasterize: two-cell bitmap and constraint render within width" {
const testing = std.testing;
const alloc = testing.allocator;
const outline: glyf.Glyf.Outline = .{
.points = &.{
.{ .x = 0, .y = 0, .on_curve = true },
.{ .x = 1000, .y = 0, .on_curve = true },
.{ .x = 1000, .y = 1000, .on_curve = true },
.{ .x = 0, .y = 1000, .on_curve = true },
},
.contours = &.{3},
};
var bm = try rasterize(alloc, outline, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 1000,
}, .{
.grid_metrics = testMetrics(20, 20),
.cell_width = 2,
.constraint_width = 2,
.constraint = .{
.size = .cover,
.align_horizontal = .center,
.align_vertical = .center,
},
});
defer bm.deinit(alloc);
try testing.expectEqual(@as(u32, 40), bm.width);
try testing.expectEqual(@as(u32, 20), bm.height);
try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 2]);
try testing.expect(bm.data[10 * bm.width + 20] > 200);
try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 37]);
}
test "glyf_rasterize: line height does not change unconstrained em scale" {
const testing = std.testing;
const alloc = testing.allocator;
const outline: glyf.Glyf.Outline = .{
.points = &.{
.{ .x = 0, .y = 0, .on_curve = true },
.{ .x = 1000, .y = 0, .on_curve = true },
.{ .x = 1000, .y = 1000, .on_curve = true },
.{ .x = 0, .y = 1000, .on_curve = true },
},
.contours = &.{3},
};
var bm = try rasterize(alloc, outline, .{
.units_per_em = 1000,
.advance_width = 1000,
.line_height = 2000,
}, .{
.grid_metrics = testMetrics(20, 20),
});
defer bm.deinit(alloc);
try testing.expect(bm.data[10 * bm.width + 10] > 200);
try testing.expect(bm.data[2 * bm.width + 10] > 200);
try testing.expect(bm.data[17 * bm.width + 10] > 200);
}

View File

@ -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
"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",
};
/// 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 BFHR 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));
}

View File

@ -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");

View File

@ -1,4 +1,5 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const sfnt = @import("sfnt.zig");
/// Glyph Data Table
@ -15,6 +16,49 @@ const sfnt = @import("sfnt.zig");
pub const Glyf = struct {
data: []const u8,
/// A decoded glyph outline.
///
/// The `contours` slice is the list of end point indices and
/// `points` owns all the points. Glyf guarantees that contour
/// points are sequential so we can just store the end and calculate
/// the points that way. Use the helpers to make it ergonomic.
pub const Outline = struct {
/// List of contour end points. Calculate the full list of
/// points using points[prev...this+1]
contours: []const sfnt.uint16,
/// The backing storage of all points in the entry.
points: []const Point,
/// A single decoded point in a simple glyph contour.
pub const Point = struct {
x: i32,
y: i32,
on_curve: bool,
};
/// Return the point slice for the contour at `index`.
///
/// The returned slice references `points` and is invalidated when
/// this outline is deinitialized.
pub fn contour(self: Outline, index: usize) []const Point {
const start = if (index == 0)
0
else
@as(usize, self.contours[index - 1]) + 1;
const end = @as(usize, self.contours[index]) + 1;
return self.points[start..end];
}
/// Free all memory owned by this outline. Pass in the same
/// allocator used for decoding.
pub fn deinit(self: *Outline, alloc: Allocator) void {
alloc.free(self.contours);
alloc.free(self.points);
self.* = undefined;
}
};
/// https://learn.microsoft.com/en-us/typography/opentype/spec/glyf#table-organization
pub const Entry = struct {
header: Header,
@ -241,6 +285,12 @@ pub const Glyf = struct {
TooManyPoints,
};
/// Errors that can be returned from `Entry.decode()`.
pub const DecodeError = SizeError || Allocator.Error || error{
/// Coordinate delta accumulation overflowed.
CoordinateOverflow,
};
/// Determines the size (in bytes) of this entry.
///
/// If the entry is valid, returns the number of bytes
@ -393,6 +443,149 @@ pub const Glyf = struct {
// No issues found, the glyf entry is valid, return its length.
return @sizeOf(Header) + fbs.pos;
}
/// Decode this simple glyph entry into an owned outline.
///
/// NOTE: Currently produces errors when given composite glyphs
/// or any glyphs that have hinting instructions included.
pub fn decode(self: Entry, alloc: Allocator) DecodeError!Glyf.Outline {
// We only support simple glyphs.
switch (self.entryType()) {
.simple => {},
.composite => return error.CompositeNotSupported,
}
var fbs = std.io.fixedBufferStream(self.data);
const reader = fbs.reader();
// A zero-contour glyph may be header-only. See size for the
// reason for the hardcoded 2 here.
const num_contours: usize = @intCast(self.header.numberOfContours);
if (num_contours == 0 and self.data.len < 2) return .{
.points = &.{},
.contours = &.{},
};
// We now know our full amount of contour ending points.
const end_points = try alloc.alloc(sfnt.uint16, num_contours);
errdefer alloc.free(end_points);
// If we have no contours, then the only possible remaining
// field is instructionLength. Instructions are not supported.
if (num_contours == 0) {
const instructions_length = try reader.readInt(sfnt.uint16, .big);
if (instructions_length > 0) return error.InstructionsNotSupported;
return .{ .points = &.{}, .contours = end_points };
}
// The number of points is determined by the final end point
// entry since the entries have to be monotonic (something
// we verify below).
const point_count: usize = point_count: {
var prev_end_point: isize = -1;
// Go through the end points array and update our end_points
// with the valid index. The final endpoint tells us our point
// count, since endpoints are stored as inclusive point indices.
for (0..end_points.len) |i| {
const index = try reader.readInt(sfnt.uint16, .big);
if (index <= prev_end_point) return error.EndPointsOutOfOrder;
prev_end_point = index;
end_points[i] = index;
}
// The final point tells us our point count.
break :point_count @as(usize, end_points[end_points.len - 1]) + 1;
};
// Instructions are not supported.
const instructions_length = try reader.readInt(sfnt.uint16, .big);
if (instructions_length > 0) return error.InstructionsNotSupported;
// Allocate our points right away even though the next entries
// are flags. We want to do this so that if the allocator is
// a bump allocator, the flags free will actually free it.
const points = try alloc.alloc(Glyf.Outline.Point, point_count);
errdefer alloc.free(points);
// This is EXTREMELY annoying but all the flags are separate
// from the points so we have to do some allocation here since
// its a dynamic amount and we need to save the values for later.
//
// Typical glyphs have small point counts, so use stack storage
// first while still falling back to the caller's allocator for
// unusually large outlines.
var flags_stack = std.heap.stackFallback(4096, alloc);
const flags_alloc = flags_stack.get();
const flags = try flags_alloc.alloc(SimpleFlags, point_count);
defer flags_alloc.free(flags);
{
var point_i: usize = 0;
while (point_i < point_count) {
const flag: SimpleFlags = @bitCast(try reader.readByte());
flags[point_i] = flag;
point_i += 1;
if (flag.repeat) {
const repeat_count: usize = try reader.readByte();
if (point_i + repeat_count > point_count) return error.TooManyPoints;
for (0..repeat_count) |_| {
flags[point_i] = flag;
point_i += 1;
}
}
}
}
// Go through x coordinate deltas
var x: i32 = 0;
for (flags, points) |flag, *point| {
const dx: i32 = if (flag.x_short) short: {
break :short if (flag.x_repeat_or_sign)
@as(i32, try reader.readByte())
else
-@as(i32, try reader.readByte());
} else if (!flag.x_repeat_or_sign)
@as(i32, try reader.readInt(sfnt.int16, .big))
else
0;
x = std.math.add(
i32,
x,
dx,
) catch return error.CoordinateOverflow;
point.x = x;
}
// Go through y coordinate deltas
var y: i32 = 0;
for (flags, points) |flag, *point| {
const dy: i32 = if (flag.y_short) short: {
break :short if (flag.y_repeat_or_sign)
@as(i32, try reader.readByte())
else
-@as(i32, try reader.readByte());
} else if (!flag.y_repeat_or_sign)
@as(i32, try reader.readInt(sfnt.int16, .big))
else
0;
y = std.math.add(
i32,
y,
dy,
) catch return error.CoordinateOverflow;
point.y = y;
point.on_curve = flag.on_curve;
}
return .{
.points = points,
.contours = end_points,
};
}
};
/// Initialize the table from the provided data.
@ -451,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));
}

BIN
src/font/testdata/glyf_rasterize.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB