diff options
Diffstat (limited to 'src/image.zig')
| -rw-r--r-- | src/image.zig | 268 |
1 files changed, 242 insertions, 26 deletions
diff --git a/src/image.zig b/src/image.zig index 1e7736c5..a9f0e227 100644 --- a/src/image.zig +++ b/src/image.zig @@ -5,6 +5,7 @@ const std = @import("std"); const zstbi = @import("zstbi"); const ghostty_vt = @import("ghostty-vt"); +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 @@ -25,46 +26,261 @@ const MAX_DIM: u32 = 1280; pub const PaletteMode = enum { commodore, terminal }; -/// Fit an image inside a pixel rectangle without changing its aspect ratio or -/// enlarging it. Native SDL uses this for the same "contain" policy the kitty -/// placement uses. Keeping the arithmetic here makes the backend geometry -/// testable without a GPU or an image-capable terminal. -pub const Fit = struct { w: u32, h: u32 }; +/// 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 fn contain(iw: usize, ih: usize, max_w: u32, max_h: u32) Fit { - if (iw == 0 or ih == 0 or max_w == 0 or max_h == 0) return .{ .w = 0, .h = 0 }; +pub const PixelRect = struct { + x: u32 = 0, + y: u32 = 0, + w: u32 = 0, + h: u32 = 0, +}; - const src_w: u64 = @intCast(iw); - const src_h: u64 = @intCast(ih); - const bound_w: u64 = max_w; - const bound_h: u64 = max_h; - if (src_w <= bound_w and src_h <= bound_h) - return .{ .w = @intCast(src_w), .h = @intCast(src_h) }; +pub const PixelPoint = struct { x: u32, y: u32 }; - // Compare the two scale ratios without floating point. Whichever bound is - // tighter determines one exact dimension; the other is rounded down so it - // can never leak a pixel outside the pane body. - if (bound_w * src_h <= bound_h * src_w) { +/// 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 .{ - .w = max_w, - .h = @intCast(@max(1, src_h * bound_w / src_w)), + .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 .{ - .w = @intCast(@max(1, src_w * bound_h / src_h)), - .h = max_h, + .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 }, }; } -test "native image contain keeps aspect, bounds, and small-image size" { +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)); - // Awkward ratios stay inside both bounds rather than rounding one pixel - // over them. - const odd = contain(403, 211, 101, 47); - try std.testing.expect(odd.w <= 101 and odd.h <= 47); + + 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) |
