A sans-I/O Zig library that reads and writes DEC sixel graphics as z2d surfaces.
  • Zig 89.1%
  • Nix 6.5%
  • Python 4.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 408d60292f
All checks were successful
test / test (push) Successful in 28m53s
test / docs (push) Successful in 10m15s
Build on z2d 95e6d0d
The head of z2d's zig-0.17 branch, which is z2d 0.13.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0173ewUqtFEMzmrLPA3vDtgg
2026-10-03 20:52:12 -05:00
.forgejo/workflows A sans-I/O sixel reader and writer for z2d surfaces 2026-10-03 17:16:04 -05:00
LICENSES A sans-I/O sixel reader and writer for z2d surfaces 2026-10-03 17:16:04 -05:00
src Test what coverage showed the tests missing, and fuzz harder 2026-10-03 17:32:02 -05:00
tests Test what coverage showed the tests missing, and fuzz harder 2026-10-03 17:32:02 -05:00
tools A sans-I/O sixel reader and writer for z2d surfaces 2026-10-03 17:16:04 -05:00
.gitignore A sans-I/O sixel reader and writer for z2d surfaces 2026-10-03 17:16:04 -05:00
build.zig Test what coverage showed the tests missing, and fuzz harder 2026-10-03 17:32:02 -05:00
build.zig.zon Build on z2d 95e6d0d 2026-10-03 20:52:12 -05:00
build.zig.zon.nix Build on z2d 95e6d0d 2026-10-03 20:52:12 -05:00
flake.lock A sans-I/O sixel reader and writer for z2d surfaces 2026-10-03 17:16:04 -05:00
flake.nix A sans-I/O sixel reader and writer for z2d surfaces 2026-10-03 17:16:04 -05:00
package.nix A sans-I/O sixel reader and writer for z2d surfaces 2026-10-03 17:16:04 -05:00
README.md Test what coverage showed the tests missing, and fuzz harder 2026-10-03 17:32:02 -05:00
REUSE.toml A sans-I/O sixel reader and writer for z2d surfaces 2026-10-03 17:16:04 -05:00

zig-sixel

A sans-I/O Zig library for reading and writing DEC sixel graphics as z2d surfaces.

API documentation, generated from the doc comments, is at https://jeff.jcollie.page/zig-sixel/.

Sixel is the bitmap format of DEC's VT240 and VT340 terminals and their printers. An image travels as printable characters inside a device control string, and each character draws six pixels in a column. xterm, foot, WezTerm, mlterm, Windows Terminal and others still display it.

The library has three entry points:

  • relay copies a std.Io.Reader to a std.Io.Writer. When it meets a sixel image it decodes the image and hands it to your handler as a z2d surface instead of writing it. Everything else passes through untouched.
  • decode turns sixel data already in memory into a surface.
  • encode, encodeAlloc and encodeBuffer write a surface as sixel to a writer, to an allocated slice, or into a buffer you supply.

The library never opens, reads or writes anything itself, and takes no Io. Under both read paths is Parser, a state machine you feed bytes in pieces of any size.

Requires Zig 0.17.0.

Where this lives

The repository is in three places, all with the same history:

Any one of them is the whole project.

Using it

zig fetch --save git+https://git.jcollie.dev/jeff/zig-sixel.git
const sixel = b.dependency("sixel", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("sixel", sixel.module("sixel"));

Picking images out of a stream

const Handler = struct {
    gpa: std.mem.Allocator,

    pub fn image(self: *Handler, img: sixel.Image) !void {
        var owned = img;
        defer owned.deinit(self.gpa);
        // owned.surface is a z2d.Surface of owned.width() x owned.height()
    }
};

var handler: Handler = .{ .gpa = gpa };
try sixel.relay(gpa, &stdin.interface, &stdout.interface, &handler, .{});

relay keeps latency low. It writes text as soon as it has read it and flushes the writer before every read that might block. It only holds back an escape sequence that has started to arrive but isn't yet long enough to tell whether it starts an image.

An image starts at ESC P, then optional digits and semicolons, then q. Other device control strings pass through untouched: DECRQSS (ESC P $ q), XTGETTCAP (ESC P + q) and tmux passthrough are all left alone.

An image ends in one of four ways, reported in Image.termination:

Termination How the image ended What happens to the terminator
.st ST (ESC \) Consumed
.aborted CAN or SUB Consumed, since they abandon the string
.interrupted Any other escape sequence Passed through
.end_of_data The input ran out Nothing to pass through

The 8-bit C1 forms of DCS and ST (0x90 and 0x9C) are recognized only with .c1 = true, because in UTF-8 text those bytes are continuation bytes.

Decoding a slice

var img = try sixel.decode(gpa, bytes, .{});
defer img.deinit(gpa);

The slice can be a whole control string or just the data after the q.

Encoding

try sixel.encode(gpa, writer, &surface, .{});
const bytes = try sixel.encodeAlloc(gpa, &surface, .{});
const used = try sixel.encodeBuffer(gpa, &buffer, &surface, .{});
  • Colors. An image with at most max_colors colors (default 256) is written with exactly those colors. Each is rounded to whole percentages, because that is all sixel can express. An image with more colors is reduced by median cut and dithered with Floyd–Steinberg (.dither = .none turns dithering off). Output is deterministic.
  • Transparency. Pixels less opaque than alpha_threshold are not drawn. When there are any, the introducer asks for P2 = 1, so they stay transparent.

Behavior worth knowing

  • Limits. Limits caps the width, the height and the total pixel count of the canvas a decoder will allocate. Anything drawn outside them is dropped, as a terminal drops what falls off its screen, and the image is marked clipped. No input can make the decoder panic or allocate without bound.
  • Colors are applied when a pixel is drawn. A pixel takes the color its register holds at that moment, as xterm does. Redefining a register later doesn't recolor what was already drawn. This is how img2sixel -I fits more colors than registers into one image.
  • Background. With P2 = 1, pixels that are never drawn are transparent. Otherwise they take the color of register 0 at the end of the image.
  • Aspect ratio. The pixel aspect ratio from P1 or the raster attributes is reported in Image.aspect but not applied: pixels stay square, as in modern terminals.
  • Default registers. Registers 0–15 start with the VT340's colors. Registers 16–255 follow libsixel: a 6×6×6 color cube, then a gray ramp. Before any register is chosen, register 15 is selected, as in libsixel.
  • HLS colors. These use DEC's hue circle, which puts blue at 0°, red at 120° and green at 240°.

libsixel as the oracle

libsixel is the reference both directions are tested against:

  • Decoder. The fixtures in tests/fixtures are sixel files, some written by img2sixel in its several modes and some written by hand from the documentation. Each is stored beside the pixels sixel2png decodes it to, and the decoder must match those pixels exactly.
  • Encoder. zig build oracle writes the test pictures with this encoder, has sixel2png read them back, and compares the result with the original pictures.

The two decoders differ deliberately in a few places, and the tests document each one:

  • Late register colors. libsixel colors pixels with the final contents of each register, so it misreads its own img2sixel -I output.
  • HLS above 50% lightness. libsixel's HLS conversion is wrong there.
  • P2. libsixel ignores it.

Development

Everything is in the Nix devshell: Zig, libsixel, Pillow, reuse, kcov and zon2nix.

nix develop
zig build test               # unit, integration, fixture and round-trip tests
zig build test --fuzz        # fuzz the decoder, the relay and the encoder
zig build test --fuzz=1M     # a bounded fuzzing run
zig build oracle             # check the encoder's output with libsixel
zig build coverage           # line coverage of src/ with kcov, in zig-out/coverage
zig build docs-serve         # read the API documentation locally
zig build example < file     # relay stdin, describing each image found
python3 tools/oracle.py generate tests/fixtures   # regenerate the fixtures
nix build .#zig-sixel        # build and test the package

After changing build.zig.zon, regenerate build.zig.zon.nix with:

nix develop -c zon2nix --17 --nix=build.zig.zon.nix build.zig.zon

License

MIT. The project follows the REUSE specification.

References cited