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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The sixel decoder itself: a state machine fed the bytes between the//! introducer and the string terminator, in pieces of any size.//!//! It performs no I/O and keeps no reference to what it is fed, so the bytes//! can arrive from anywhere and be split anywhere, even in the middle of a//! number. `decode` and `relay` are both built on it; use it directly to feed//! a decoder from something that is not a `std.Io.Reader`.//!//! var p: Parser = .init(gpa, params, .{});//! defer p.deinit();//! const n = try p.feed(bytes); // n < bytes.len: stopped at bytes[n]//! var image = (try p.finish(.st)).?;//!//! `feed` stops, without consuming it, at any byte that ends a control//! string: ESC, CAN, SUB, and with `c1` set any C1 control. What that byte//! means — ST, an abort, the start of something else — is the caller's to//! decide, because only the caller can see what comes after it.//!//! The commands are those of the VT330/VT340 Programmer Reference Manual,//! Volume 2, chapter 14://!//! | | |//! | --- | --- |//! | `?` to `~` | a sixel: six pixels in a column, least significant bit on top |//! | `!` Pn | repeat the next sixel Pn times |//! | `"` Pan;Pad;Ph;Pv | raster attributes: pixel aspect ratio and image size |//! | `#` Pc | use color register Pc |//! | `#` Pc;Pu;Px;Py;Pz | define register Pc, in HLS (Pu = 1) or RGB (Pu = 2), and use it |//! | `$` | graphics carriage return: back to the left of this band |//! | `-` | graphics new line: to the left of the next band, six pixels down |//!//! Anything else is ignored, as the manual says it should be. Numbers too//! large to mean anything saturate rather than overflow.
const std = @import("std");const Allocator = std.mem.Allocator;const Canvas = @import("Canvas.zig");const Image = @import("Image.zig");const introducer = @import("introducer.zig");const palette = @import("palette.zig");const Rgb = palette.Rgb;
const Parser = @This();
pub const Options = struct { limits: Canvas.Limits = .{}, /// How many color registers to offer, up to `palette.max_registers`. A /// register number beyond the last is taken as the last, as libsixel /// does. registers: u16 = palette.max_registers, /// Whether 8-bit C1 controls (`0x80` to `0x9F`) end the data. Off, they /// are ignored like any other byte that is not a command, which is right /// for a stream of UTF-8, where those bytes are continuation bytes. c1: bool = false,};
const State = enum { ground, repeat, raster, color };
/// A command takes at most five numbers (`#Pc;Pu;Px;Py;Pz`); any beyond that/// are read and thrown away.const max_params = 5;
canvas: Canvas,registers: [palette.max_registers]Rgb,register_count: u16,c1: bool,
state: State = .ground,params: [max_params]u32 = @splat(0),/// The index of the number being read; one less than how many there are.param: u8 = 0,
x: u32 = 0,y: u32 = 0,repeat: u32 = 1,/// libsixel starts with register 15 selected, and so does this, so that data/// drawing before it chooses a color comes out the same in both.color: u16 = 15,
aspect: introducer.Aspect,transparent: bool,raster_w: u32 = 0,raster_h: u32 = 0,
pub fn init(gpa: Allocator, params: introducer.Params, options: Options) Parser { var p: Parser = .{ .canvas = .init(gpa, options.limits), .registers = undefined, .register_count = @max(1, @min(options.registers, palette.max_registers)), .c1 = options.c1, .aspect = params.aspect, .transparent = params.transparent, }; for (&p.registers, 0..) |*r, i| r.* = palette.defaultRegister(i); p.color = @min(p.color, p.register_count - 1); return p;}
pub fn deinit(self: *Parser) void { self.canvas.deinit(); self.* = undefined;}
/// Whether `b` ends the data. `feed` stops in front of it.pub fn isTerminator(b: u8, c1: bool) bool { return switch (b) { introducer.esc, 0x18, 0x1a => true, // ESC, CAN, SUB 0x80...0x9f => c1, else => false, };}
/// Decodes as much of `bytes` as comes before the first byte that ends the/// data, and returns how much that was. If it is less than `bytes.len`, the/// byte at that index is the one that stopped it.pub fn feed(self: *Parser, bytes: []const u8) Allocator.Error!usize { for (bytes, 0..) |b, i| { if (isTerminator(b, self.c1)) return i; try self.byte(b); } return bytes.len;}
fn byte(self: *Parser, b: u8) Allocator.Error!void { if (self.state != .ground) { switch (b) { '0'...'9' => { if (self.param < max_params) { const p = &self.params[self.param]; p.* = p.* *| 10 +| (b - '0'); } return; }, ';' => { self.param +|= 1; return; }, else => { // Anything else ends the command, and is then a command, or // a sixel, in its own right. try self.endCommand(); }, } }
switch (b) { '?'...'~' => { const n = self.repeat; self.repeat = 1; try self.canvas.draw(self.x, self.y, @intCast(b - '?'), n, self.registers[self.color]); self.x +|= n; }, '!' => self.begin(.repeat), '"' => self.begin(.raster), '#' => self.begin(.color), '$' => self.x = 0, '-' => { self.x = 0; self.y +|= 6; }, else => {}, }}
fn begin(self: *Parser, state: State) void { self.state = state; self.params = @splat(0); self.param = 0;}
fn endCommand(self: *Parser) Allocator.Error!void { const p = self.params; // How many numbers there were, counting an empty one as zero. const count = @as(usize, self.param) + 1; switch (self.state) { .ground => {}, .repeat => self.repeat = @max(p[0], 1), .raster => { // Pan;Pad;Ph;Pv. A zero in either half of the ratio is taken as // one rather than as a division by zero. if (count >= 2) self.aspect = .{ .numerator = @max(p[0], 1), .denominator = @max(p[1], 1) }; if (count >= 3) self.raster_w = p[2]; if (count >= 4) self.raster_h = p[3]; try self.canvas.reserve(self.raster_w, self.raster_h); }, .color => { const reg: u16 = @intCast(@min(p[0], self.register_count - 1)); self.color = reg; if (count >= 5) switch (p[1]) { 1 => self.registers[reg] = palette.fromHls(p[2], p[3], p[4]), 2 => self.registers[reg] = palette.fromPercent(p[2], p[3], p[4]), else => {}, }; }, } self.state = .ground;}
/// Ends the image and hands back its pixels, which the caller then owns, or/// null if it drew nothing and gave no size. The parser still has to be/// `deinit`ed afterwards.pub fn finish(self: *Parser, termination: Image.Termination) Allocator.Error!?Image { // A command still open at the end, say a color definition with no data // after it, still takes effect. try self.endCommand(); const background: ?Rgb = if (self.transparent) null else self.registers[0]; const surface = (try self.canvas.finish(self.raster_w, self.raster_h, background)) orelse return null; return .{ .surface = surface, .aspect = self.aspect, .transparent = self.transparent, .termination = termination, .clipped = self.canvas.clipped, };}
const testing = std.testing;const z2d = @import("z2d");
fn decodeBody(body: []const u8, params: introducer.Params) !?Image { var p: Parser = .init(testing.allocator, params, .{}); defer p.deinit(); try testing.expectEqual(body.len, try p.feed(body)); return p.finish(.st);}
fn rgbAt(img: Image, x: i32, y: i32) [4]u8 { const px = z2d.pixel.RGBA.fromPixel(img.surface.getPixel(x, y).?); return .{ px.r, px.g, px.b, px.a };}
test "the HI example from All About SIXELs" { var img = (try decodeBody( "#0;2;0;0;0#1;2;100;100;0#2;2;0;100;0" ++ "#1~~@@vv@@~~@@~~$" ++ "#2??}}GG}}??}}??-" ++ "#1!14@", .{}, )).?; defer img.deinit(testing.allocator); try testing.expectEqual(@as(u32, 14), img.width()); try testing.expectEqual(@as(u32, 7), img.height()); const yellow = [4]u8{ 255, 255, 0, 255 }; const green = [4]u8{ 0, 255, 0, 255 }; const picture = [_]*const [14]u8{ "..............", "..**..**..**..", "..**..**..**..", "..******..**..", "..**..**..**..", "..**..**..**..", "..............", }; for (picture, 0..) |row, y| { for (row, 0..) |c, x| { const want = if (c == '*') green else yellow; try testing.expectEqual(want, rgbAt(img, @intCast(x), @intCast(y))); } }}
test "undrawn pixels take register 0, or stay transparent" { { var img = (try decodeBody("#0;2;0;0;100\"1;1;3;2#1@", .{})).?; defer img.deinit(testing.allocator); try testing.expectEqual(@as(u32, 3), img.width()); try testing.expectEqual(@as(u32, 2), img.height()); try testing.expectEqual([4]u8{ 0, 0, 255, 255 }, rgbAt(img, 2, 1)); try testing.expectEqual([4]u8{ 51, 51, 204, 255 }, rgbAt(img, 0, 0)); } { var img = (try decodeBody("\"1;1;3;2#1@", .{ .transparent = true })).?; defer img.deinit(testing.allocator); try testing.expect(img.transparent); try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, rgbAt(img, 2, 1)); }}
test "repeat, carriage return and HLS" { var img = (try decodeBody("#5;1;120;50;100!3~$#6;2;0;0;100@-!0B", .{})).?; defer img.deinit(testing.allocator); try testing.expectEqual(@as(u32, 3), img.width()); try testing.expectEqual(@as(u32, 8), img.height()); try testing.expectEqual([4]u8{ 0, 0, 255, 255 }, rgbAt(img, 0, 0)); try testing.expectEqual([4]u8{ 255, 0, 0, 255 }, rgbAt(img, 1, 0)); try testing.expectEqual([4]u8{ 255, 0, 0, 255 }, rgbAt(img, 2, 5)); // A repeat count of zero means one. try testing.expectEqual([4]u8{ 0, 0, 255, 255 }, rgbAt(img, 0, 7));}
test "raster attributes give the aspect ratio" { var img = (try decodeBody("\"3;2;1;1", .{})).?; defer img.deinit(testing.allocator); try testing.expectEqual(introducer.Aspect{ .numerator = 3, .denominator = 2 }, img.aspect);}
test "nothing drawn is no image" { try testing.expect((try decodeBody("#1;2;3;4;5???-", .{})) == null);}
test "feed stops at a terminator and can be split anywhere" { const data = "#1;2;100;0;0!12~-#2~"; var whole = (try decodeBody(data, .{})).?; defer whole.deinit(testing.allocator);
for (0..data.len + 1) |split| { var p: Parser = .init(testing.allocator, .{}, .{}); defer p.deinit(); _ = try p.feed(data[0..split]); _ = try p.feed(data[split..]); var img = (try p.finish(.st)).?; defer img.deinit(testing.allocator); try testing.expectEqualSlices( z2d.pixel.RGBA, whole.surface.image_surface_rgba.buf, img.surface.image_surface_rgba.buf, ); }
var p: Parser = .init(testing.allocator, .{}, .{}); defer p.deinit(); try testing.expectEqual(@as(usize, 2), try p.feed("~~\x1b\\")); try testing.expectEqual(@as(usize, 1), try p.feed("~\x18")); try testing.expectEqual(@as(usize, 3), try p.feed("~\x9c~")); p.c1 = true; try testing.expectEqual(@as(usize, 1), try p.feed("~\x9c~"));}
test "register numbers are clamped" { var p: Parser = .init(testing.allocator, .{}, .{ .registers = 4 }); defer p.deinit(); _ = try p.feed("#900;2;100;100;100~"); var img = (try p.finish(.st)).?; defer img.deinit(testing.allocator); try testing.expectEqual([4]u8{ 255, 255, 255, 255 }, rgbAt(img, 0, 0)); try testing.expect(p.registers[3].eql(.{ .r = 255, .g = 255, .b = 255 }));}