250 lines
10 KiB
Zig
250 lines
10 KiB
Zig
//! 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);
|
|
}
|