# zig-sixel
A sans-I/O Zig library for reading and writing DEC sixel graphics as
[z2d](https://github.com/vancluever/z2d) surfaces.
API documentation, generated from the doc comments, is at
.
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:
- **Forgejo:** , the web-visible one.
```console
git clone https://git.jcollie.dev/jeff/zig-sixel.git
```
- **Tangled:** a mirror at .
- **Radicle:** repository ID `rad:z3xgR2D5mHrWsCazcxsz5W7ta3fyg`. This
fetches it from any node that seeds it:
```console
rad clone rad:z3xgR2D5mHrWsCazcxsz5W7ta3fyg
```
Any one of them is the whole project.
## Using it
```console
zig fetch --save git+https://git.jcollie.dev/jeff/zig-sixel.git
```
```zig
const sixel = b.dependency("sixel", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("sixel", sixel.module("sixel"));
```
### Picking images out of a stream
```zig
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
```zig
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
```zig
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](https://saitoha.github.io/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.
```console
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:
```console
nix develop -c zon2nix --17 --nix=build.zig.zon.nix build.zig.zon
```
## License
MIT. The project follows the [REUSE](https://reuse.software/) specification.
## References cited
- Chiesa, Chris F. "All About SIXELs." DECUS VAX SIG Symposium Tape VAX90B1,
29 September 1990.
.
- 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. .
- Shuford, Richard S., ed. "Sixel Graphics News." Shuford's terminal archive.
.