// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The pixels a sixel image draws, while it is still being drawn. //! //! Sixel data does not have to say how big it is. Raster attributes give a //! size, but they are optional and the data may draw beyond it anyway, so the //! canvas grows to fit whatever is drawn, doubling as it goes, and is cut //! down to its final size once the image ends. Growth stops at `Limits`: //! anything drawn beyond them is dropped, as a terminal drops what falls off //! the edge of its screen, and the image is marked as clipped. Nothing a //! stream says can make the canvas allocate more than the limits allow. const std = @import("std"); const Allocator = std.mem.Allocator; const z2d = @import("z2d"); const Rgb = @import("palette.zig").Rgb; const Canvas = @This(); const Pixel = z2d.pixel.RGBA; /// Bounds on the size of an image, which is the only thing an untrusted /// stream can use to make a decoder allocate. The defaults allow any image a /// terminal would display and cap the canvas at 64 MiB. pub const Limits = struct { max_width: u32 = 16384, max_height: u32 = 16384, /// Width times height, which is what decides the memory: four bytes a /// pixel. max_pixels: u64 = 16 * 1024 * 1024, }; gpa: Allocator, limits: Limits, /// `cap_w * cap_h` pixels, row by row. A pixel never drawn is all zeros, /// which is also how it stays if the image is transparent. buf: []Pixel = &.{}, cap_w: u32 = 0, cap_h: u32 = 0, /// How far drawing has reached: one past the rightmost column and the lowest /// row that has a pixel in it. extent_w: u32 = 0, extent_h: u32 = 0, /// Whether anything was dropped for falling outside the limits. clipped: bool = false, pub fn init(gpa: Allocator, limits: Limits) Canvas { return .{ .gpa = gpa, .limits = limits }; } pub fn deinit(self: *Canvas) void { self.gpa.free(self.buf); self.* = undefined; } /// Makes room for `w` by `h` pixels up front, when raster attributes say how /// large the image will be. A size beyond the limits reserves nothing; the /// canvas grows as the drawing needs instead. pub fn reserve(self: *Canvas, w: u32, h: u32) Allocator.Error!void { if (w > self.limits.max_width or h > self.limits.max_height) return; _ = try self.ensure(w, h); } /// Draws a sixel: the six pixels from row `y` down whose bits are set in /// `bits`, least significant at the top, repeated across `n` columns from /// `x`. pub fn draw(self: *Canvas, x: u32, y: u32, bits: u6, n: u32, color: Rgb) Allocator.Error!void { if (bits == 0 or n == 0) return; const lim = self.limits; if (x >= lim.max_width) { self.clipped = true; return; } const top = y +| @ctz(bits); if (top >= lim.max_height) { self.clipped = true; return; } var count = n; if (count > lim.max_width - x) { count = lim.max_width - x; self.clipped = true; } var bottom = y +| (5 - @as(u32, @clz(bits))); if (bottom >= lim.max_height) { bottom = lim.max_height - 1; self.clipped = true; } if (!try self.ensure(x + count, bottom + 1)) { self.clipped = true; return; } const px: Pixel = .{ .r = color.r, .g = color.g, .b = color.b, .a = 255 }; var row = top; while (row <= bottom) : (row += 1) { if (bits & (@as(u6, 1) << @intCast(row - y)) == 0) continue; const start = @as(usize, row) * self.cap_w + x; @memset(self.buf[start..][0..count], px); } self.extent_w = @max(self.extent_w, x + count); self.extent_h = @max(self.extent_h, bottom + 1); } /// Grows the canvas to hold at least `w` by `h` pixels. Returns false, having /// changed nothing, if that would exceed `max_pixels`. fn ensure(self: *Canvas, w: u32, h: u32) Allocator.Error!bool { if (w <= self.cap_w and h <= self.cap_h) return true; const lim = self.limits; const need_w = @max(self.cap_w, w); const need_h = @max(self.cap_h, h); if (@as(u64, need_w) * need_h > lim.max_pixels) return false; // Doubling, so that an image drawn a band at a time costs a logarithmic // number of copies rather than one per band, but only in the direction // that needs it and never past the limits. var new_w = need_w; var new_h = need_h; if (w > self.cap_w) new_w = @max(need_w, @min(self.cap_w *| 2, lim.max_width)); if (h > self.cap_h) new_h = @max(need_h, @min(self.cap_h *| 2, lim.max_height)); if (@as(u64, new_w) * new_h > lim.max_pixels) { new_w = need_w; new_h = need_h; } const buf = try self.gpa.alloc(Pixel, @as(usize, new_w) * new_h); @memset(buf, std.mem.zeroes(Pixel)); for (0..self.cap_h) |row| { @memcpy(buf[row * new_w ..][0..self.cap_w], self.buf[row * self.cap_w ..][0..self.cap_w]); } self.gpa.free(self.buf); self.buf = buf; self.cap_w = new_w; self.cap_h = new_h; return true; } /// The finished image as a surface the caller owns, at least `min_w` by /// `min_h` (the raster attributes' size, as far as the limits allow) and /// otherwise as large as what was drawn. Pixels never drawn are filled with /// `background`, or left transparent if it is null. Returns null for an /// image with no pixels at all, which z2d cannot represent. pub fn finish(self: *Canvas, min_w: u32, min_h: u32, background: ?Rgb) Allocator.Error!?z2d.Surface { const lim = self.limits; var w = @max(self.extent_w, min_w); var h = @max(self.extent_h, min_h); if (w > lim.max_width or h > lim.max_height or @as(u64, w) * h > lim.max_pixels) { // The raster attributes asked for more than is allowed. What was // drawn always fits, so fall back to that. w = self.extent_w; h = self.extent_h; self.clipped = true; } if (w == 0 or h == 0) return null; const out = try self.gpa.alloc(Pixel, @as(usize, w) * h); @memset(out, std.mem.zeroes(Pixel)); const copy_w = @min(w, self.cap_w); for (0..@min(h, self.cap_h)) |row| { @memcpy(out[row * w ..][0..copy_w], self.buf[row * self.cap_w ..][0..copy_w]); } if (background) |bg| { const fill: Pixel = .{ .r = bg.r, .g = bg.g, .b = bg.b, .a = 255 }; for (out) |*p| { if (p.a == 0) p.* = fill; } } return .{ .image_surface_rgba = .{ .width = @intCast(w), .height = @intCast(h), .buf = out, } }; } const testing = std.testing; const red: Rgb = .{ .r = 255, .g = 0, .b = 0 }; fn pixelAt(s: z2d.Surface, x: i32, y: i32) Pixel { return Pixel.fromPixel(s.getPixel(x, y).?); } test "drawing grows the canvas and finish cuts it to what was drawn" { var c = init(testing.allocator, .{}); defer c.deinit(); try c.draw(0, 0, 0b000001, 1, red); try c.draw(9, 6, 0b100000, 3, red); var s = (try c.finish(0, 0, null)).?; defer s.deinit(testing.allocator); try testing.expectEqual(@as(i32, 12), s.getWidth()); try testing.expectEqual(@as(i32, 12), s.getHeight()); try testing.expectEqual(@as(u8, 255), pixelAt(s, 0, 0).r); try testing.expectEqual(@as(u8, 0), pixelAt(s, 1, 0).a); try testing.expectEqual(@as(u8, 255), pixelAt(s, 11, 11).a); try testing.expect(!c.clipped); } test "raster size is a minimum and the background fills it" { var c = init(testing.allocator, .{}); defer c.deinit(); try c.reserve(4, 3); try c.draw(0, 0, 0b1, 1, red); var s = (try c.finish(4, 3, .{ .r = 0, .g = 0, .b = 9 })).?; defer s.deinit(testing.allocator); try testing.expectEqual(@as(i32, 4), s.getWidth()); try testing.expectEqual(@as(i32, 3), s.getHeight()); try testing.expectEqual(@as(u8, 9), pixelAt(s, 3, 2).b); try testing.expectEqual(@as(u8, 255), pixelAt(s, 3, 2).a); } test "drawing beyond the limits is clipped, not allocated" { var c = init(testing.allocator, .{ .max_width = 10, .max_height = 10, .max_pixels = 60 }); defer c.deinit(); try c.draw(0, 0, 0b111111, std.math.maxInt(u32), red); try testing.expect(c.clipped); // Below the bottom, right of the right edge, and past the pixel budget. try c.draw(0, std.math.maxInt(u32) - 2, 0b111111, 1, red); try c.draw(20, 0, 0b111111, 1, red); try c.draw(0, 6, 0b111111, 1, red); var s = (try c.finish(1000, 1000, null)).?; defer s.deinit(testing.allocator); try testing.expectEqual(@as(i32, 10), s.getWidth()); try testing.expectEqual(@as(i32, 6), s.getHeight()); try testing.expectEqual(@as(u8, 255), pixelAt(s, 9, 5).a); } test "an empty canvas has no surface" { var c = init(testing.allocator, .{}); defer c.deinit(); try c.draw(0, 0, 0, 100, red); try testing.expect((try c.finish(0, 0, null)) == null); } test "growth falls back to the exact size near the pixel budget" { // Doubling a 6-wide canvas to 12 would need 12 * 6 = 72 pixels, over the // budget of 60; growing to exactly 10 fits. var c = init(testing.allocator, .{ .max_width = 100, .max_height = 100, .max_pixels = 60 }); defer c.deinit(); try c.draw(0, 0, 0b111111, 6, red); try c.draw(6, 0, 0b111111, 4, red); try testing.expectEqual(@as(u32, 10), c.cap_w); try testing.expect(!c.clipped); }