Files
AgentCompany/builtin-skills/iOS-Dev/icon-generator/scripts/generate_icons.py
T
ZacharyZhang-NYandClaude Sonnet 4.6 66158e222e update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:02:28 -04:00

275 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""Generate icon assets for web UI/UX (favicons + PWA icons) and optional UE packaging.
This script is designed to be simple, predictable, and cross-platform.
Inputs:
- A single source image: preferably a square 1024x1024 PNG.
- SVG is accepted only if optional rasterizers are installed.
Outputs (by default):
- Web/PWA: favicon.ico (multi-size), apple-touch-icon.png, icon-192.png, icon-512.png,
icon-maskable-512.png (padded + background).
- UE (optional): Windows icon.ico (multi-size), macOS .iconset folder, Linux PNGs.
Dependencies:
- Pillow (required): pip install pillow
Usage:
python scripts/generate_icons.py --input icon.png --out ./dist --targets web
python scripts/generate_icons.py --input icon.png --out ./dist --targets web,ue
Notes:
- For SVG input: export to a 1024x1024 PNG first (recommended) unless you install CairoSVG.
"""
from __future__ import annotations
import argparse
import math
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional
try:
from PIL import Image
except ImportError as e: # pragma: no cover
raise SystemExit(
"Pillow is required. Install it with: pip install pillow"
) from e
@dataclass(frozen=True)
class Options:
input_path: Path
out_dir: Path
targets: set[str]
pad_to_square: bool
background: str
maskable_scale: float
resample: int
def _parse_hex_color(value: str) -> tuple[int, int, int, int]:
v = value.strip().lstrip("#")
if len(v) == 6:
r = int(v[0:2], 16)
g = int(v[2:4], 16)
b = int(v[4:6], 16)
return (r, g, b, 255)
if len(v) == 8:
r = int(v[0:2], 16)
g = int(v[2:4], 16)
b = int(v[4:6], 16)
a = int(v[6:8], 16)
return (r, g, b, a)
raise ValueError(f"Invalid color '{value}'. Use #RRGGBB or #RRGGBBAA")
def _is_svg(path: Path) -> bool:
return path.suffix.lower() == ".svg"
def _load_image(path: Path) -> Image.Image:
if _is_svg(path):
# Best-effort: try cairosvg if installed, else fail with actionable guidance.
try:
import cairosvg # type: ignore
png_bytes = cairosvg.svg2png(url=str(path))
from io import BytesIO
return Image.open(BytesIO(png_bytes)).convert("RGBA")
except ImportError as e:
raise SystemExit(
"SVG input requires CairoSVG (pip install cairosvg) or export SVG to a 1024x1024 PNG first."
) from e
return Image.open(path).convert("RGBA")
def _ensure_square(img: Image.Image, pad_to_square: bool, background_rgba: tuple[int, int, int, int]) -> Image.Image:
w, h = img.size
if w == h:
return img
if not pad_to_square:
side = min(w, h)
left = (w - side) // 2
top = (h - side) // 2
return img.crop((left, top, left + side, top + side))
side = max(w, h)
canvas = Image.new("RGBA", (side, side), background_rgba)
x = (side - w) // 2
y = (side - h) // 2
canvas.alpha_composite(img, (x, y))
return canvas
def _resize(img: Image.Image, size: int, resample: int) -> Image.Image:
if img.size == (size, size):
return img
return img.resize((size, size), resample=resample)
def _fit_with_padding(img: Image.Image, size: int, content_scale: float, background_rgba: tuple[int, int, int, int], resample: int) -> Image.Image:
"""Create a square icon with padding.
For maskable icons, we want important content inside a safe zone.
A pragmatic default is to fit the original artwork inside a centered square
with side length = size * content_scale.
"""
content_scale = max(0.1, min(content_scale, 1.0))
inner = int(round(size * content_scale))
inner = max(1, min(inner, size))
inner_img = _resize(img, inner, resample)
canvas = Image.new("RGBA", (size, size), background_rgba)
x = (size - inner) // 2
y = (size - inner) // 2
canvas.alpha_composite(inner_img, (x, y))
return canvas
def _write_png(img: Image.Image, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
img.save(path, format="PNG", optimize=True)
def _write_ico(source_square: Image.Image, path: Path, sizes: Iterable[int], resample: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
icon_sizes = [(s, s) for s in sizes]
# Pillow expects the base image to be at least as large as the biggest size.
max_size = max(sizes)
base = _resize(source_square, max_size, resample)
base.save(path, format="ICO", sizes=icon_sizes)
def generate_web(opts: Options, src_square: Image.Image) -> None:
# Minimal, modern set (good coverage without going crazy):
# - favicon.ico (16/32/48)
# - apple-touch-icon.png (180)
# - icon-192.png, icon-512.png
# - icon-maskable-512.png (opaque background + safe padding)
out = opts.out_dir
_write_ico(src_square, out / "favicon.ico", sizes=[16, 32, 48], resample=opts.resample)
_write_png(_resize(src_square, 180, opts.resample), out / "apple-touch-icon.png")
_write_png(_resize(src_square, 192, opts.resample), out / "icon-192.png")
_write_png(_resize(src_square, 512, opts.resample), out / "icon-512.png")
bg = _parse_hex_color(opts.background)
maskable = _fit_with_padding(
img=src_square,
size=512,
content_scale=opts.maskable_scale,
background_rgba=bg,
resample=opts.resample,
)
_write_png(maskable, out / "icon-maskable-512.png")
def generate_ue(opts: Options, src_square: Image.Image) -> None:
out = opts.out_dir
# Windows: multi-size .ico. Minimum recommended set includes 16/24/32/48/256.
_write_ico(src_square, out / "ue" / "windows" / "icon.ico", sizes=[16, 24, 32, 48, 64, 128, 256], resample=opts.resample)
# macOS: generate a .iconset folder; creating .icns requires iconutil on macOS.
iconset_dir = out / "ue" / "mac" / "AppIcon.iconset"
iconset_dir.mkdir(parents=True, exist_ok=True)
# Standard mac iconset filenames.
mac_entries: list[tuple[str, int]] = [
("icon_16x16.png", 16),
("icon_16x16@2x.png", 32),
("icon_32x32.png", 32),
("icon_32x32@2x.png", 64),
("icon_128x128.png", 128),
("icon_128x128@2x.png", 256),
("icon_256x256.png", 256),
("icon_256x256@2x.png", 512),
("icon_512x512.png", 512),
("icon_512x512@2x.png", 1024),
]
for filename, size in mac_entries:
_write_png(_resize(src_square, size, opts.resample), iconset_dir / filename)
# Linux: PNG is typically fine.
_write_png(_resize(src_square, 256, opts.resample), out / "ue" / "linux" / "icon-256.png")
_write_png(_resize(src_square, 512, opts.resample), out / "ue" / "linux" / "icon-512.png")
def _parse_targets(value: str) -> set[str]:
targets = {t.strip().lower() for t in value.split(",") if t.strip()}
allowed = {"web", "ue"}
unknown = targets - allowed
if unknown:
raise ValueError(f"Unknown targets: {', '.join(sorted(unknown))}. Use: web, ue")
return targets
def main() -> int:
parser = argparse.ArgumentParser(description="Generate web/PWA and UE icon assets from a single source image.")
parser.add_argument("--input", required=True, help="Path to source image (PNG recommended; SVG supported with cairosvg).")
parser.add_argument("--out", required=True, help="Output directory.")
parser.add_argument("--targets", default="web", help="Comma-separated: web, ue (default: web)")
parser.add_argument(
"--no-pad",
action="store_true",
help="Do not pad to square. If the input is not square, crop instead.",
)
parser.add_argument(
"--background",
default="#000000",
help="Background color for maskable icons and padding. Use #RRGGBB or #RRGGBBAA (default: #000000)",
)
parser.add_argument(
"--maskable-scale",
type=float,
default=0.80,
help="Scale of the artwork inside maskable icon canvas (default: 0.80).",
)
args = parser.parse_args()
input_path = Path(args.input)
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
targets = _parse_targets(args.targets)
# Pillow resampling: prefer high-quality downsampling.
# Use getattr to satisfy type checkers across Pillow versions/stubs.
resample = (
getattr(getattr(Image, "Resampling", None), "LANCZOS", None)
or getattr(Image, "LANCZOS", None)
or getattr(Image, "ANTIALIAS", None)
or getattr(Image, "BICUBIC", 3)
)
opts = Options(
input_path=input_path,
out_dir=out_dir,
targets=targets,
pad_to_square=not args.no_pad,
background=args.background,
maskable_scale=args.maskable_scale,
resample=resample,
)
bg = _parse_hex_color(opts.background)
src = _load_image(opts.input_path)
src_square = _ensure_square(src, pad_to_square=opts.pad_to_square, background_rgba=bg)
if "web" in opts.targets:
generate_web(opts, src_square)
if "ue" in opts.targets:
generate_ue(opts, src_square)
return 0
if __name__ == "__main__":
raise SystemExit(main())