#!/usr/bin/env bash # # optimize-docs-images.sh # # Shrinks PNG screenshots in the user guide so they don't bloat the repository. # Each image is converted to sRGB, stripped of metadata, capped at MAX_WIDTH # pixels wide, and reduced to a 256-colour palette. Images that lose too much # fidelity as a palette (photos, gradients) stay truecolor, and an image is # only rewritten when the result is at least 10% smaller. Images that are # already optimized are skipped, so the script is safe to re-run. # # Usage: # # scripts/optimize-docs-images.sh [file.png ...] # # With no arguments, all tracked PNGs under docs/user/rstudio are processed. # Requires ImageMagick 7 (magick) built with lcms. # # Environment: # # MAX_WIDTH Maximum width, in pixels (default: 2000) # MIN_PSNR Minimum PSNR, in dB, for the palette version (default: 40) # SRGB_PROFILE Path to an sRGB ICC profile, for images that embed a profile # of their own (default: the usual macOS and Linux locations) set -euo pipefail MAX_WIDTH="${MAX_WIDTH:-2000}" MIN_PSNR="${MIN_PSNR:-40}" if ! command -v magick > /dev/null 2>&1; then echo "error: ImageMagick 7 (magick) is required" >&2 exit 1 fi # Screenshots usually embed the profile of the display they were taken on. # Dropping it without converting would shift colours in the browser. Images # without a profile don't need this, so a missing one is only an error once an # image that needs it turns up. SRGB_PROFILE="${SRGB_PROFILE:-}" if [ -z "$SRGB_PROFILE" ]; then for candidate in \ "/System/Library/ColorSync/Profiles/sRGB Profile.icc" \ "/usr/share/color/icc/colord/sRGB.icc" \ "/usr/share/color/icc/sRGB.icc" do if [ -f "$candidate" ]; then SRGB_PROFILE="$candidate" break fi done fi root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" workdir="$(mktemp -d)" trap 'rm -rf "$workdir"' EXIT if [ "$#" -gt 0 ]; then printf '%s\n' "$@" > "$workdir/files" else git -C "$root" ls-files ':(glob,icase)docs/user/rstudio/**/*.png' | sed "s|^|$root/|" > "$workdir/files" fi # Leave out the timestamp chunks ImageMagick writes by default, so the same # input always produces the same bytes. png_options=(-define png:compression-level=9 -define png:exclude-chunks=date,time) changed=0 failed=0 before=0 after=0 while IFS= read -r file; do truecolor="$workdir/truecolor.png" palette="$workdir/palette.png" # identify warns on stderr about every image without a profile, so its # output is only worth showing when it fails. if ! info="$(magick identify -ping -format '%w|%[png:IHDR.color-type-orig]|%[profile:icc]' "$file" 2> "$workdir/identify.log")"; then echo "error: cannot read $file" >&2 cat "$workdir/identify.log" >&2 failed=$((failed + 1)) continue fi IFS='|' read -r width color_type profile <<< "$info" # Already optimized: a palette PNG (colour type 3) is what this script # writes. Quantizing again is not a no-op: ImageMagick merges more colours # on each pass, so skip rather than degrade the image. This goes by how the # file is stored rather than how many colours it has, so that a 16-bit or # uncompressed image with few colours still gets processed. if [ -z "$profile" ] && [ "$width" -le "$MAX_WIDTH" ] && [ "$color_type" = 3 ]; then size="$(wc -c < "$file" | tr -d ' ')" before=$((before + size)) after=$((after + size)) continue fi if [ -n "$profile" ] && [ ! -f "$SRGB_PROFILE" ]; then echo "error: $file embeds a colour profile, and no sRGB ICC profile was found to convert it; set SRGB_PROFILE" >&2 failed=$((failed + 1)) continue fi if [ -n "$profile" ]; then magick "$file" -profile "$SRGB_PROFILE" -strip -resize "${MAX_WIDTH}x>" -depth 8 "${png_options[@]}" "$truecolor" else magick "$file" -strip -resize "${MAX_WIDTH}x>" -depth 8 "${png_options[@]}" "$truecolor" fi # No dithering: it adds noise to the flat regions that dominate screenshots. magick "$truecolor" -dither None -colors 256 "${png_options[@]}" "png8:$palette" # compare exits non-zero when the images differ at all psnr="$(magick compare -metric PSNR "$truecolor" "$palette" null: 2>&1 | awk '{ print $1 }' || true)" result="$truecolor" if awk -v psnr="$psnr" -v min="$MIN_PSNR" 'BEGIN { exit !(psnr == "inf" || psnr + 0 >= min) }'; then result="$palette" fi old_size="$(wc -c < "$file" | tr -d ' ')" new_size="$(wc -c < "$result" | tr -d ' ')" before=$((before + old_size)) if [ $((new_size * 10)) -lt $((old_size * 9)) ]; then cp "$result" "$file" changed=$((changed + 1)) after=$((after + new_size)) echo "$((old_size / 1024)) KB -> $((new_size / 1024)) KB ${file#"$root"/}" else after=$((after + old_size)) fi done < "$workdir/files" echo "Rewrote $changed image(s): $((before / 1024)) KB -> $((after / 1024)) KB" if [ "$failed" -gt 0 ]; then echo "error: $failed file(s) could not be processed" >&2 exit 1 fi