"""Regenerate every platform icon from the master logo. Source of truth: assets/icon/app_icon.jpg (2048x2048). Run from anywhere: python assets/icon/generate_icons.py Produces: - Web: favicon.png, icons/Icon-{192,512}.png, icons/Icon-maskable-{192,512}.png - Android: mipmap-*/ic_launcher.png (legacy) + adaptive icons (mipmap-anydpi-v26, drawable/ic_launcher_background.xml, drawable-*/ic_launcher_foreground.png) - iOS: every AppIcon.appiconset PNG referenced by Contents.json (opaque, no alpha) - macOS: app_icon_{16..1024}.png (transparent corners, system masks them) - Windows: windows/runner/resources/app_icon.ico (16-256 px, multi-resolution) The logo is a full-bleed circle, so standard icons get a feathered circular alpha mask (removes the JPEG's white corners); maskable/adaptive variants scale the circle into the safe zone over the brand sage so any mask crop stays on-brand. """ from pathlib import Path from PIL import Image, ImageDraw, ImageFilter ROOT = Path(__file__).resolve().parents[2] SRC = Path(__file__).parent / "app_icon.jpg" # Brand colors sampled from the master logo. SAGE_RGB = (212, 228, 199) # outer ring / maskable background TEAL_RGB = (55, 90, 83) # inner circle (used for web manifest theme) RESAMPLE = Image.LANCZOS def circle_rgba(img: Image.Image) -> Image.Image: """Return RGBA with a feathered circular alpha mask (design circle spans the full square).""" rgba = img.convert("RGBA") n = rgba.size[0] mask = Image.new("L", (n, n), 0) ImageDraw.Draw(mask).ellipse((0, 0, n - 1, n - 1), fill=255) mask = mask.filter(ImageFilter.GaussianBlur(radius=max(1.0, n / 512))) rgba.putalpha(mask) return rgba def fit(img: Image.Image, size: int) -> Image.Image: return img.resize((size, size), RESAMPLE) def save(img: Image.Image, rel: str) -> Path: out = ROOT / rel out.parent.mkdir(parents=True, exist_ok=True) img.save(out) print(f" {rel} {img.size[0]}x{img.size[1]}") return out def main() -> None: master = Image.open(SRC) if master.size != (2048, 2048): raise SystemExit(f"master must be 2048x2048, got {master.size}") master_rgb = master.convert("RGB") full_circle = circle_rgba(master_rgb) # transparent corners, brand edge intact print("Web:") save(fit(full_circle, 32), "web/favicon.png") save(fit(full_circle, 192), "web/icons/Icon-192.png") save(fit(full_circle, 512), "web/icons/Icon-512.png") for s in (192, 512): # maskable: logo inside the 80% safe circle on brand sage canvas = Image.new("RGB", (s, s), SAGE_RGB) logo = fit(full_circle, int(s * 0.8)) canvas.paste(logo, ((s - logo.size[0]) // 2, (s - logo.size[1]) // 2), logo) save(canvas, f"web/icons/Icon-maskable-{s}.png") print("Android (legacy densities):") for dpi, px in {"mdpi": 48, "hdpi": 72, "xhdpi": 96, "xxhdpi": 144, "xxxhdpi": 192}.items(): save(fit(full_circle, px), f"android/app/src/main/res/mipmap-{dpi}/ic_launcher.png") print("Android (adaptive icons):") # Foreground: 108 dp canvas, circular logo confined to the 66 dp safe circle. for dpi, px in {"mdpi": 108, "hdpi": 162, "xhdpi": 216, "xxhdpi": 324, "xxxhdpi": 432}.items(): canvas = Image.new("RGBA", (px, px), (0, 0, 0, 0)) logo = fit(full_circle, int(px * 66 / 108)) canvas.paste(logo, ((px - logo.size[0]) // 2, (px - logo.size[1]) // 2), logo) save(canvas, f"android/app/src/main/res/drawable-{dpi}/ic_launcher_foreground.png") bg_xml = ROOT / "android/app/src/main/res/drawable/ic_launcher_background.xml" bg_xml.parent.mkdir(parents=True, exist_ok=True) bg_xml.write_text( '\n' ' \n' "\n" % SAGE_RGB, encoding="utf-8", ) print(" android/app/src/main/res/drawable/ic_launcher_background.xml") adaptive_xml = ( '\n' ' \n' ' \n' "\n" ) for name in ("ic_launcher.xml", "ic_launcher_round.xml"): p = ROOT / "android/app/src/main/res/mipmap-anydpi-v26" / name p.parent.mkdir(parents=True, exist_ok=True) p.write_text(adaptive_xml, encoding="utf-8") print(f" android/app/src/main/res/mipmap-anydpi-v26/{name}") print("iOS (opaque - App Store rejects alpha channels):") ios_dir = "ios/Runner/Assets.xcassets/AppIcon.appiconset" ios_sizes = { "Icon-App-20x20@1x.png": 20, "Icon-App-20x20@2x.png": 40, "Icon-App-20x20@3x.png": 60, "Icon-App-29x29@1x.png": 29, "Icon-App-29x29@2x.png": 58, "Icon-App-29x29@3x.png": 87, "Icon-App-40x40@1x.png": 40, "Icon-App-40x40@2x.png": 80, "Icon-App-40x40@3x.png": 120, "Icon-App-60x60@2x.png": 120, "Icon-App-60x60@3x.png": 180, "Icon-App-76x76@1x.png": 76, "Icon-App-76x76@2x.png": 152, "Icon-App-83.5x83.5@2x.png": 167, "Icon-App-1024x1024@1x.png": 1024, } for name, px in ios_sizes.items(): fit(master_rgb, px).save(ROOT / ios_dir / name, "PNG") print(f" {ios_dir}/{name} {px}x{px}") print("macOS:") mac_dir = "macos/Runner/Assets.xcassets/AppIcon.appiconset" for px in (16, 32, 64, 128, 256, 512, 1024): save(fit(full_circle, px), f"{mac_dir}/app_icon_{px}.png") print("Windows:") ico_path = ROOT / "windows/runner/resources/app_icon.ico" fit(full_circle, 256).save( ico_path, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)], ) print(" windows/runner/resources/app_icon.ico frames: 16-256 px") # Sanity checks on the 512 web icon and the ICO. chk = Image.open(ROOT / "web/icons/Icon-512.png").convert("RGBA") c = chk.size[0] // 2 r, g, b, a = chk.getpixel((c, c)) assert g > r and a == 255, f"center should be opaque teal, got {(r, g, b, a)}" assert chk.getpixel((2, 2))[3] == 0, "corner should be transparent" assert Image.open(ico_path).format == "ICO" print("OK: all icons generated and sanity-checked.") if __name__ == "__main__": main()