// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Recognizing the device control string that starts a sixel image. //! //! The introducer is //! //! DCS P1 ; P2 ; P3 q //! //! where DCS is either the 8-bit C1 control `0x90` or the 7-bit `ESC P`, the //! three parameters are optional decimal numbers, and `q` is the final //! character that says the string is sixel data. Many other things are DCS //! too: DECRQSS is `ESC P $ q`, XTGETTCAP is `ESC P + q`, tmux wraps whole //! sequences in `ESC P tmux;`. What makes a DCS a sixel is that nothing but //! digits and semicolons comes between the `P` and the `q`; anything else //! means it is somebody else's and should be left alone. const std = @import("std"); pub const esc = 0x1b; /// The 8-bit form of DCS. pub const dcs_c1 = 0x90; /// The 8-bit form of ST. pub const st_c1 = 0x9c; /// The longest introducer this recognizes. Real ones are at most a dozen /// bytes; anything longer is treated as not being sixel, which bounds how far /// anything ever has to look ahead to decide. pub const max_len = 64; /// The pixel aspect ratio, vertical to horizontal: a pixel `numerator` units /// tall for every `denominator` wide. /// /// Modern terminals draw sixel pixels square whatever this says, and so does /// this library: it is reported, not applied. pub const Aspect = struct { numerator: u32 = 2, denominator: u32 = 1, }; /// What the introducer's three parameters say. pub const Params = struct { /// From `P1`, the "macro parameter". Raster attributes in the data /// override it. aspect: Aspect = .{}, /// From `P2`: whether pixels the data never draws stay as they were /// (`1`) or are filled with the background color (`0` or `2`). A decoder /// with nothing underneath the image makes the first transparent. transparent: bool = false, /// `P3`, the horizontal grid size, which every terminal ignores. grid: u32 = 0, pub fn fromP1(p1: ?u32) Aspect { // The table in the VT330/VT340 Programmer Reference Manual, Volume 2, // chapter 14. Values outside it are taken as the default. const n: u32 = switch (p1 orelse 0) { 2 => 5, 3, 4 => 3, 7, 8, 9 => 1, else => 2, }; return .{ .numerator = n, .denominator = 1 }; } }; pub const Match = union(enum) { /// A sixel introducer, `len` bytes long. sixel: struct { len: usize, params: Params }, /// Something else: an escape sequence, or a DCS that is not sixel. not_sixel, /// The bytes so far could still be either; more are needed to decide. need_more, }; /// Decides whether `bytes`, which start with ESC or `0x90`, begin a sixel /// introducer. `0x90` counts only when `c1` is set, since in UTF-8 text that /// byte is a continuation byte and not a control. pub fn match(bytes: []const u8, c1: bool) Match { std.debug.assert(bytes.len > 0); var i: usize = switch (bytes[0]) { esc => blk: { if (bytes.len < 2) return .need_more; if (bytes[1] != 'P') return .not_sixel; break :blk 2; }, dcs_c1 => if (c1) 1 else return .not_sixel, else => return .not_sixel, }; var values: [3]?u32 = .{ null, null, null }; var n: usize = 0; while (i < bytes.len) : (i += 1) { if (i >= max_len) return .not_sixel; switch (bytes[i]) { '0'...'9' => |d| if (n < values.len) { values[n] = (values[n] orelse 0) *| 10 +| (d - '0'); }, ';' => n +|= 1, 'q' => return .{ .sixel = .{ .len = i + 1, .params = .{ .aspect = Params.fromP1(values[0]), .transparent = (values[1] orelse 0) == 1, .grid = values[2] orelse 0, } } }, else => return .not_sixel, } } return if (i >= max_len) .not_sixel else .need_more; } /// Whether `b` could start an introducer: where to stop when scanning text. pub fn isStart(b: u8, c1: bool) bool { return b == esc or (c1 and b == dcs_c1); } const testing = std.testing; fn expectSixel(bytes: []const u8, c1: bool, len: usize, params: Params) !void { const m = match(bytes, c1); try testing.expect(m == .sixel); try testing.expectEqual(len, m.sixel.len); try testing.expectEqual(params, m.sixel.params); } test "sixel introducers" { try expectSixel("\x1bPq", false, 3, .{}); try expectSixel("\x1bPq#0", false, 3, .{}); try expectSixel("\x1bP0;1;0q", false, 8, .{ .transparent = true }); try expectSixel("\x1bP9;2q", false, 6, .{ .aspect = .{ .numerator = 1 } }); try expectSixel("\x1bP2q", false, 4, .{ .aspect = .{ .numerator = 5 } }); try expectSixel("\x1bP;1q", false, 5, .{ .transparent = true }); try expectSixel("\x1bP0;0;8q", false, 8, .{ .grid = 8 }); try expectSixel("\x90q", true, 2, .{}); // Numbers too large to mean anything saturate rather than overflow. try expectSixel("\x1bP99999999999999999999;1q", false, 25, .{ .transparent = true }); // A fourth parameter is not in the format, but costs nothing to allow. try expectSixel("\x1bP0;1;0;7q", false, 10, .{ .transparent = true }); } test "other sequences are not sixel" { try testing.expectEqual(Match.not_sixel, match("\x1b[0m", false)); try testing.expectEqual(Match.not_sixel, match("\x1b\\", false)); try testing.expectEqual(Match.not_sixel, match("\x1bP$q\"p\x1b\\", false)); try testing.expectEqual(Match.not_sixel, match("\x1bP+q544e\x1b\\", false)); try testing.expectEqual(Match.not_sixel, match("\x1bPtmux;\x1b\x1bPq", false)); try testing.expectEqual(Match.not_sixel, match("\x1bP1$r", false)); try testing.expectEqual(Match.not_sixel, match("\x90q", false)); const ones: [70]u8 = @splat('1'); try testing.expectEqual(Match.not_sixel, match("\x1bP" ++ ones ++ "q", false)); } test "a prefix needs more" { try testing.expectEqual(Match.need_more, match("\x1b", false)); try testing.expectEqual(Match.need_more, match("\x1bP", false)); try testing.expectEqual(Match.need_more, match("\x1bP0;1", false)); try testing.expectEqual(Match.need_more, match("\x90", true)); }