#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.11" # dependencies = [ # "cairosvg>=2.7", # "pillow>=10", # ] # /// """Render the AT-IRC SVG app icons into the asset catalog. Usage: uv run Scripts/render_app_icon.py The SVG masters live in Icon/. iOS uses Xcode's single-size app icon format, so this writes three 1024x1024 PNGs (default, dark, tinted) and the matching Contents.json; Xcode derives every device size from them. The default icon is flattened to RGB because App Store icons must not have an alpha channel. The dark and tinted variants intentionally keep their transparent backgrounds, as Apple recommends for those appearances. Rendering prefers cairosvg. If cairo is unavailable (common on macOS without a matching library), it falls back to Homebrew's rsvg-convert. """ from __future__ import annotations import json import shutil import subprocess import sys import tempfile from io import BytesIO from pathlib import Path from PIL import Image SIZE = 1024 REPO_ROOT = Path(__file__).resolve().parent.parent SOURCE_DIR = REPO_ROOT / "Icon" ASSET_DIR = REPO_ROOT / "AT-IRC" / "Assets.xcassets" / "AppIcon.appiconset" # (source SVG, output PNG, must be fully opaque) ICONS = ( ("AppIcon.svg", "AppIcon-1024.png", True), ("AppIcon-dark.svg", "AppIcon-dark-1024.png", False), ("AppIcon-tinted.svg", "AppIcon-tinted-1024.png", False), ) CONTENTS = { "images": [ { "filename": "AppIcon-1024.png", "idiom": "universal", "platform": "ios", "size": "1024x1024", }, { "appearances": [{"appearance": "luminosity", "value": "dark"}], "filename": "AppIcon-dark-1024.png", "idiom": "universal", "platform": "ios", "size": "1024x1024", }, { "appearances": [{"appearance": "luminosity", "value": "tinted"}], "filename": "AppIcon-tinted-1024.png", "idiom": "universal", "platform": "ios", "size": "1024x1024", }, ], "info": {"author": "xcode", "version": 1}, } def render_with_cairosvg(svg_path: Path) -> bytes | None: try: import cairosvg except Exception: return None try: return cairosvg.svg2png( url=str(svg_path), output_width=SIZE, output_height=SIZE ) except Exception: return None def render_with_rsvg(svg_path: Path) -> bytes: rsvg = shutil.which("rsvg-convert") if rsvg is None: raise SystemExit( "No SVG renderer available. Install cairo (for cairosvg) or " "librsvg with `brew install librsvg`." ) with tempfile.TemporaryDirectory() as tmp: output = Path(tmp) / "icon.png" subprocess.run( [rsvg, "-w", str(SIZE), "-h", str(SIZE), "-o", str(output), str(svg_path)], check=True, capture_output=True, text=True, ) return output.read_bytes() def render(svg_path: Path) -> tuple[bytes, str]: data = render_with_cairosvg(svg_path) if data is not None: return data, "cairosvg" return render_with_rsvg(svg_path), "rsvg-convert" def write_png(data: bytes, destination: Path, opaque: bool) -> None: with Image.open(BytesIO(data)) as image: image.load() if image.size != (SIZE, SIZE): raise SystemExit( f"{destination.name}: expected {SIZE}x{SIZE}, got {image.size[0]}x{image.size[1]}" ) if opaque: rgba = image.convert("RGBA") if rgba.getchannel("A").getextrema()[0] < 255: raise SystemExit( f"{destination.name}: default icons must be fully opaque" ) image = rgba.convert("RGB") elif image.mode != "RGBA": image = image.convert("RGBA") image.save(destination, "PNG", optimize=True) def main() -> int: if not SOURCE_DIR.is_dir(): raise SystemExit(f"Missing icon source directory: {SOURCE_DIR}") ASSET_DIR.mkdir(parents=True, exist_ok=True) for source_name, output_name, opaque in ICONS: source = SOURCE_DIR / source_name if not source.is_file(): raise SystemExit(f"Missing icon source: {source}") data, renderer = render(source) destination = ASSET_DIR / output_name write_png(data, destination, opaque) print(f"{source_name} -> {output_name} ({renderer})") contents_path = ASSET_DIR / "Contents.json" contents_path.write_text(json.dumps(CONTENTS, indent=2) + "\n", encoding="utf-8") print(f"wrote {contents_path.relative_to(REPO_ROOT)}") return 0 if __name__ == "__main__": sys.exit(main())