//! Image panes. Decoding is zstbi (C stb_image) to RGBA, downscaled; the kept //! pixels render as PETSCII glyph art directly into the Surface — or, when the //! host supports native images (and PETSCII isn't toggled), ride the Surface as //! a pixel attachment the shell transmits/places (TTY: Kitty; SDL: GPU texture). const std = @import("std"); const zstbi = @import("zstbi"); /// The 16-colour ANSI table below is the only thing this file ever wanted from /// the emulator, and a build without one still renders images — see /// `pardes.terminal_panes`. const terminal_panes = @import("pardes.zig").terminal_panes; const ghostty_vt = if (terminal_panes) @import("ghostty-vt") else struct {}; const pdf_enabled = @import("pardes_config").mupdf; pub const petscii = @import("petscii.zig"); /// the terminal's own 16 ANSI colors — what the `terminal` palette mode /// scores against; cells then paint as indexed colors so the real terminal /// resolves the RGB /// /// These are GHOSTTY's sixteen, transcribed, because on a platform that has an /// emulator the scoring used to read them straight out of it and a build /// without one has to score against the identical table or render different /// glyph art for the same photo. Note they are the base16 Tomorrow Night set, /// NOT the xterm defaults: zig-pkg/ghostty-1.3.2-dev-5UdBCzeJ.../src/terminal/ /// color.zig:389-405 (`Name.default`), which color.zig:8-15 copies into the /// first sixteen entries of `color.default`. The comptime block below makes /// the emulator prove that, so the transcription cannot rot in silence. const ansi_default = [16][3]u8{ .{ 0x1D, 0x1F, 0x21 }, // black .{ 0xCC, 0x66, 0x66 }, // red .{ 0xB5, 0xBD, 0x68 }, // green .{ 0xF0, 0xC6, 0x74 }, // yellow .{ 0x81, 0xA2, 0xBE }, // blue .{ 0xB2, 0x94, 0xBB }, // magenta .{ 0x8A, 0xBE, 0xB7 }, // cyan .{ 0xC5, 0xC8, 0xC6 }, // white .{ 0x66, 0x66, 0x66 }, // bright black .{ 0xD5, 0x4E, 0x53 }, // bright red .{ 0xB9, 0xCA, 0x4A }, // bright green .{ 0xE7, 0xC5, 0x47 }, // bright yellow .{ 0x7A, 0xA6, 0xDA }, // bright blue .{ 0xC3, 0x97, 0xD8 }, // bright magenta .{ 0x70, 0xC0, 0xB1 }, // bright cyan .{ 0xEA, 0xEA, 0xEA }, // bright white }; comptime { if (terminal_panes) for (ansi_default, 0..) |rgb, i| { const c = ghostty_vt.color.default[i]; if (rgb[0] != c.r or rgb[1] != c.g or rgb[2] != c.b) @compileError( "src/image.zig ansi_default has drifted from ghostty's color.default", ); }; } pub fn ansiPalette() [16][3]u8 { return ansi_default; } /// cap the longest side before keeping/transmitting: a pane is at most a /// screenful of cells, multi-thousand-pixel photos waste decode/transmit time const MAX_DIM: u32 = 1280; pub const PaletteMode = enum { commodore, terminal }; /// How a native pixel attachment is placed in its pane body. `contain` is the /// historical image-pane policy: shrink only when necessary and keep the /// image at the top left. The axis-specific modes may enlarge, letterbox the /// unconstrained axis, or crop it when it overflows. pub const NativeFit = if (pdf_enabled) enum { contain, width, height } else enum { contain }; pub const PixelRect = struct { x: u32 = 0, y: u32 = 0, w: u32 = 0, h: u32 = 0, }; pub const PixelPoint = struct { x: u32, y: u32 }; /// Source pixels and their destination inside a pane body's pixel rectangle. /// Both rectangles are half-open. Rounding is always toward the inside, so a /// backend can draw this result without a backend-specific overflow clip. pub const NativeGeometry = struct { src: PixelRect, dst: PixelRect, /// Map a destination pixel back into the retained source crop. This is the /// same integer transform the texture rectangle describes; points in a /// letterbox return null. pub fn sourcePoint(self: NativeGeometry, x: u32, y: u32) ?PixelPoint { if (self.src.w == 0 or self.src.h == 0 or self.dst.w == 0 or self.dst.h == 0) return null; if (x < self.dst.x or y < self.dst.y) return null; const dx = x - self.dst.x; const dy = y - self.dst.y; if (dx >= self.dst.w or dy >= self.dst.h) return null; return .{ .x = self.src.x + @as(u32, @intCast(@as(u64, dx) * self.src.w / self.dst.w)), .y = self.src.y + @as(u32, @intCast(@as(u64, dy) * self.src.h / self.dst.h)), }; } }; fn panOffset(overflow: u32, pan: u16) u32 { if (overflow == 0 or pan == 0) return 0; if (pan == std.math.maxInt(u16)) return overflow; // Round, rather than truncate, so the midpoint is the visual midpoint. return @intCast((@as(u64, overflow) * pan + std.math.maxInt(u16) / 2) / std.math.maxInt(u16)); } fn fitWidth(src_w: u32, src_h: u32, body_w: u32, body_h: u32, pan_y: u16) ?NativeGeometry { const scaled_h = @as(u64, src_h) * body_w; if (scaled_h <= @as(u64, body_h) * src_w) { const full_h: u32 = @intCast(@max(1, scaled_h / src_w)); return .{ .src = .{ .w = src_w, .h = src_h }, .dst = .{ .y = (body_h - full_h) / 2, .w = body_w, .h = full_h }, }; } // Retain the largest whole source-pixel crop which cannot extend beyond // the body at this scale. The sub-pixel remainder becomes at most a small // centered letterbox instead of leaking into an adjacent cell in Kitty. const crop_h_wide = @as(u64, body_h) * src_w / body_w; if (crop_h_wide == 0) return null; const crop_h: u32 = @min(src_h, @as(u32, @intCast(crop_h_wide))); const dst_h: u32 = @min(body_h, @max(1, @as(u32, @intCast(@as(u64, crop_h) * body_w / src_w)))); return .{ .src = .{ .y = panOffset(src_h - crop_h, pan_y), .w = src_w, .h = crop_h, }, .dst = .{ .y = (body_h - dst_h) / 2, .w = body_w, .h = dst_h }, }; } fn fitHeight(src_w: u32, src_h: u32, body_w: u32, body_h: u32, pan_x: u16) ?NativeGeometry { const scaled_w = @as(u64, src_w) * body_h; if (scaled_w <= @as(u64, body_w) * src_h) { const full_w: u32 = @intCast(@max(1, scaled_w / src_h)); return .{ .src = .{ .w = src_w, .h = src_h }, .dst = .{ .x = (body_w - full_w) / 2, .w = full_w, .h = body_h }, }; } const crop_w_wide = @as(u64, body_w) * src_h / body_h; if (crop_w_wide == 0) return null; const crop_w: u32 = @min(src_w, @as(u32, @intCast(crop_w_wide))); const dst_w: u32 = @min(body_w, @max(1, @as(u32, @intCast(@as(u64, crop_w) * body_h / src_h)))); return .{ .src = .{ .x = panOffset(src_w - crop_w, pan_x), .w = crop_w, .h = src_h, }, .dst = .{ .x = (body_w - dst_w) / 2, .w = dst_w, .h = body_h }, }; } /// Compute native image placement without floating point. Source dimensions /// are limited to u32 because SDL's texture API and Kitty's crop parameters /// cannot describe anything larger. Kitty applies the stricter u16 check at /// its protocol boundary. pub fn nativeGeometry( iw: usize, ih: usize, body_pixel_w: u32, body_pixel_h: u32, fit: NativeFit, pan_x: u16, pan_y: u16, ) ?NativeGeometry { if (comptime !pdf_enabled) { return containGeometry(iw, ih, body_pixel_w, body_pixel_h); } if (iw == 0 or ih == 0 or body_pixel_w == 0 or body_pixel_h == 0 or iw > std.math.maxInt(u32) or ih > std.math.maxInt(u32)) return null; const src_w: u32 = @intCast(iw); const src_h: u32 = @intCast(ih); return switch (fit) { .width => fitWidth(src_w, src_h, body_pixel_w, body_pixel_h, pan_y), .height => fitHeight(src_w, src_h, body_pixel_w, body_pixel_h, pan_x), .contain => blk: { if (src_w <= body_pixel_w and src_h <= body_pixel_h) break :blk .{ .src = .{ .w = src_w, .h = src_h }, .dst = .{ .w = src_w, .h = src_h }, }; // Compare scale ratios without floating point. The tighter bound // is exact; the other is rounded inward. Contain intentionally // ignores pan and keeps the historical top-left placement. const dst: PixelRect = if (@as(u64, body_pixel_w) * src_h <= @as(u64, body_pixel_h) * src_w) .{ .w = body_pixel_w, .h = @intCast(@max(1, @as(u64, src_h) * body_pixel_w / src_w)), } else .{ .w = @intCast(@max(1, @as(u64, src_w) * body_pixel_h / src_h)), .h = body_pixel_h, }; break :blk .{ .src = .{ .w = src_w, .h = src_h }, .dst = dst }; }, }; } /// The ordinary image-pane policy, separate from the PDF-only fit/pan /// machinery so feature-off backends retain their original contain-only path. pub fn containGeometry(iw: usize, ih: usize, max_w: u32, max_h: u32) ?NativeGeometry { if (iw == 0 or ih == 0 or max_w == 0 or max_h == 0 or iw > std.math.maxInt(u32) or ih > std.math.maxInt(u32)) return null; const src_w: u32 = @intCast(iw); const src_h: u32 = @intCast(ih); if (src_w <= max_w and src_h <= max_h) return .{ .src = .{ .w = src_w, .h = src_h }, .dst = .{ .w = src_w, .h = src_h }, }; const dst: PixelRect = if (@as(u64, max_w) * src_h <= @as(u64, max_h) * src_w) .{ .w = max_w, .h = @intCast(@max(1, @as(u64, src_h) * max_w / src_w)), } else .{ .w = @intCast(@max(1, @as(u64, src_w) * max_h / src_h)), .h = max_h, }; return .{ .src = .{ .w = src_w, .h = src_h }, .dst = dst }; } /// Compatibility helper for callers which need only the old contain size. pub const Fit = struct { w: u32, h: u32 }; pub fn contain(iw: usize, ih: usize, max_w: u32, max_h: u32) Fit { const geometry = containGeometry(iw, ih, max_w, max_h) orelse return .{ .w = 0, .h = 0 }; return .{ .w = geometry.dst.w, .h = geometry.dst.h }; } test "native image contain keeps aspect, bounds, top-left, and small-image size" { try std.testing.expectEqual(Fit{ .w = 40, .h = 20 }, contain(400, 200, 40, 40)); try std.testing.expectEqual(Fit{ .w = 20, .h = 40 }, contain(200, 400, 40, 40)); try std.testing.expectEqual(Fit{ .w = 17, .h = 9 }, contain(17, 9, 40, 40)); try std.testing.expectEqual(Fit{ .w = 0, .h = 0 }, contain(17, 9, 0, 40)); const odd = containGeometry(403, 211, 101, 47).?; try std.testing.expectEqual(PixelRect{ .w = 89, .h = 47 }, odd.dst); try std.testing.expectEqual(PixelRect{ .w = 403, .h = 211 }, odd.src); } test "native fit width handles portrait crop, pan extrema, and landscape letterbox" { if (comptime !pdf_enabled) return; const top = nativeGeometry(600, 1000, 800, 600, .width, 0, 0).?; try std.testing.expectEqual(PixelRect{ .w = 600, .h = 450 }, top.src); try std.testing.expectEqual(PixelRect{ .w = 800, .h = 600 }, top.dst); const middle = nativeGeometry(600, 1000, 800, 600, .width, 0, 32768).?; try std.testing.expectEqual(@as(u32, 275), middle.src.y); const bottom = nativeGeometry(600, 1000, 800, 600, .width, 65535, 65535).?; try std.testing.expectEqual(@as(u32, 550), bottom.src.y); const landscape = nativeGeometry(1000, 600, 800, 600, .width, 0, 65535).?; try std.testing.expectEqual(PixelRect{ .w = 1000, .h = 600 }, landscape.src); try std.testing.expectEqual(PixelRect{ .y = 60, .w = 800, .h = 480 }, landscape.dst); } test "native fit height handles portrait letterbox and landscape crop" { if (comptime !pdf_enabled) return; const portrait = nativeGeometry(600, 1000, 800, 600, .height, 65535, 0).?; try std.testing.expectEqual(PixelRect{ .w = 600, .h = 1000 }, portrait.src); try std.testing.expectEqual(PixelRect{ .x = 220, .w = 360, .h = 600 }, portrait.dst); const left = nativeGeometry(1000, 600, 800, 600, .height, 0, 0).?; try std.testing.expectEqual(PixelRect{ .w = 800, .h = 600 }, left.src); try std.testing.expectEqual(PixelRect{ .w = 800, .h = 600 }, left.dst); const right = nativeGeometry(1000, 600, 800, 600, .height, 65535, 0).?; try std.testing.expectEqual(@as(u32, 200), right.src.x); } test "native fit odd ratios round inward and map points through letterboxes and crops" { if (comptime !pdf_enabled) return; const width = nativeGeometry(403, 211, 101, 47, .width, 0, 32768).?; try std.testing.expectEqual(PixelRect{ .y = 12, .w = 403, .h = 187 }, width.src); try std.testing.expectEqual(PixelRect{ .w = 101, .h = 46 }, width.dst); const mapped = width.sourcePoint(100, 45).?; try std.testing.expect(mapped.x < width.src.x + width.src.w); try std.testing.expect(mapped.y < width.src.y + width.src.h); try std.testing.expect(width.sourcePoint(101, 46) == null); const height = nativeGeometry(403, 211, 101, 47, .height, 32768, 0).?; try std.testing.expectEqual(PixelRect{ .w = 403, .h = 211 }, height.src); try std.testing.expectEqual(PixelRect{ .x = 6, .w = 89, .h = 47 }, height.dst); try std.testing.expect(height.sourcePoint(5, 20) == null); try std.testing.expect(height.sourcePoint(6, 20) != null); } test "native geometry rejects dimensions which its backend contract cannot represent" { try std.testing.expect(containGeometry(0, 10, 20, 20) == null); if (@bitSizeOf(usize) > 32) try std.testing.expect(containGeometry(@as(usize, std.math.maxInt(u32)) + 1, 10, 20, 20) == null); if (comptime pdf_enabled) { try std.testing.expect(nativeGeometry(10, 10, 0, 20, .width, 0, 0) == null); if (@bitSizeOf(usize) > 32) try std.testing.expect(nativeGeometry(@as(usize, std.math.maxInt(u32)) + 1, 10, 20, 20, .height, 0, 0) == null); } } test "native geometry is total at extreme accepted aspect ratios" { if (comptime !pdf_enabled) return; const largest = std.math.maxInt(u32); try std.testing.expect(nativeGeometry(1, largest, largest, 1, .width, 0, 65535) == null); try std.testing.expect(nativeGeometry(largest, 1, 1, largest, .height, 65535, 0) == null); const wide = nativeGeometry(largest, 1, largest, 1, .width, 0, 0).?; try std.testing.expectEqual(PixelRect{ .w = largest, .h = 1 }, wide.dst); const tall = nativeGeometry(1, largest, 1, largest, .height, 0, 0).?; try std.testing.expectEqual(PixelRect{ .w = 1, .h = largest }, tall.dst); } /// call once at startup / exit (stb_image's allocator shim) pub fn start(io: std.Io, gpa: std.mem.Allocator) void { zstbi.init(io, gpa); } pub fn stop() void { zstbi.deinit(); } /// decode + downscale to RGBA, gpa-owned. Returns null on any failure — the /// pane then simply shows a blank body. pub fn decode(gpa: std.mem.Allocator, bytes: []const u8) ?struct { rgba: []u8, w: usize, h: usize } { if (bytes.len == 0) return null; var img = zstbi.Image.loadFromMemory(bytes, 4) catch return null; // 4 = RGBA defer img.deinit(); var scaled: ?zstbi.Image = null; defer if (scaled) |*s| s.deinit(); const longest = @max(img.width, img.height); const src: *const zstbi.Image = if (longest > MAX_DIM) blk: { const nw = @max(1, img.width * MAX_DIM / longest); const nh = @max(1, img.height * MAX_DIM / longest); scaled = img.resize(nw, nh); break :blk &scaled.?; } else &img; const rgba = gpa.dupe(u8, src.data) catch return null; return .{ .rgba = rgba, .w = src.width, .h = src.height }; }