Something went wrong. Try again.
A sans-I/O Zig library that reads and writes DEC sixel graphics as z2d surfaces.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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);}