Skip to content

Photo Merge — Zig source

Combine photos side by side, stacked, or in a grid — uniform cells, adjustable gap, one PNG out. Runs entirely in your browser.

This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.

// photo-merge — Zig port: side-by-side merge cell layout.

const std = @import("std");

/// A decoded image's dimensions. In the browser tool the bitmap is
/// drawImage'd into its letterboxed cell; the geometry here is the
/// deterministic part of that pipeline.
const ImageSize = struct { width: i32, height: i32 };

/// Where one image lands on the merged canvas.
const Placement = struct { scale: f64, x: i32, y: i32, w: i32, h: i32 };

const MergedLayout = struct {
    canvas_w: i32,
    canvas_h: i32,
    placements: []Placement,
};

/// JS Math.round (half up for positives): @round gives half-away-from-
/// zero, identical on the non-negative values used here.
fn jsRound(v: f64) i32 {
    return @intFromFloat(@round(v));
}

/// Mirrors mergeSideBySide in typescript.ts: cellW/cellH are the largest
/// input dimensions, the canvas is n cells + (n + 1) gaps wide and one
/// cell + 2 gaps tall, and each image is scaled by
/// min(cellW / w, cellH / h) and centered inside its cell.
/// Caller owns the returned placements slice (allocated from `alloc`).
fn mergeLayout(alloc: std.mem.Allocator, images: []const ImageSize, gap: i32) !MergedLayout {
    const n: i32 = @intCast(images.len);
    var cell_w: i32 = 0;
    var cell_h: i32 = 0;
    for (images) |img| {
        cell_w = @max(cell_w, img.width);
        cell_h = @max(cell_h, img.height);
    }

    const placements = try alloc.alloc(Placement, images.len);
    for (images, 0..) |img, i| {
        const idx: i32 = @intCast(i);
        const fw: f64 = @floatFromInt(img.width);
        const fh: f64 = @floatFromInt(img.height);
        const scale = @min(@as(f64, @floatFromInt(cell_w)) / fw,
                           @as(f64, @floatFromInt(cell_h)) / fh);
        const w = jsRound(fw * scale);
        const h = jsRound(fh * scale);
        placements[i] = .{
            .scale = scale,
            .x = gap + idx * (cell_w + gap) + jsRound(@as(f64, @floatFromInt(cell_w - w)) / 2),
            .y = gap + jsRound(@as(f64, @floatFromInt(cell_h - h)) / 2),
            .w = w,
            .h = h,
        };
    }
    return .{ .canvas_w = n * cell_w + (n + 1) * gap,
              .canvas_h = cell_h + 2 * gap, .placements = placements };
}

pub fn main() !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();

    // 800x600 + 600x800 + 1024x768 with a 12px gap -> 3120x824 canvas.
    const images = [_]ImageSize{
        .{ .width = 800, .height = 600 },
        .{ .width = 600, .height = 800 },
        .{ .width = 1024, .height = 768 },
    };
    const layout = try mergeLayout(arena.allocator(), &images, 12);

    const out = std.io.getStdOut().writer();
    try out.print("canvas {d}x{d}\n", .{ layout.canvas_w, layout.canvas_h });
    for (layout.placements, 0..) |p, i| {
        try out.print("image {d}: ({d}, {d}) {d}x{d} scale={d:.4}\n",
            .{ i, p.x, p.y, p.w, p.h, p.scale });
    }
}

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →