summaryrefslogtreecommitdiff
path: root/src/image.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/image.zig')
-rw-r--r--src/image.zig268
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)