feat(bin/pngs2collage): add script for creating grid collages from multiple PNG images with customizable options

This commit is contained in:
2025-10-19 00:09:13 +01:00
parent aa7b647a7f
commit b7aef0bb99

461
bin/pngs2collage Executable file
View File

@@ -0,0 +1,461 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat >&2 << EOF
Usage: $(basename "$0") [OPTIONS] <input1.png> <input2.png> [input3.png ...]
Create a grid collage from multiple PNG images.
OPTIONS:
-o, --output <file> Output filename (default: collage.png)
--max-width <pixels> Maximum width for grid cells (default: 1024)
--max-height <pixels> Maximum height for grid cells (default: 1024)
--gap <pixels> Gap between images (default: 0)
--border <pixels> Border around entire collage (default: 0)
--max-columns <count> Maximum columns in grid (default: auto)
--dpi <value> DPI for output image (default: 72)
-h, --help Show this help message
ARGUMENTS:
At least 2 input PNG files are required.
EXAMPLES:
$(basename "$0") img1.png img2.png
$(basename "$0") -o output.png --gap 10 img1.png img2.png img3.png
$(basename "$0") --output result.png --max-columns 3 *.png
EOF
}
verify-png() {
local src="$1"
local fmt
fmt=$(sips -g format "$src" 2> /dev/null |
awk '/format/ {print tolower($2)}') || true
if [[ -z "$fmt" ]]; then
echo "Unable to determine image format with sips: $src" >&2
return 1
fi
if [[ "$fmt" != "png" ]]; then
echo "Input must be a PNG image: $src" >&2
return 1
fi
}
get-image-dimensions() {
local src="$1"
local w h
w=$(sips -g pixelWidth "$src" 2> /dev/null |
awk '/pixelWidth/ {print $2}') || true
h=$(sips -g pixelHeight "$src" 2> /dev/null |
awk '/pixelHeight/ {print $2}') || true
if [[ -z "$w" || -z "$h" ]]; then
echo "Failed to read image dimensions with sips: $src" >&2
return 1
fi
echo "$w $h"
}
calculate-cell-size() {
local max_w="$1"
local max_h="$2"
shift 2
local images=("$@")
local cell_w=0
local cell_h=0
local any_exceeds=0
for img in "${images[@]}"; do
local dims
if ! dims=$(get-image-dimensions "$img"); then
return 1
fi
local w h
read -r w h <<< "$dims"
if ((w > max_w || h > max_h)); then
any_exceeds=1
fi
if ((w > cell_w)); then
cell_w=$w
fi
if ((h > cell_h)); then
cell_h=$h
fi
done
# If any image exceeds max dimensions, use max dimensions as cell size
if ((any_exceeds)); then
if ((max_w > max_h)); then
echo "$max_w $max_w"
else
echo "$max_h $max_h"
fi
else
# Use largest dimension from all images to make square cells
if ((cell_w > cell_h)); then
echo "$cell_w $cell_w"
else
echo "$cell_h $cell_h"
fi
fi
}
calculate-padding() {
local row="$1"
local col="$2"
local total_rows="$3"
local total_cols="$4"
local gap="$5"
local border="$6"
local top bottom left right
local half_gap
# Calculate half gap (rounded for odd gaps)
half_gap=$(awk "BEGIN {print int($gap / 2)}")
# Top padding
if ((row == 0)); then
top=$border
else
top=$half_gap
fi
# Bottom padding
if ((row == total_rows - 1)); then
bottom=$border
else
bottom=$half_gap
fi
# Left padding
if ((col == 0)); then
left=$border
else
left=$half_gap
fi
# Right padding
if ((col == total_cols - 1)); then
right=$border
else
right=$half_gap
fi
echo "$top $bottom $left $right"
}
prepare-image() {
local src="$1"
local cell_size="$2"
local top="$3"
local bottom="$4"
local left="$5"
local right="$6"
local out="$7"
local tmp_resized tmp_padded
tmp_resized="${out}.tmp.resized.png"
tmp_padded="${out}.tmp.padded.png"
# Scale preserving aspect ratio; fit within cell size
sips -Z "$cell_size" "$src" --out "$tmp_resized" > /dev/null
# Pad to exact square dimensions centered on transparent background
sips --padToHeightWidth "$cell_size" "$cell_size" \
"$tmp_resized" --out "$tmp_padded" > /dev/null
# Apply asymmetric padding using ImageMagick
# Create a transparent canvas and composite the image at the correct position
local final_width final_height
final_width=$((cell_size + left + right))
final_height=$((cell_size + top + bottom))
# Create transparent canvas of final size, then composite the prepared
# image at position (left, top) to create the exact padding we want
if ! magick -size "${final_width}x${final_height}" xc:none \
"$tmp_padded" -geometry "+${left}+${top}" -composite \
"$out" 2> /dev/null; then
echo "Failed to apply padding to image: $src" >&2
rm -f "$tmp_resized" "$tmp_padded" || true
return 1
fi
rm -f "$tmp_resized" "$tmp_padded" || true
}
calculate-grid-dimensions() {
local count="$1"
local max_cols="$2"
local cols rows
if [[ "$max_cols" == "auto" ]]; then
# Calculate square-ish grid
cols=$(awk "BEGIN {print int(sqrt($count) + 0.999)}")
else
if ((max_cols > count)); then
cols=$count
else
cols=$max_cols
fi
fi
rows=$(awk "BEGIN {print int(($count + $cols - 1) / $cols)}")
echo "$cols $rows"
}
create-collage() {
local cols="$1"
local rows="$2"
local dpi="$3"
local out="$4"
shift 4
local images=("$@")
# Build ImageMagick montage command
# montage combines images in a grid with zero spacing
# (padding is already applied to individual images)
local tile_arg="${cols}x${rows}"
# Create collage with transparent background and zero spacing
if ! magick montage "${images[@]}" \
-tile "$tile_arg" \
-geometry "+0+0" \
-background "none" \
"$out" 2> /dev/null; then
echo "Failed to create collage with ImageMagick" >&2
return 1
fi
# Set DPI metadata
sips -s dpiHeight "$dpi" -s dpiWidth "$dpi" "$out" > /dev/null
}
cleanup() {
local path="$1"
if [[ -z "$path" || ! -d "$path" ]]; then
return 0
fi
# Remove generated PNGs inside the temp directory
find "$path" -type f -name '*.png' -delete 2> /dev/null || true
# Attempt to remove the temp directory if now empty
rmdir "$path" 2> /dev/null || true
}
parse-args() {
local max_width=1024
local max_height=1024
local gap=0
local border=0
local max_columns="auto"
local dpi=72
local output_file=""
local -a input_files=()
while [[ $# -gt 0 ]]; do
case "$1" in
-h | --help)
usage
exit 0
;;
-o | --output)
output_file="$2"
shift 2
;;
--max-width)
max_width="$2"
shift 2
;;
--max-height)
max_height="$2"
shift 2
;;
--gap)
gap="$2"
shift 2
;;
--border)
border="$2"
shift 2
;;
--max-columns)
max_columns="$2"
shift 2
;;
--dpi)
dpi="$2"
shift 2
;;
-*)
echo "Unknown option: $1" >&2
usage
exit 1
;;
*)
input_files+=("$1")
shift
;;
esac
done
# Export parsed values for main to access
export PARSED_MAX_WIDTH="$max_width"
export PARSED_MAX_HEIGHT="$max_height"
export PARSED_GAP="$gap"
export PARSED_BORDER="$border"
export PARSED_MAX_COLUMNS="$max_columns"
export PARSED_DPI="$dpi"
export PARSED_OUTPUT="$output_file"
export PARSED_INPUT_COUNT="${#input_files[@]}"
# Export input files as a string (will be parsed in main)
local i
for i in "${!input_files[@]}"; do
export "PARSED_INPUT_$i=${input_files[$i]}"
done
}
main() {
if [[ $# -lt 2 ]]; then
usage
exit 1
fi
# Parse arguments
parse-args "$@"
# Reconstruct input files array from exports
local -a all_files=()
local i
for ((i = 0; i < PARSED_INPUT_COUNT; i++)); do
local varname="PARSED_INPUT_$i"
all_files+=("${!varname}")
done
# Determine output file and input files
local -a input_files=()
local output_file
if [[ -n "$PARSED_OUTPUT" ]]; then
# Explicit output was specified via --output/-o
output_file="$PARSED_OUTPUT"
input_files=("${all_files[@]}")
else
# No explicit output, use default or check last argument
output_file="collage.png"
if [[ ${#all_files[@]} -ge 2 ]]; then
local last_file="${all_files[-1]}"
# If last file doesn't exist and has .png extension, treat as output
if [[ ! -f "$last_file" && "$last_file" == *.png ]]; then
output_file="$last_file"
input_files=("${all_files[@]:0:$((${#all_files[@]} - 1))}")
else
input_files=("${all_files[@]}")
fi
else
input_files=("${all_files[@]}")
fi
fi
# Validate we have at least 2 input files
if [[ ${#input_files[@]} -lt 2 ]]; then
echo "Error: At least 2 input PNG files are required." >&2
usage
exit 1
fi
# Check for required commands
if ! command -v sips > /dev/null 2>&1; then
echo "This script requires 'sips' (macOS) but it wasn't found." >&2
exit 1
fi
if ! command -v magick > /dev/null 2>&1; then
echo "This script requires 'magick' (ImageMagick) but it wasn't found." >&2
exit 1
fi
# Verify all input files exist and are PNGs
for img in "${input_files[@]}"; do
if [[ ! -f "$img" ]]; then
echo "Input file not found: $img" >&2
exit 1
fi
if ! verify-png "$img"; then
exit 1
fi
done
# Create output directory if needed
local outdir
outdir="$(dirname "$output_file")"
mkdir -p "$outdir"
# Create temporary working directory
tmp_root="$(mktemp -d 2> /dev/null || mktemp -d -t pngs2collage)"
trap 'cleanup "$tmp_root"' EXIT
# Calculate grid dimensions first
local grid_dims
grid_dims=$(calculate-grid-dimensions "${#input_files[@]}" \
"$PARSED_MAX_COLUMNS")
local cols rows
read -r cols rows <<< "$grid_dims"
# Calculate cell size
local cell_dims
if ! cell_dims=$(calculate-cell-size "$PARSED_MAX_WIDTH" \
"$PARSED_MAX_HEIGHT" "${input_files[@]}"); then
exit 1
fi
local cell_size
read -r cell_size _ <<< "$cell_dims"
# Prepare all images with position-specific padding
local -a prepared_images=()
local idx=0
for img in "${input_files[@]}"; do
# Calculate grid position (row, col) from index
local row col
row=$((idx / cols))
col=$((idx % cols))
# Calculate padding for this position
local padding
padding=$(calculate-padding "$row" "$col" "$rows" "$cols" \
"$PARSED_GAP" "$PARSED_BORDER")
local top bottom left right
read -r top bottom left right <<< "$padding"
# Prepare image with position-specific padding
local prepared="${tmp_root}/prepared_${idx}.png"
if ! prepare-image "$img" "$cell_size" "$top" "$bottom" "$left" \
"$right" "$prepared"; then
echo "Failed to prepare image: $img" >&2
exit 1
fi
prepared_images+=("$prepared")
idx=$((idx + 1))
done
# Create the collage
if ! create-collage "$cols" "$rows" "$PARSED_DPI" "$output_file" \
"${prepared_images[@]}"; then
exit 1
fi
echo "Done. Collage written to: $output_file"
}
main "$@"