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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The decoder against libsixel's.//!//! Each fixture is a sixel file and the pixels libsixel's `sixel2png` decodes//! it to, both written by `tools/oracle.py generate`. Some of the sixel files//! were written by `img2sixel`, in its several modes; the rest were written by//! hand for what it never writes: the default registers, HLS colors,//! overstriking, and the example from "All About SIXELs". Every fixture draws//! all of its pixels, since libsixel ignores P2 and fills undrawn ones with a//! register no terminal would, and every HLS color in them is at most 50%//! lightness, above which libsixel's conversion is wrong.//!//! The match has to be exact: both decoders round percentages the same way.//!//! One file is held to the picture it was written from instead. `img2sixel//! -I` writes more colors than there are registers by redefining registers//! between bands, which works because a terminal draws in the color a//! register has when the pixel is drawn. libsixel's own decoder does not: it//! decodes to register numbers and colors them at the end, with whatever each//! register held last, so it reads its own high-color output wrongly. This//! library colors as it draws, as a terminal does.
const std = @import("std");const testing = std.testing;const sixel = @import("sixel");const z2d = @import("z2d");
const names = [_][]const u8{ "gradient", "gradient-16-nodither", "gradient-gri-limit", "blocks", "blocks-8bit", "odd", "hi", "defaults", "hls", "overstrike",};
pub const fixtures = blk: { var list: [names.len]struct { name: []const u8, six: []const u8, rgb: []const u8 } = undefined; for (names, &list) |name, *f| f.* = .{ .name = name, .six = @embedFile("fixtures/" ++ name ++ ".six"), .rgb = @embedFile("fixtures/" ++ name ++ ".rgb"), }; break :blk list;};
test "every fixture decodes as libsixel decodes it" { for (fixtures) |f| { errdefer std.debug.print("fixture {s}\n", .{f.name}); var img = try sixel.decode(testing.allocator, f.six, .{}); defer img.deinit(testing.allocator);
const w = std.mem.readInt(u32, f.rgb[0..4], .little); const h = std.mem.readInt(u32, f.rgb[4..8], .little); try testing.expectEqual(w, img.width()); try testing.expectEqual(h, img.height()); try testing.expectEqual(sixel.Image.Termination.st, img.termination); try testing.expect(!img.clipped);
const want = f.rgb[8..]; for (img.surface.image_surface_rgba.buf, 0..) |px, i| { errdefer std.debug.print("pixel {d},{d}\n", .{ i % w, i / w }); try testing.expectEqual(@as(u8, 255), px.a); try testing.expectEqual(want[i * 3 + 0], px.r); try testing.expectEqual(want[i * 3 + 1], px.g); try testing.expectEqual(want[i * 3 + 2], px.b); } }}
test "every fixture relays as it decodes" { for (fixtures) |f| { var whole = try sixel.decode(testing.allocator, f.six, .{}); defer whole.deinit(testing.allocator);
const Grab = struct { got: ?sixel.Image = null, pub fn image(self: *@This(), img: sixel.Image) !void { try testing.expect(self.got == null); self.got = img; } }; var grab: Grab = .{}; var reader: std.Io.Reader = .fixed(f.six); // blocks-8bit uses the C1 controls, and none of this is UTF-8. const options: sixel.RelayOptions = .{ .c1 = true }; var buf: [sixel.min_buffer_len]u8 = undefined; // A small buffer, so the image arrives in many pieces. var indirect: testing.ReaderIndirect = .init(&reader, &buf); var out: std.Io.Writer.Allocating = .init(testing.allocator); defer out.deinit(); try sixel.relay(testing.allocator, &indirect.interface, &out.writer, &grab, options); var got = grab.got.?; defer got.deinit(testing.allocator); // img2sixel ends with nothing after ST; the handwritten ones neither. try testing.expectEqualStrings("", out.written()); try testing.expectEqualSlices( z2d.pixel.RGBA, whole.surface.image_surface_rgba.buf, got.surface.image_surface_rgba.buf, ); }}
test "registers redefined partway through color what is drawn after" { const six = @embedFile("fixtures/gradient-high-color.six"); var img = try sixel.decode(testing.allocator, six, .{}); defer img.deinit(testing.allocator); const w = img.width(); const h = img.height(); try testing.expectEqual(@as(u32, 50), w); try testing.expectEqual(@as(u32, 20), h); // The picture tools/oracle.py drew, at 15 bits per pixel and rounded to // percentages: within eight levels a channel, and closer on average. var total: u64 = 0; for (img.surface.image_surface_rgba.buf, 0..) |px, i| { const x: u32 = @intCast(i % w); const y: u32 = @intCast(i / w); const want = [3]u32{ x * 255 / (w - 1), y * 255 / (h - 1), (x + y) * 255 / (w + h - 2) }; const got = [3]u32{ px.r, px.g, px.b }; for (want, got) |a, b| { const d = @max(a, b) - @min(a, b); try testing.expect(d <= 12); total += d; } } try testing.expect(total / (w * h * 3) <= 4);}
test "img2sixel starts a one-pixel image a band down" { // It writes `-` before the data, so the pixel lands on row 6 and the six // above it are never drawn. libsixel shows those black; with P2 = 0 they // are the background, which is register 0, the pixel's own color. const six = @embedFile("fixtures/tiny.six"); const rgb = @embedFile("fixtures/tiny.rgb"); var img = try sixel.decode(testing.allocator, six, .{}); defer img.deinit(testing.allocator); try testing.expectEqual(@as(u32, 1), img.width()); try testing.expectEqual(@as(u32, 7), img.height()); for (img.surface.image_surface_rgba.buf) |px| { try testing.expectEqual([3]u8{ rgb[8 + 18], rgb[8 + 19], rgb[8 + 20] }, [3]u8{ px.r, px.g, px.b }); }}