From 51995a7822de65adfbd1f7c3208d9522500ee58c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Jun 2026 20:42:41 -0700 Subject: [PATCH] font: glyf rasterization png comparison --- src/font/glyf_rasterize.zig | 4 + src/font/glyf_rasterize_png_test.zig | 301 +++++++++++++++++++++++++++ src/font/opentype/glyf.zig | 2 +- src/font/testdata/glyf_rasterize.png | Bin 0 -> 1215 bytes 4 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/font/glyf_rasterize_png_test.zig create mode 100644 src/font/testdata/glyf_rasterize.png diff --git a/src/font/glyf_rasterize.zig b/src/font/glyf_rasterize.zig index f1b1eb3e7..43dde026f 100644 --- a/src/font/glyf_rasterize.zig +++ b/src/font/glyf_rasterize.zig @@ -465,6 +465,10 @@ fn testMetrics(width: u32, height: u32) @import("Metrics.zig") { }; } +test { + _ = @import("glyf_rasterize_png_test.zig"); +} + test "glyf_rasterize: empty outline returns empty bitmap" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/font/glyf_rasterize_png_test.zig b/src/font/glyf_rasterize_png_test.zig new file mode 100644 index 000000000..e8ad70fdf --- /dev/null +++ b/src/font/glyf_rasterize_png_test.zig @@ -0,0 +1,301 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const wuffs = @import("wuffs"); +const z2d = @import("z2d"); + +const glyf_rasterize = @import("glyf_rasterize.zig"); +const glyf = @import("opentype/glyf.zig"); + +const log = std.log.scoped(.glyf_rasterize); + +const test_glyf_payloads = [_][]const u8{ + // Nerd Font branch, folder, home, heart, and Rust cog outlines from: + // https://github.com/raphamorim/glyph-protocol-examples/blob/main/bubbletea/main.go + "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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)); +} diff --git a/src/font/opentype/glyf.zig b/src/font/opentype/glyf.zig index 6f94264c8..194385345 100644 --- a/src/font/opentype/glyf.zig +++ b/src/font/opentype/glyf.zig @@ -18,7 +18,7 @@ pub const Glyf = struct { /// A decoded glyph outline. /// - /// The `countours` slice is the list of end point indices and + /// The `contours` slice is the list of end point indices and /// `points` owns all the points. Glyf guarantees that contour /// points are sequential so we can just store the end and calculate /// the points that way. Use the helpers to make it ergonomic. diff --git a/src/font/testdata/glyf_rasterize.png b/src/font/testdata/glyf_rasterize.png new file mode 100644 index 0000000000000000000000000000000000000000..f5f8fb81be51d29dc7bfc1ab791a2d765d79ff44 GIT binary patch literal 1215 zcmV;w1VH&J7{Lfc`0i3c9e8oLOE<@}kNlebtrzs!Gl&l` z`AZs;LTmQfq-0The2qy>^pu+^3sNcmr*HZUq^pm?O3bZ7ooh>D_5C88gT4ho6pj0p zrJ}XsWOb#xO53R|4Z;{5p1~0ak(fL4 z{!r13$sx+sQnr&-Le$H2n$*)kw3LjGAEiE#GybzxHJR#pAoUp32Am_)6Io*{+1gNP zkm@^#jf&$Gl^WSmFNj7VCNfP&T}hB>ej0rPi8+XLSg>3UB$)fFVq$rMmjdbdyviLH zLR=7^3#Gcm~o7A}HKv)3c-do&zJyk{)Dj^8)zMzg?cywk@H!zOj-Q`^ok?`_pPa2U6JWv9qJjCmxS#nQot3 z`??1JogD>FJRV&*aC?-)zK_Ps*#m&ij)G~AM<9XQqa5~qG+x#o0CaW~3|E%T;}J;U z_9%yaAB~r{2LKH*2gG?i0*Txn<)H7Q@iO-SE`aC+NbL5P#>?CTxE}<#{iX3T_W+=? zU%D`G`_qTV)*3io?j8Vi_A4OB?FW!UzK`cp-W~vS_A4OB?G?x&-$&!+?g2n&zc$&C z5`!G@eGHPf2LPSD2nh0hlyiY^djQbc?*Iwh9^`=Uqg>`50Ce^{Kti_%AhGYGT;?7C zboM(ySO!S!`zV*U2LPQNHSF=}rdt|ybmx#LZKY+3|4VDE|XoW0A<-cLYg zSN!smiPv}mb1b%Zm;?5X$Mg0srMLIHK~ho{+54sOs@UEOfyDL>HvzLjVtYRWscr+Q zfxUB(1NIKb1NNQ-S?&EMkW+g<^%YE?Y%TeZ0|5vyTHNTQy|XXZwBe5^!A>}MfQ$!-rnVA?+FlZ?>B?Y zFL~k|B(!&&^Y$(`dryFPd%qckWq<_sp2$V^{sFSLzuMk^k+Z+&?9Zyb=j`7u@}9GU z@!oTG6b#pn_nh73-gEY1An!T*d(Q4b-gEZEWPi`u6O(;y?>YNbkoTND%Kanf?Ee7( d0RR7kVF2=KAy0*kZfO7j002ovPDHLkV1h-_VL<=@ literal 0 HcmV?d00001