Last active
January 31, 2026 02:03
-
-
Save jonlabelle/e308d337f830e678319b9697e360311a to your computer and use it in GitHub Desktop.
Removes excess transparent space from the top and bottom of PNG screenshots while preserving the shadow effect. Designed for screenshots taken with macOS screenshot tools that add shadow/padding.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # trim-screenshot.sh - Trim transparent padding from macOS terminal screenshots | |
| # | |
| # Removes excess transparent space from the top and bottom of PNG screenshots | |
| # while preserving the shadow effect. Designed for screenshots taken with | |
| # macOS screenshot tools that add shadow/padding. | |
| # | |
| # Used to trim my macOS Terminal screenshots for the pwsh-project project: | |
| # https://github.com/jonlabelle/pwsh-profile | |
| # | |
| # Dependencies: | |
| # - ImageMagick (brew install imagemagick) | |
| # | |
| # Snippet: | |
| # https://jonlabelle.com/snippets/view/shell/trim-excess-space-from-screenshot | |
| # | |
| # Author: Jon LaBelle | |
| # License: MIT | |
| # Date: 2026-01-30 | |
| # | |
| set -euo pipefail | |
| # Default values | |
| TOP_TRIM=40 | |
| BOTTOM_TRIM=50 | |
| OUTPUT_DIR="" | |
| SUFFIX="" | |
| BACKUP=false | |
| FORCE=false | |
| DRY_RUN=false | |
| # Colors for output | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[0;33m' | |
| BLUE='\033[0;34m' | |
| NC='\033[0m' # No Color | |
| usage() { | |
| cat <<'EOF' | |
| trim-screenshot.sh - Trim transparent padding from macOS terminal screenshots | |
| Removes excess transparent space from the top and bottom of PNG screenshots | |
| while preserving the shadow effect. Designed for screenshots taken with | |
| macOS screenshot tools that add shadow/padding. | |
| Usage: | |
| ./trim-screenshot.sh <image.png> [image2.png ...] | |
| ./trim-screenshot.sh -t 40 -b 50 screenshot.png | |
| ./trim-screenshot.sh --top 30 --bottom 40 *.png | |
| Options: | |
| -t, --top <pixels> Pixels to trim from top (default: 40) | |
| -b, --bottom <pixels> Pixels to trim from bottom (default: 50) | |
| -o, --output <path> Output path: directory for multiple files, or filename for single file | |
| -s, --suffix <suffix> Add suffix to output filename (e.g., '-trimmed') | |
| -k, --keep-backup Create backup of original file (.bak extension) | |
| -f, --force Overwrite existing output files without prompting | |
| -n, --dry-run Show what would be done without making changes | |
| -h, --help Show this help message | |
| Requirements: | |
| - ImageMagick (brew install imagemagick) | |
| Examples: | |
| # Trim with defaults (40px top, 50px bottom) | |
| ./trim-screenshot.sh screenshot.png | |
| # Trim multiple images | |
| ./trim-screenshot.sh term-screen-shot.png netdiag.png | |
| # Custom trim amounts | |
| ./trim-screenshot.sh -t 30 -b 60 screenshot.png | |
| # Output to different directory with suffix | |
| ./trim-screenshot.sh -o ./trimmed -s '-small' *.png | |
| # Output to specific filename (single file only) | |
| ./trim-screenshot.sh -o result.png screenshot.png | |
| # Preview changes without modifying files | |
| ./trim-screenshot.sh --dry-run *.png | |
| EOF | |
| exit 0 | |
| } | |
| error() { | |
| echo -e "${RED}Error:${NC} $1" >&2 | |
| exit 1 | |
| } | |
| info() { | |
| echo -e "${BLUE}→${NC} $1" | |
| } | |
| success() { | |
| echo -e "${GREEN}✓${NC} $1" | |
| } | |
| warn() { | |
| echo -e "${YELLOW}⚠${NC} $1" | |
| } | |
| # Check for ImageMagick | |
| check_dependencies() { | |
| if ! command -v magick &>/dev/null; then | |
| error "ImageMagick is required but not installed. Install with: brew install imagemagick" | |
| fi | |
| } | |
| # Trim a single image | |
| trim_image() { | |
| local input="$1" | |
| local filename | |
| local base | |
| local ext | |
| local output | |
| # Validate input file | |
| if [[ ! -f "${input}" ]]; then | |
| warn "File not found: ${input}" | |
| return 1 | |
| fi | |
| # Check if it's a PNG (case-insensitive) | |
| local lower_input | |
| lower_input=$(echo "${input}" | tr '[:upper:]' '[:lower:]') | |
| if [[ "${lower_input}" != *.png ]]; then | |
| warn "Skipping non-PNG file: ${input}" | |
| return 1 | |
| fi | |
| filename=$(basename "${input}") | |
| base="${filename%.*}" | |
| ext="${filename##*.}" | |
| # Determine output path | |
| if [[ -n "${OUTPUT_DIR}" ]]; then | |
| # Check if OUTPUT_DIR is an existing directory or looks like a directory path | |
| if [[ -d "${OUTPUT_DIR}" ]] || [[ "${OUTPUT_DIR}" == */ ]]; then | |
| # It's a directory - use original filename (with optional suffix) | |
| output="${OUTPUT_DIR%/}/${base}${SUFFIX}.${ext}" | |
| else | |
| # It's a file path - use it directly (suffix is ignored) | |
| output="${OUTPUT_DIR}" | |
| fi | |
| elif [[ -n "${SUFFIX}" ]]; then | |
| local dir | |
| dir=$(dirname "${input}") | |
| output="${dir}/${base}${SUFFIX}.${ext}" | |
| else | |
| output="${input}" | |
| fi | |
| # Get original dimensions | |
| local orig_height | |
| local orig_width | |
| orig_height=$(magick identify -format '%h' "${input}") | |
| orig_width=$(magick identify -format '%w' "${input}") | |
| # Calculate new height | |
| local new_height=$((orig_height - TOP_TRIM - BOTTOM_TRIM)) | |
| if [[ ${new_height} -le 0 ]]; then | |
| warn "Trim values (${TOP_TRIM} + ${BOTTOM_TRIM}) exceed image height (${orig_height}): ${input}" | |
| return 1 | |
| fi | |
| if [[ "${DRY_RUN}" == true ]]; then | |
| info "[dry-run] Would trim '${input}'" | |
| echo " Original: ${orig_width}x${orig_height}" | |
| echo " New: ${orig_width}x${new_height} (top: -${TOP_TRIM}px, bottom: -${BOTTOM_TRIM}px)" | |
| echo " Output: ${output}" | |
| return 0 | |
| fi | |
| # Check if output file exists and we're not forcing overwrite | |
| # (skip check if output is the same as input - that's in-place modification) | |
| if [[ -f "${output}" ]] && [[ "${output}" != "${input}" ]] && [[ "${FORCE}" == false ]]; then | |
| warn "Output file already exists: ${output} (use --force to overwrite)" | |
| return 1 | |
| fi | |
| # Create backup if requested | |
| if [[ "${BACKUP}" == true ]] && [[ "${output}" == "${input}" ]]; then | |
| local backup_file="${input}.bak" | |
| cp "${input}" "${backup_file}" | |
| info "Backup saved: ${backup_file}" | |
| fi | |
| # Perform the trim (preserving PNG quality and color settings) | |
| info "Trimming '${input}'..." | |
| # PNG quality options to preserve original characteristics | |
| local png_opts=( | |
| -quality 95 # Compression level 9, adaptive filtering | |
| -define png:compression-filter=adaptive # Best filter selection | |
| -define png:compression-level=9 # Maximum compression | |
| -define png:compression-strategy=default # Standard strategy | |
| -define png:exclude-chunk=date # Don't update date chunk | |
| ) | |
| if [[ "${output}" == "${input}" ]]; then | |
| # In-place modification - use temp file | |
| local temp_file | |
| temp_file=$(mktemp "${TMPDIR:-/tmp}/trim-screenshot.XXXXXX.png") | |
| magick "${input}" -gravity North -chop "0x${TOP_TRIM}" -gravity South -chop "0x${BOTTOM_TRIM}" "${png_opts[@]}" "${temp_file}" | |
| mv "${temp_file}" "${output}" | |
| else | |
| magick "${input}" -gravity North -chop "0x${TOP_TRIM}" -gravity South -chop "0x${BOTTOM_TRIM}" "${png_opts[@]}" "${output}" | |
| fi | |
| success "Saved: ${output} (${orig_width}x${orig_height} → ${orig_width}x${new_height})" | |
| } | |
| main() { | |
| local files=() | |
| # Parse command line arguments | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| -t | --top) | |
| TOP_TRIM="$2" | |
| shift 2 | |
| ;; | |
| -b | --bottom) | |
| BOTTOM_TRIM="$2" | |
| shift 2 | |
| ;; | |
| -o | --output) | |
| OUTPUT_DIR="$2" | |
| shift 2 | |
| ;; | |
| -s | --suffix) | |
| SUFFIX="$2" | |
| shift 2 | |
| ;; | |
| -k | --keep-backup) | |
| BACKUP=true | |
| shift | |
| ;; | |
| -f | --force) | |
| FORCE=true | |
| shift | |
| ;; | |
| -n | --dry-run) | |
| DRY_RUN=true | |
| shift | |
| ;; | |
| -h | --help) | |
| usage | |
| ;; | |
| -*) | |
| error "Unknown option: $1" | |
| ;; | |
| *) | |
| files+=("$1") | |
| shift | |
| ;; | |
| esac | |
| done | |
| # Validate we have files | |
| if [[ ${#files[@]} -eq 0 ]]; then | |
| error "No input files specified. Use -h for help." | |
| fi | |
| # Validate numeric inputs | |
| if ! [[ "${TOP_TRIM}" =~ ^[0-9]+$ ]]; then | |
| error "Top trim must be a positive integer: ${TOP_TRIM}" | |
| fi | |
| if ! [[ "${BOTTOM_TRIM}" =~ ^[0-9]+$ ]]; then | |
| error "Bottom trim must be a positive integer: ${BOTTOM_TRIM}" | |
| fi | |
| check_dependencies | |
| # Create output directory if specified and it's a directory path | |
| if [[ -n "${OUTPUT_DIR}" ]] && [[ "${DRY_RUN}" == false ]]; then | |
| if [[ -d "${OUTPUT_DIR}" ]] || [[ "${OUTPUT_DIR}" == */ ]]; then | |
| mkdir -p "${OUTPUT_DIR%/}" | |
| else | |
| # It's a file path - create parent directory if needed | |
| local parent_dir | |
| parent_dir=$(dirname "${OUTPUT_DIR}") | |
| [[ -n "${parent_dir}" ]] && [[ "${parent_dir}" != "." ]] && mkdir -p "${parent_dir}" | |
| fi | |
| fi | |
| echo "" | |
| if [[ "${DRY_RUN}" == true ]]; then | |
| warn "Dry run mode - no files will be modified" | |
| fi | |
| info "Trim settings: top=${TOP_TRIM}px, bottom=${BOTTOM_TRIM}px" | |
| echo "" | |
| local count=0 | |
| local failed=0 | |
| for file in "${files[@]}"; do | |
| # shellcheck disable=SC2310 # Function has explicit error handling, doesn't rely on set -e | |
| if trim_image "${file}"; then | |
| ((count++)) || true | |
| else | |
| ((failed++)) || true | |
| fi | |
| done | |
| echo "" | |
| if [[ "${DRY_RUN}" == true ]]; then | |
| info "Dry run complete. Would process ${count} file(s)." | |
| else | |
| success "Processed ${count} file(s) successfully." | |
| fi | |
| if [[ ${failed} -gt 0 ]]; then | |
| warn "${failed} file(s) skipped or failed." | |
| fi | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment