#!/usr/bin/env bash # make-icon-pack.sh: one logo in, the whole favicon / app-icon / OG pack out. # # bash make-icon-pack.sh [og-hex] # # square-ish, 1024px or more on the long edge, transparent ground preferred # the OPAQUE brand colour placed behind apple-touch-icon, the maskable # icons and the OG image. Those three may not carry alpha. # [og-hex] optional separate ground for the 1200x630 share image. # # Requires ImageMagick 7. Nothing else. No node, no npm, no online generator. # # Written by J.A. Watte for the Agent Site Standards Kit: https://jwatte.com/downloads/ # Free to copy, adapt and ship. Every trap in the comments below cost real time to find. # # --------------------------------------------------------------------------- # TRAP 1: on Windows, `convert` is C:\Windows\system32\convert.exe, the # FAT-to-NTFS volume converter. Every command here starts with `magick`. # TRAP 2: `magick identify -format` emits NO trailing newline, so `read` returns # 1 and `set -e` kills the script on the first line. Always append \n. # TRAP 3: on Windows that \n comes out as CRLF, so the last field carries \r and # every string comparison fails silently. Always pipe through tr -d '\r'. # TRAP 4: -format honours \n only. It has no \t and no printf width specifiers # ('%-26f' prints the literal "26f"). Use a separator and column -t. # --------------------------------------------------------------------------- set -euo pipefail SRC="${1:?usage: make-icon-pack.sh [og-hex]}" OUT="${2:?output directory required}" BG="${3:-#ffffff}" OGBG="${4:-$BG}" FUZZ="${FUZZ:-12%}" # tolerance when trimming a flat plate off the mark mkdir -p "$OUT" M="$OUT/_master.png" K="$OUT/_mark.png" # --------------------------------------------------------------- 0. GUARD # Refuse silently-wrong sources: a JPEG named .png, or anything under 512px. # A 357x200 JPEG shipped as an app icon is the single most common version of # this mistake, and nothing downstream complains about it. read -r SW SH SFMT < <(magick identify -format '%w %h %m\n' "$SRC" | tr -d '\r') echo "source: $SRC ${SW}x${SH} $SFMT" [ "$SFMT" = "JPEG" ] && echo "!! WARNING: source is JPEG-encoded: no alpha, ringing at 16px." >&2 if [ "$SW" -lt 512 ] || [ "$SH" -lt 512 ]; then echo "!! WARNING: source under 512px: every icon below is an upscale." >&2 fi # -------------------------------------------------------------- 1. MASTER # Rasterise ONCE at 1024, square-padded, 8-bit sRGB. Everything else resizes # from this, so any source-format weirdness is dealt with exactly once. # -density matters only for SVG input; librsvg is built into ImageMagick 7. # An SVG's viewBox drives its raster size, so without -density a 100x100 # viewBox rasterises to 100x100 and every icon is a blurry upscale. magick -background none -density 384 "$SRC" \ -colorspace sRGB -alpha on \ -resize 1024x1024 -gravity center -extent 1024x1024 \ -depth 8 -strip "PNG32:$M" # The MARK is the artwork's true bounding box, used only by the maskable step. # A transparent source trims on alpha. A source with a flat opaque plate needs # -fuzz, or -trim returns the whole canvas and the maskable icon ends up being # a shrunken screenshot of the plate instead of the mark. magick -background none "$SRC" -fuzz "$FUZZ" -trim +repage \ -colorspace sRGB -depth 8 "PNG32:$K" read -r KW KH < <(magick identify -format '%w %h\n' "$K" | tr -d '\r') echo "mark bbox: ${KW}x${KH}" # ---------------------------------------------------------- 2. favicon.svg # If the source IS an SVG, ship it byte for byte. Never retrace a raster. # If it is not, you have no SVG favicon, and that is fine: the .ico covers it. case "${SRC,,}" in *.svg) cp "$SRC" "$OUT/favicon.svg" ;; esac # --------------------------------------------------------- 3. PNG favicons magick "$M" -resize 16x16 -depth 8 -strip "PNG32:$OUT/favicon-16.png" magick "$M" -resize 32x32 -depth 8 -strip "PNG32:$OUT/favicon-32.png" # ------------------------------------------------------ 4. multi-frame ICO # One command, three frames. 48/32/16 lands at about 15 KB regardless of the # artwork, because ICO frames are uncompressed BMP. Adding a 256 frame costs # roughly 270 KB on its own: never include one. magick "$M" -background none -define icon:auto-resize=48,32,16 "$OUT/favicon.ico" # ------------------------------------------------ 5. apple-touch-icon, 180 # MUST be opaque: iOS composites alpha onto black. iOS also applies its own # rounded mask and gloss, so ship a square with no corner radius of your own. # Art at 80% leaves the breathing room the mask expects. magick "$M" -resize 144x144 \ -background "$BG" -gravity center -extent 180x180 \ -alpha remove -alpha off -depth 8 -strip "PNG24:$OUT/apple-touch-icon.png" # ---------------------------------------------------- 6. PWA purpose: any # Full bleed, alpha kept. These are the two Chrome checks installability on. magick "$M" -resize 192x192 -depth 8 -strip "PNG32:$OUT/icon-192.png" magick "$M" -resize 512x512 -depth 8 -strip "PNG32:$OUT/icon-512.png" # ----------------------------------------------- 7. PWA purpose: maskable # The W3C safe zone is a CIRCLE whose radius is 2/5 of the icon size: 409.6px # across at 512. So the mark's DIAGONAL, not its width, is what has to fit: # scale = (0.8 * S) / hypot(w, h) # An "80% square box" is a different, wrong calculation that clips wide marks. # A maskable icon is a DIFFERENT FILE from the purpose:any icon. Declaring # "purpose": "maskable" on the full-bleed file is the most common PWA icon bug # and it is invisible until somebody installs the app on a circular-mask launcher. for S in 512 192; do NW=$(magick identify -format "%[fx:int(w*($S*0.8)/hypot(w,h))]" "$K" | tr -d '\r') NH=$(magick identify -format "%[fx:int(h*($S*0.8)/hypot(w,h))]" "$K" | tr -d '\r') magick "$K" -resize "${NW}x${NH}" \ -background "$BG" -gravity center -extent "${S}x${S}" \ -alpha remove -alpha off -depth 8 -strip "PNG24:$OUT/icon-maskable-$S.png" echo "maskable $S: mark fitted to ${NW}x${NH} inside a safe circle of $(awk -v s=$S 'BEGIN{print s*0.8}')px" done # ------------------------------------------------------- 8. OG share image # Exactly 1200x630, which is 1.90476:1. Opaque, because Slack, LinkedIn and X # flatten alpha to black. Never declare og:image:width/height your file does # not actually have: the platform crops to the ratio it was promised. # Composite the MARK, not the square master: dropping a square that carries its # own ground onto a flat canvas leaves a visible seam wherever the two darks # disagree by a shade, and you will not see it until somebody shares the link. magick -size 1200x630 "canvas:$OGBG" \ \( "$K" -resize 720x360 \) -gravity center -composite \ -alpha remove -alpha off -depth 8 -strip "PNG24:$OUT/og-image.png" magick "$OUT/og-image.png" -quality 82 -define webp:method=6 "$OUT/og-image.webp" rm -f "$M" "$K" # --------------------------------------------------------------- 9. GATES # Three assertions. If any of them is not what it says below, do not ship. echo; echo "=== INVENTORY ===" magick identify -format '%f|%wx%h|%m|alpha=%A|depth=%z|%B bytes\n' \ "$OUT"/*.png "$OUT"/*.webp 2>/dev/null | column -t -s '|' echo "--- favicon.ico frames (expect 48, 32, 16) ---" magick identify "$OUT/favicon.ico" echo "--- GATE 1: opacity. apple-touch, both maskables and og MUST read 1 ---" FAIL=0 for f in apple-touch-icon.png icon-maskable-512.png icon-maskable-192.png og-image.png; do A=$(magick "$OUT/$f" -alpha extract -format '%[fx:mean]' info: | tr -d '\r') printf '%s mean-alpha=%s\n' "$f" "$A" [ "$A" = "1" ] || { echo " ^^ FAIL: carries transparency"; FAIL=1; } done echo "--- GATE 2: maskable safe circle. Expect 0 px outside ---" magick -size 512x512 xc:black -fill white -draw "circle 256,256 256,51.2" \ -alpha off -colorspace Gray "$OUT/_c.png" magick "$OUT/_c.png" -negate -alpha off "$OUT/_o.png" magick "$OUT/icon-maskable-512.png" -fill white -opaque "$BG" -negate \ -threshold 1% -alpha off -colorspace Gray "$OUT/_m.png" magick "$OUT/_m.png" "$OUT/_o.png" -compose multiply -composite \ -alpha off -colorspace Gray "$OUT/_x.png" OUTSIDE=$(magick identify -format '%[fx:int(mean*w*h)]' "$OUT/_x.png" | tr -d '\r') echo "mark px outside safe circle: $OUTSIDE" [ "$OUTSIDE" = "0" ] || { echo " ^^ FAIL: the launcher mask will cut the mark"; FAIL=1; } rm -f "$OUT"/_c.png "$OUT"/_o.png "$OUT"/_m.png "$OUT"/_x.png echo "--- GATE 3: og-image is exactly 1200x630 ---" OGDIM=$(magick identify -format '%wx%h\n' "$OUT/og-image.png" | tr -d '\r') echo "og-image.png $OGDIM" [ "$OGDIM" = "1200x630" ] || { echo " ^^ FAIL"; FAIL=1; } echo if [ "$FAIL" -eq 0 ]; then echo "ALL GATES PASSED." else echo "GATES FAILED: do not ship this pack."; exit 1 fi cat <<'HEAD' Head block to paste (adjust paths to where you put the files): Do NOT emit rel="shortcut icon" (non-conforming) or rel="mask-icon" (obsolete since Safari 12 uses the regular favicon for pinned tabs). Do NOT list the 512px raster as a : no browser picks a 512 for a 16px tab. HEAD