//! A tiny 5x7 bitmap font for drawing axis labels directly into a z2d //! `image_surface_rgb` pixel buffer. //! //! Why a hand-rolled bitmap font instead of `z2d.text` + a TTF? //! 1. The chart renderers in this directory deliberately draw with //! anti-aliasing OFF and the `.src` operator, pre-blending colors //! against the background to sidestep z2d's `src_over` compositor //! overflow on semi-transparent fills. Glyph outline rasterization //! needs alpha blending for its edges and would hit that same bug. //! Solid 1-bit pixels avoid it entirely. //! 2. It keeps a ~hundreds-of-KB TTF (and its license) out of the repo. //! //! The glyph set is intentionally minimal - just what axis labels and //! chart legends need: digits, `$`, `.`, `,`, `-`, `%`, the `T`/`B`/`M` //! magnitude suffixes emitted by `format.fmtLargeNum`, and the //! lowercase letters `t`/`h`/`e`/`n`/`o`/`w` for the comparison //! chart's "then"/"now" legend. Space and unknown chars render blank. //! //! Coordinates are in surface pixels; `scale` multiplies the 5x7 cell //! (so `scale = 3` renders 15x21 glyphs). Drawing is clipped to the //! surface bounds. Callers own pixel layout (margins, alignment); this //! module only stamps glyphs. const std = @import("std"); const z2d = @import("z2d"); const draw = @import("draw.zig"); const Surface = z2d.Surface; const RGB = z2d.pixel.RGB; /// Glyph cell dimensions, in font pixels (pre-scale). pub const glyph_w: i32 = 5; pub const glyph_h: i32 = 7; /// Horizontal advance per glyph including the 1px inter-glyph gap. pub const advance: i32 = glyph_w + 1; /// Each glyph is 7 rows; the low 5 bits of each row are the pixels, /// bit 4 (0b10000) = leftmost column. Row 0 is the top. const Glyph = [7]u8; const blank: Glyph = .{ 0, 0, 0, 0, 0, 0, 0 }; const digits = [10]Glyph{ .{ 0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E }, // 0 .{ 0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E }, // 1 .{ 0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F }, // 2 .{ 0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E }, // 3 .{ 0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02 }, // 4 .{ 0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E }, // 5 .{ 0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E }, // 6 .{ 0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08 }, // 7 .{ 0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E }, // 8 .{ 0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C }, // 9 }; const glyph_dollar: Glyph = .{ 0x04, 0x0E, 0x14, 0x0E, 0x05, 0x0E, 0x04 }; const glyph_period: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06 }; const glyph_comma: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x08 }; const glyph_minus: Glyph = .{ 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00 }; const glyph_percent: Glyph = .{ 0x19, 0x1A, 0x02, 0x04, 0x08, 0x13, 0x03 }; const glyph_T: Glyph = .{ 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 }; const glyph_B: Glyph = .{ 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E }; const glyph_M: Glyph = .{ 0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11 }; // Lowercase letters - just what the comparison chart's "then"/"now" // legend needs (t, h, e, n, o, w). 5x7, low 5 bits per row. Named // `lc_*` to avoid colliding with the `glyph_w`/`glyph_h` cell dims. const lc_t: Glyph = .{ 0x08, 0x08, 0x1C, 0x08, 0x08, 0x08, 0x0C }; const lc_h: Glyph = .{ 0x10, 0x10, 0x10, 0x1E, 0x12, 0x12, 0x12 }; const lc_e: Glyph = .{ 0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E }; const lc_n: Glyph = .{ 0x00, 0x00, 0x1E, 0x12, 0x12, 0x12, 0x12 }; const lc_o: Glyph = .{ 0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E }; const lc_w: Glyph = .{ 0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A }; /// Look up the bitmap for a character. Unknown characters (including /// space) render blank. fn glyphFor(ch: u8) Glyph { return switch (ch) { '0'...'9' => digits[ch - '0'], '$' => glyph_dollar, '.' => glyph_period, ',' => glyph_comma, '-' => glyph_minus, '%' => glyph_percent, 'T' => glyph_T, 'B' => glyph_B, 'M' => glyph_M, 't' => lc_t, 'h' => lc_h, 'e' => lc_e, 'n' => lc_n, 'o' => lc_o, 'w' => lc_w, else => blank, }; } /// Width in surface pixels that `drawText` will occupy for `text` at /// `scale` (excludes the trailing inter-glyph gap). Zero for empty text. /// Useful for right-aligning a label against a known x. pub fn measureWidth(text: []const u8, scale: i32) i32 { if (text.len == 0) return 0; const n: i32 = @intCast(text.len); // n full glyphs + (n-1) gaps = n*advance - 1, times scale. return (n * advance - 1) * scale; } /// Stamp `text` onto the surface's RGB buffer with its top-left at /// `(x, y)`, each font pixel drawn as a `scale` x `scale` solid block in /// `color`. No-op for non-RGB surfaces. Pixels outside the surface are /// clipped. pub fn drawText(sfc: *Surface, x: i32, y: i32, scale: i32, color: [3]u8, text: []const u8) void { if (scale <= 0) return; const img = switch (sfc.*) { .image_surface_rgb => |s| s, else => return, }; const w = img.width; const h = img.height; const px = RGB{ .r = color[0], .g = color[1], .b = color[2] }; var cursor_x = x; for (text) |ch| { const glyph = glyphFor(ch); for (0..@intCast(glyph_h)) |gr| { const bits = glyph[gr]; for (0..@intCast(glyph_w)) |gc| { const mask: u8 = @as(u8, 1) << @intCast(glyph_w - 1 - @as(i32, @intCast(gc))); if (bits & mask == 0) continue; fillBlock( img.buf, w, h, cursor_x + @as(i32, @intCast(gc)) * scale, y + @as(i32, @intCast(gr)) * scale, scale, px, ); } } cursor_x += advance * scale; } } /// Fill a `size` x `size` block of pixels at `(bx, by)`, clipped to the /// `[0,w) x [0,h)` surface bounds. fn fillBlock(buf: []RGB, w: i32, h: i32, bx: i32, by: i32, size: i32, px: RGB) void { var dy: i32 = 0; while (dy < size) : (dy += 1) { const yy = by + dy; if (yy < 0 or yy >= h) continue; var dx: i32 = 0; while (dx < size) : (dx += 1) { const xx = bx + dx; if (xx < 0 or xx >= w) continue; buf[@intCast(yy * w + xx)] = px; } } } // ── Tests ───────────────────────────────────────────────────────────── const testing = std.testing; test "measureWidth: empty is zero, scales with length" { try testing.expectEqual(@as(i32, 0), measureWidth("", 3)); // One glyph = glyph_w * scale (no trailing gap). try testing.expectEqual(@as(i32, glyph_w * 2), measureWidth("1", 2)); // Two glyphs = (2*advance - 1) * scale = (2*6 - 1)*2 = 22. try testing.expectEqual(@as(i32, 22), measureWidth("12", 2)); } test "drawText stamps glyph pixels in the requested color" { const alloc = testing.allocator; var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 16); defer sfc.deinit(alloc); // Surface starts zeroed (black). Draw white text. const white = [3]u8{ 0xFF, 0xFF, 0xFF }; try testing.expectEqual(@as(usize, 0), draw.countColor(&sfc, white)); drawText(&sfc, 1, 1, 1, white, "1"); // '1' has 10 set pixels in the 5x7 bitmap; at scale 1 that's 10 white px. try testing.expectEqual(@as(usize, 10), draw.countColor(&sfc, white)); } test "drawText scale multiplies the stamped pixel count" { const alloc = testing.allocator; var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 32); defer sfc.deinit(alloc); const c = [3]u8{ 0x10, 0x20, 0x30 }; drawText(&sfc, 0, 0, 2, c, "1"); // 10 set font-pixels, each a 2x2 block -> 10 * 4 = 40 colored px. try testing.expectEqual(@as(usize, 40), draw.countColor(&sfc, c)); } test "drawText clips out-of-bounds without writing past the buffer" { const alloc = testing.allocator; var sfc = try Surface.init(.image_surface_rgb, alloc, 8, 8); defer sfc.deinit(alloc); const c = [3]u8{ 0xAA, 0xBB, 0xCC }; // Draw partly off the right/bottom edge and fully off-screen; must // not crash or panic (bounds-checked writes). drawText(&sfc, 6, 6, 3, c, "8"); drawText(&sfc, -50, -50, 4, c, "8"); drawText(&sfc, 100, 100, 4, c, "8"); // Some pixels of the first (partly-visible) glyph may have landed. try testing.expect(draw.countColor(&sfc, c) <= 8 * 9); } test "drawText ignores unknown glyphs (renders blank)" { const alloc = testing.allocator; var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 16); defer sfc.deinit(alloc); const white = [3]u8{ 0xFF, 0xFF, 0xFF }; // '?' and space are not in the set -> nothing drawn. drawText(&sfc, 1, 1, 2, white, " ?"); try testing.expectEqual(@as(usize, 0), draw.countColor(&sfc, white)); } test "drawText renders the comma glyph (so thousands separators show)" { const alloc = testing.allocator; var sfc = try Surface.init(.image_surface_rgb, alloc, 16, 16); defer sfc.deinit(alloc); const white = [3]u8{ 0xFF, 0xFF, 0xFF }; drawText(&sfc, 1, 1, 1, white, ","); // The comma bitmap (rows 0x06,0x06,0x08) has 5 set pixels. try testing.expectEqual(@as(usize, 5), draw.countColor(&sfc, white)); } test "drawText renders the lowercase legend letters (then/now)" { const alloc = testing.allocator; var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 16); defer sfc.deinit(alloc); const white = [3]u8{ 0xFF, 0xFF, 0xFF }; // "thenow" exercises all six lowercase glyphs; none may be blank. drawText(&sfc, 1, 1, 1, white, "thenow"); try testing.expect(draw.countColor(&sfc, white) > 30); } test "drawText renders the percent glyph (for return-rate axis labels)" { const alloc = testing.allocator; var sfc = try Surface.init(.image_surface_rgb, alloc, 16, 16); defer sfc.deinit(alloc); const white = [3]u8{ 0xFF, 0xFF, 0xFF }; drawText(&sfc, 1, 1, 1, white, "%"); try testing.expect(draw.countColor(&sfc, white) > 0); }