font: glyf rasterization png comparison

pull/12893/head
Mitchell Hashimoto 2026-06-01 20:42:41 -07:00
parent 8eff74ef76
commit 51995a7822
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
4 changed files with 306 additions and 1 deletions

View File

@ -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" { test "glyf_rasterize: empty outline returns empty bitmap" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;

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

@ -18,7 +18,7 @@ pub const Glyf = struct {
/// A decoded glyph outline. /// 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` owns all the points. Glyf guarantees that contour
/// points are sequential so we can just store the end and calculate /// points are sequential so we can just store the end and calculate
/// the points that way. Use the helpers to make it ergonomic. /// the points that way. Use the helpers to make it ergonomic.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB