font: glyf rasterization png comparison
parent
8eff74ef76
commit
51995a7822
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 │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));
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Loading…
Reference in New Issue