// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Scaling a glyph's image to the size it is drawn at. //! //! Embedded bitmaps are drawn for particular sizes and have to be scaled to //! any other. Shrinking uses a box filter: every destination pixel is the //! average of the source pixels it covers, weighted by how much of it each //! covers, so no source pixel is skipped however far the image shrinks. //! Enlarging either keeps the box filter, which leaves a pixel font's //! pixels square and sharp, or interpolates linearly between source pixel //! centers, which is kinder to a photographic emoji. //! //! The filter is separable, so it runs across the rows and then down the //! columns. Pixels are treated as `channels` independent bytes; for color, //! these must be pre-multiplied, as z2d's are, or a transparent pixel's //! color would bleed into its neighbors. const std = @import("std"); const Allocator = std.mem.Allocator; pub const Filter = enum { /// Area averaging, at every scale. box, /// Area averaging when shrinking, linear interpolation when enlarging. smooth, }; /// Where the source image goes, in destination pixels: its top-left corner /// at `(x, y)`, each source pixel `scale_x` by `scale_y` destination pixels. pub const Placement = struct { x: f32, y: f32, scale_x: f32, scale_y: f32, }; /// Resample `src`, `src_w` by `src_h` pixels of `channels` bytes each, into /// `dst`, `dst_w` by `dst_h` pixels of the same, at `at`. Destination /// pixels the image does not reach are zero. pub fn image( gpa: Allocator, src: []const u8, src_w: usize, src_h: usize, channels: usize, dst: []u8, dst_w: usize, dst_h: usize, at: Placement, filter: Filter, ) Allocator.Error!void { std.debug.assert(src.len == src_w * src_h * channels); std.debug.assert(dst.len == dst_w * dst_h * channels); // Across: src_h rows of dst_w pixels. const rows = try gpa.alloc(f32, src_h * dst_w * channels); defer gpa.free(rows); const line_in = try gpa.alloc(f32, @max(src_w, src_h)); defer gpa.free(line_in); const line_out = try gpa.alloc(f32, @max(dst_w, dst_h)); defer gpa.free(line_out); for (0..src_h) |r| for (0..channels) |c| { for (line_in[0..src_w], 0..) |*v, x| v.* = @floatFromInt(src[(r * src_w + x) * channels + c]); line(line_out[0..dst_w], line_in[0..src_w], at.x, at.scale_x, filter); for (line_out[0..dst_w], 0..) |v, x| rows[(r * dst_w + x) * channels + c] = v; }; // Down: dst_w columns of dst_h pixels. for (0..dst_w) |x| for (0..channels) |c| { for (line_in[0..src_h], 0..) |*v, r| v.* = rows[(r * dst_w + x) * channels + c]; line(line_out[0..dst_h], line_in[0..src_h], at.y, at.scale_y, filter); for (line_out[0..dst_h], 0..) |v, y| { dst[(y * dst_w + x) * channels + c] = @intFromFloat(std.math.clamp(@round(v), 0, 255)); } }; } /// One row or column: source pixel `i` covers `[start + i * scale, /// start + (i + 1) * scale)` of `dst`. fn line(dst: []f32, src: []const f32, start: f32, scale: f32, filter: Filter) void { @memset(dst, 0); if (filter == .smooth and scale > 1) return interpolate(dst, src, start, scale); for (src, 0..) |v, i| { if (v == 0) continue; spread(dst, v, start + @as(f32, @floatFromInt(i)) * scale, scale); } } /// Add `v` over `[a, a + len)` of `dst`, in proportion to how much of each /// pixel that interval covers. fn spread(dst: []f32, v: f32, a: f32, len: f32) void { const b = a + len; var d: usize = @intFromFloat(@max(@floor(a), 0)); while (d < dst.len) : (d += 1) { const lo: f32 = @floatFromInt(d); if (lo >= b) break; const overlap = @min(lo + 1, b) - @max(lo, a); if (overlap > 0) dst[d] += v * overlap; } } /// Each destination pixel's center, mapped back into the source, takes the /// two source pixels whose centers it falls between, weighted by distance. /// Past the outermost centers the image fades to nothing over half a source /// pixel, as if it were surrounded by transparency, which it is. fn interpolate(dst: []f32, src: []const f32, start: f32, scale: f32) void { const n: f32 = @floatFromInt(src.len); for (dst, 0..) |*o, d| { const u = (@as(f32, @floatFromInt(d)) + 0.5 - start) / scale - 0.5; if (u <= -1 or u >= n) continue; const f = @floor(u); const w = u - f; const lo = sampleAt(src, f); const hi = sampleAt(src, f + 1); o.* = lo * (1 - w) + hi * w; } } fn sampleAt(src: []const f32, i: f32) f32 { if (i < 0 or i >= @as(f32, @floatFromInt(src.len))) return 0; return src[@intFromFloat(i)]; } test "the box filter copies at scale one and spreads otherwise" { var dst: [4]f32 = undefined; line(&dst, &.{ 255, 0, 255 }, 1, 1, .box); try std.testing.expectEqualSlices(f32, &.{ 0, 255, 0, 255 }, &dst); line(&dst, &.{ 255, 0 }, 0, 2, .box); try std.testing.expectEqualSlices(f32, &.{ 255, 255, 0, 0 }, &dst); line(&dst, &.{ 255, 255 }, 0.5, 1, .box); try std.testing.expectEqualSlices(f32, &.{ 127.5, 255, 127.5, 0 }, &dst); // Halving averages each pair. line(dst[0..2], &.{ 255, 0, 100, 100 }, 0, 0.5, .smooth); try std.testing.expectEqualSlices(f32, &.{ 127.5, 100 }, dst[0..2]); } test "enlarging smoothly interpolates between pixel centers" { var dst: [4]f32 = undefined; line(&dst, &.{ 0, 200 }, 0, 2, .smooth); // Centers at 0.5, 1.5, 2.5, 3.5 map back to -0.25, 0.25, 0.75, 1.25. try std.testing.expectEqualSlices(f32, &.{ 0, 50, 150, 150 }, &dst); } test "a whole image, two channels, shrunk by half" { const src = [_]u8{ 255, 0, 255, 0, 0, 0, 0, 0, 255, 0, 255, 0, 0, 0, 0, 0, }; var dst: [4]u8 = undefined; try image(std.testing.allocator, &src, 4, 2, 2, &dst, 2, 1, .{ .x = 0, .y = 0, .scale_x = 0.5, .scale_y = 0.5 }, .smooth); try std.testing.expectEqualSlices(u8, &.{ 255, 0, 0, 0 }, &dst); }