- Zig 89.1%
- Nix 6.5%
- Python 4.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
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:
relaycopies astd.Io.Readerto astd.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.decodeturns sixel data already in memory into a surface.encode,encodeAllocandencodeBufferwrite 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:
-
Forgejo: https://git.jcollie.dev/jeff/zig-sixel, the web-visible one.
git clone https://git.jcollie.dev/jeff/zig-sixel.git -
Tangled: a mirror at https://tangled.org/jcollie.dev/zig-sixel.
-
Radicle: repository ID
rad:z3xgR2D5mHrWsCazcxsz5W7ta3fyg. This fetches it from any node that seeds it:rad clone rad:z3xgR2D5mHrWsCazcxsz5W7ta3fyg
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_colorscolors (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 = .noneturns dithering off). Output is deterministic. - Transparency. Pixels less opaque than
alpha_thresholdare not drawn. When there are any, the introducer asks forP2 = 1, so they stay transparent.
Behavior worth knowing
- Limits.
Limitscaps 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 markedclipped. 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 -Ifits 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
P1or the raster attributes is reported inImage.aspectbut 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/fixturesare sixel files, some written byimg2sixelin its several modes and some written by hand from the documentation. Each is stored beside the pixelssixel2pngdecodes it to, and the decoder must match those pixels exactly. - Encoder.
zig build oraclewrites the test pictures with this encoder, hassixel2pngread 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 -Ioutput. - 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
- Chiesa, Chris F. "All About SIXELs." DECUS VAX SIG Symposium Tape VAX90B1, 29 September 1990. https://www.digiater.nl/openvms/decus/vax90b1/krypton-nasa/all-about-sixels.text.
- Digital Equipment Corporation. VT300/VT340 Programmer Reference Manual, Volume 2: Graphics Programming. 2nd ed., EK-VT3XX-GP-002, May 1988. Chapter 14, "Sixel Graphics."
- Heckbert, Paul. "Color Image Quantization for Frame Buffer Display." Computer Graphics (SIGGRAPH '82) 16, no. 3 (1982): 297–307.
- Saito, Hayaki. libsixel. Version 1.10.5. https://saitoha.github.io/libsixel/.
- Shuford, Richard S., ed. "Sixel Graphics News." Shuford's terminal archive. https://invisible-island.net/archives/shuford/terminal/sixel_graphics_news.txt.