capafix.sh

#!/usr/bin/env bash
#
# capafix.sh
# Author: Robert Miller, St. John's, Newfoundland and Labrador
#
# Purpose:
#   Resize images to meet CAPA-style pixel dimensions and save corrected
#   copies as new JPEG files while preserving all metadata.
#
# Notes:
#   Designed for macOS with ExifTool and ImageMagick installed.
#   Compatible with Linux and other UNIX-based systems.
#   Update MAGICK and EXIFTOOL paths, if required.
#
# On macOS:
# Install Homebrew, then run:
#   brew install exiftool
#   brew install imagemagick
#
# Installation:
# Save this script to a directory in your system PATH (e.g., ~/bin or /usr/local/bin).
# Example:
#   mkdir -p ~/bin
#   mv capafix.sh ~/bin/
#   chmod +x ~/bin/capafix.sh
#
# To verify your PATH:
#   echo $PATH
#
# Ensure script location is listed. If not, add it:
#   export PATH="$HOME/bin:$PATH"
#
# To make this permanent (macOS with bash):
#   echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bash_profile
#
# Reload your shell:
#   source ~/.bash_profile
#
# Confirm script is accessible:
#   which capafix.sh
#   capafix.sh --help
#
# Behaviour:
#   - does not default to all files
#   - accepts one or more filenames or globs
#   - resizes to fit within CAPA maximum dimensions
#   - writes output as basename-fixed.jpg
#   - preserves all metadata from source file
#   - appends comment: "Dimensions corrected by capafix.sh"
#
# Usage examples:
#   ./capafix.sh "*.jpg"
#   ./capafix.sh image1.jpg image2.tif
#   ./capafix.sh --quality 92 *.jpg
#   ./capafix.sh --filter Lanczos --unsharp 0x0.5 *.jpg
#   ./capafix.sh --suffix=-fixed *.png
#

set -u
set -o pipefail

MAGICK="/usr/local/bin/magick"
EXIFTOOL="/usr/local/bin/exiftool"

MAX_WIDTH=1400
MAX_HEIGHT=1050
JPEG_QUALITY=92
FILTER="Lanczos"
UNSHARP="0x0.5"
USE_UNSHARP=1
SUFFIX="-fixed"
OVERWRITE=0

usage() {
  cat <<EOF
Usage:
  $(basename "$0") [options] [files or globs]

Options:
  --max-width N            Set max width
  --max-height N           Set max height
  --quality N              Set JPEG quality (default: $JPEG_QUALITY)
  --filter NAME            Set resize filter (default: $FILTER)
  --unsharp VALUE          Set unsharp mask (default: $UNSHARP)
  --no-unsharp             Disable unsharp mask
  --suffix TEXT            Set output suffix (default: $SUFFIX)
  --overwrite              Overwrite output file if it already exists
  --help | -h | -?         Show this help

Notes:
  - At least one file or glob is required.
  - Script does not default to scanning all files.
  - Output is always written as JPEG.
  - Output name is:
      originalname${SUFFIX}.jpg
  - All metadata is copied from source file to output file.
  - Comment appended:
      Dimensions corrected by capafix.sh

Examples:
  $(basename "$0") "*.jpg"
  $(basename "$0") image1.jpg image2.png
  $(basename "$0") --quality 90 *.tif
  $(basename "$0") --filter Lanczos --unsharp 0x0.5 *.jpg
  $(basename "$0") --suffix=-capa *.png
EOF
}

die() {
  echo "Error: $*" >&2
  exit 1
}

warn() {
  echo "Warning: $*" >&2
}

info() {
  echo "$*"
}

command_exists() {
  command -v "$1" >/dev/null 2>&1
}

lower_ext() {
  printf "%s" "${1##*.}" | tr '[:upper:]' '[:lower:]'
}

is_supported_image() {
  case "$(lower_ext "$1")" in
    jpg|jpeg|png|tif|tiff) return 0 ;;
    *) return 1 ;;
  esac
}

build_output_name() {
  input="$1"
  dir=$(dirname "$input")
  file=$(basename "$input")
  stem="${file%.*}"
  printf "%s/%s%s.jpg" "$dir" "$stem" "$SUFFIX"
}

add_file_if_valid() {
  candidate="$1"
  [ -f "$candidate" ] || return 0

  if [ "${#FILES[@]}" -gt 0 ]; then
    for existing in "${FILES[@]}"; do
      [ "$existing" = "$candidate" ] && return 0
    done
  fi

  FILES+=("$candidate")
}

expand_input_patterns() {
  for arg in "$@"; do
    if [ -e "$arg" ]; then
      add_file_if_valid "$arg"
    else
      for match in $arg; do
        [ -e "$match" ] || continue
        add_file_if_valid "$match"
      done
    fi
  done
}

INPUT_ARGS=()

while [ "$#" -gt 0 ]; do
  case "$1" in
    --max-width)
      shift
      [ "$#" -gt 0 ] || die "Missing value for --max-width"
      MAX_WIDTH="$1"
      ;;
    --max-height)
      shift
      [ "$#" -gt 0 ] || die "Missing value for --max-height"
      MAX_HEIGHT="$1"
      ;;
    --quality)
      shift
      [ "$#" -gt 0 ] || die "Missing value for --quality"
      JPEG_QUALITY="$1"
      ;;
    --filter)
      shift
      [ "$#" -gt 0 ] || die "Missing value for --filter"
      FILTER="$1"
      ;;
    --unsharp)
      shift
      [ "$#" -gt 0 ] || die "Missing value for --unsharp"
      UNSHARP="$1"
      USE_UNSHARP=1
      ;;
    --no-unsharp)
      USE_UNSHARP=0
      ;;
    --suffix)
      shift
      [ "$#" -gt 0 ] || die "Missing value for --suffix"
      SUFFIX="$1"
      ;;
    --overwrite)
      OVERWRITE=1
      ;;
    --help|-h|-?)
      usage
      exit 0
      ;;
    --*)
      die "Unknown option: $1"
      ;;
    *)
      INPUT_ARGS+=("$1")
      ;;
  esac
  shift
done

[ "${#INPUT_ARGS[@]}" -gt 0 ] || {
  usage >&2
  exit 1
}

[ -x "$MAGICK" ] || command_exists magick || die "ImageMagick 'magick' not found"
[ -x "$EXIFTOOL" ] || command_exists exiftool || die "ExifTool not found"

if [ ! -x "$MAGICK" ]; then
  MAGICK="$(command -v magick)"
fi

if [ ! -x "$EXIFTOOL" ]; then
  EXIFTOOL="$(command -v exiftool)"
fi

case "$MAX_WIDTH" in ''|*[!0-9]*) die "--max-width must be an integer" ;; esac
case "$MAX_HEIGHT" in ''|*[!0-9]*) die "--max-height must be an integer" ;; esac
case "$JPEG_QUALITY" in ''|*[!0-9]*) die "--quality must be an integer" ;; esac

FILES=()
expand_input_patterns "${INPUT_ARGS[@]}"

[ "${#FILES[@]}" -gt 0 ] || die "No matching files found"

processed=0
failed=0
skipped=0

for input in "${FILES[@]}"; do
  if [ ! -f "$input" ]; then
    warn "Skipping non-file: $input"
    skipped=$((skipped + 1))
    continue
  fi

  if ! is_supported_image "$input"; then
    warn "Skipping unsupported file type: $input"
    skipped=$((skipped + 1))
    continue
  fi

  output="$(build_output_name "$input")"

  if [ "$input" = "$output" ]; then
    warn "Skipping because output would overwrite input: $input"
    skipped=$((skipped + 1))
    continue
  fi

  if [ -e "$output" ] && [ "$OVERWRITE" -ne 1 ]; then
    warn "Skipping existing output: $output"
    skipped=$((skipped + 1))
    continue
  fi

  tmp="${output}.tmp.$$"

  info "Processing: $input"
  info "  Output: $output"

  if [ "$USE_UNSHARP" -eq 1 ]; then
    "$MAGICK" "$input" \
      -auto-orient \
      -colorspace sRGB \
      -filter "$FILTER" \
      -resize "${MAX_WIDTH}x${MAX_HEIGHT}>" \
      -unsharp "$UNSHARP" \
      -quality "$JPEG_QUALITY" \
      -interlace none \
      "$tmp"
  else
    "$MAGICK" "$input" \
      -auto-orient \
      -colorspace sRGB \
      -filter "$FILTER" \
      -resize "${MAX_WIDTH}x${MAX_HEIGHT}>" \
      -quality "$JPEG_QUALITY" \
      -interlace none \
      "$tmp"
  fi

  if [ $? -ne 0 ]; then
    warn "Resize failed: $input"
    rm -f "$tmp"
    failed=$((failed + 1))
    continue
  fi

  "$EXIFTOOL" -overwrite_original \
    -TagsFromFile "$input" -all:all \
    -Comment+="Dimensions corrected by capafix.sh" \
    "$tmp" >/dev/null 2>&1

  if [ $? -ne 0 ]; then
    warn "Metadata copy failed: $input"
    rm -f "$tmp"
    failed=$((failed + 1))
    continue
  fi

  mv -f "$tmp" "$output"
  if [ $? -ne 0 ]; then
    warn "Could not move output into place: $output"
    rm -f "$tmp"
    failed=$((failed + 1))
    continue
  fi

  processed=$((processed + 1))
done

echo
echo "Summary:"
echo "  Processed: $processed"
echo "  Failed:    $failed"
echo "  Skipped:   $skipped"

[ "$failed" -eq 0 ] || exit 1
exit 0