#!/usr/bin/env python3 """Generate Burrow app icons from locked raster panel masters.""" from __future__ import annotations import json import shutil import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] BRAND = ROOT / "design" / "brand" PANEL_DIR = BRAND / "panel-variants" APPLE_ASSETS = ROOT / "Apple" / "UI" / "Assets.xcassets" APPICON_SET = APPLE_ASSETS / "AppIcon.appiconset" WATCHOS_BRAND_APPICON_SET = BRAND / "watchos" / "AppIcon.appiconset" ANDROID_RES = ROOT / "Android" / "app" / "src" / "main" / "res" GTK_ICONS = ROOT / "burrow-gtk" / "data" / "icons" / "hicolor" VARIANTS = [ ("panel-01-guardian", "BurrowPanel01", "Guardian", "top-left panel"), ("panel-02-field", "BurrowPanel02", "Field", "top-middle panel"), ("panel-03-burrow", "BurrowPanel03", "Burrow", "top-right panel"), ("panel-04-sentinel", "BurrowPanel04", "Sentinel", "bottom-left panel"), ("panel-05-stride", "BurrowPanel05", "Stride", "bottom-middle panel"), ("panel-06-post", "BurrowPanel06", "Post", "bottom-right panel"), ] PRIMARY_IOS = "panel-01-guardian" PRIMARY_MACOS = "panel-05-stride" PRIMARY_WATCHOS = "panel-03-burrow" PRIMARY_ANDROID = PRIMARY_IOS PRIMARY_LINUX = PRIMARY_IOS # Rough cells in the supplied 2928x1896 panel screenshot. Extraction trims the # white sheet background, makes the crop square with transparent padding, then # normalizes to 1024px. EXTRACTION_CELLS = { "panel-01-guardian": (40, 40, 900, 900), "panel-02-field": (940, 40, 900, 900), "panel-03-burrow": (1840, 40, 900, 900), "panel-04-sentinel": (40, 980, 900, 900), "panel-05-stride": (940, 980, 900, 900), "panel-06-post": (1840, 980, 900, 900), } IOS_ROWS = [ ("iphone", "20x20", "2x", 40, {}), ("iphone", "20x20", "3x", 60, {}), ("iphone", "29x29", "1x", 29, {}), ("iphone", "29x29", "2x", 58, {}), ("iphone", "29x29", "3x", 87, {}), ("iphone", "40x40", "2x", 80, {}), ("iphone", "40x40", "3x", 120, {}), ("iphone", "57x57", "1x", 57, {}), ("iphone", "57x57", "2x", 114, {}), ("iphone", "60x60", "2x", 120, {}), ("iphone", "60x60", "3x", 180, {}), ("ipad", "20x20", "1x", 20, {}), ("ipad", "20x20", "2x", 40, {}), ("ipad", "29x29", "1x", 29, {}), ("ipad", "29x29", "2x", 58, {}), ("ipad", "40x40", "1x", 40, {}), ("ipad", "40x40", "2x", 80, {}), ("ipad", "50x50", "1x", 50, {}), ("ipad", "50x50", "2x", 100, {}), ("ipad", "72x72", "1x", 72, {}), ("ipad", "72x72", "2x", 144, {}), ("ipad", "76x76", "1x", 76, {}), ("ipad", "76x76", "2x", 152, {}), ("ipad", "83.5x83.5", "2x", 167, {}), ("ios-marketing", "1024x1024", "1x", 1024, {}), ] MAC_ROWS = [ ("mac", "16x16", "1x", 16, {}), ("mac", "16x16", "2x", 32, {}), ("mac", "32x32", "1x", 32, {}), ("mac", "32x32", "2x", 64, {}), ("mac", "128x128", "1x", 128, {}), ("mac", "128x128", "2x", 256, {}), ("mac", "256x256", "1x", 256, {}), ("mac", "256x256", "2x", 512, {}), ("mac", "512x512", "1x", 512, {}), ("mac", "512x512", "2x", 1024, {}), ] WATCH_ROWS = [ ("watch", "24x24", "2x", 48, {"role": "notificationCenter", "subtype": "38mm"}), ("watch", "27.5x27.5", "2x", 55, {"role": "notificationCenter", "subtype": "42mm"}), ("watch", "33x33", "2x", 66, {"role": "notificationCenter", "subtype": "45mm"}), ("watch", "29x29", "2x", 58, {"role": "companionSettings"}), ("watch", "29x29", "3x", 87, {"role": "companionSettings"}), ("watch", "40x40", "2x", 80, {"role": "appLauncher", "subtype": "38mm"}), ("watch", "44x44", "2x", 88, {"role": "appLauncher", "subtype": "40mm"}), ("watch", "46x46", "2x", 92, {"role": "appLauncher", "subtype": "41mm"}), ("watch", "50x50", "2x", 100, {"role": "appLauncher", "subtype": "44mm"}), ("watch", "51x51", "2x", 102, {"role": "appLauncher", "subtype": "45mm"}), ("watch", "54x54", "2x", 108, {"role": "appLauncher", "subtype": "49mm"}), ("watch", "86x86", "2x", 172, {"role": "quickLook", "subtype": "38mm"}), ("watch", "98x98", "2x", 196, {"role": "quickLook", "subtype": "42mm"}), ("watch", "108x108", "2x", 216, {"role": "quickLook", "subtype": "44mm"}), ("watch", "117x117", "2x", 234, {"role": "quickLook", "subtype": "45mm"}), ("watch", "129x129", "2x", 258, {"role": "quickLook", "subtype": "49mm"}), ("watch-marketing", "1024x1024", "1x", 1024, {}), ] ANDROID_MIPMAPS = { "mipmap-mdpi": 48, "mipmap-hdpi": 72, "mipmap-xhdpi": 96, "mipmap-xxhdpi": 144, "mipmap-xxxhdpi": 192, } LINUX_SIZES = [16, 24, 32, 48, 64, 128, 256, 512] def run(args: list[str]) -> None: subprocess.run(args, cwd=ROOT, check=True) def output(args: list[str]) -> str: return subprocess.check_output(args, cwd=ROOT, text=True).strip() def write_text(path: Path, text: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") def write_json(path: Path, value: object) -> None: write_text(path, json.dumps(value, indent=2) + "\n") def variant(asset_id: str) -> tuple[str, str, str, str]: for item in VARIANTS: if item[0] == asset_id: return item raise KeyError(asset_id) def master_path(asset_id: str) -> Path: return PANEL_DIR / f"{asset_id}.png" def extract_masters(sheet: Path) -> None: PANEL_DIR.mkdir(parents=True, exist_ok=True) shutil.copyfile(sheet, PANEL_DIR / "source-sheet.png") for asset_id, (x, y, w, h) in EXTRACTION_CELLS.items(): tmp = PANEL_DIR / f".{asset_id}-trim.png" dest = master_path(asset_id) run( [ "magick", str(sheet), "-crop", f"{w}x{h}+{x}+{y}", "-fuzz", "8%", "-trim", "+repage", str(tmp), ] ) geometry = output(["magick", str(tmp), "-format", "%w %h", "info:"]) width, height = [int(part) for part in geometry.split()] extent = max(width, height) run( [ "magick", str(tmp), "-alpha", "set", "-fuzz", "6%", "-fill", "none", "-draw", "color 0,0 floodfill", "-draw", f"color {width - 1},0 floodfill", "-draw", f"color 0,{height - 1} floodfill", "-draw", f"color {width - 1},{height - 1} floodfill", "-background", "none", "-gravity", "center", "-extent", f"{extent}x{extent}", "-resize", "1024x1024!", "(", "-size", "1024x1024", "xc:none", "-fill", "white", "-draw", "roundrectangle 10,10 1013,1013 166,166", ")", "-compose", "DstIn", "-composite", "-strip", f"PNG32:{dest}", ] ) tmp.unlink() def ensure_masters() -> None: missing = [asset_id for asset_id, *_ in VARIANTS if not master_path(asset_id).exists()] if missing: raise SystemExit(f"missing panel masters: {', '.join(missing)}") def clean_generated() -> None: APPICON_SET.mkdir(parents=True, exist_ok=True) for path in APPICON_SET.glob("*.png"): path.unlink() for path in APPLE_ASSETS.glob("BurrowPanel*.appiconset"): shutil.rmtree(path) for path in APPLE_ASSETS.glob("BurrowPanel*Preview.imageset"): shutil.rmtree(path) shutil.rmtree(WATCHOS_BRAND_APPICON_SET, ignore_errors=True) low_res = BRAND / "low-res" low_res.mkdir(parents=True, exist_ok=True) for path in low_res.glob("*.png"): path.unlink() report = low_res / "report.csv" if report.exists(): report.unlink() for folder in ANDROID_MIPMAPS: shutil.rmtree(ANDROID_RES / folder, ignore_errors=True) for size in LINUX_SIZES: shutil.rmtree(GTK_ICONS / str(size), ignore_errors=True) for path in PANEL_DIR.glob("*-preview.png"): path.unlink() preview = PANEL_DIR / "preview-strip.png" if preview.exists(): preview.unlink() def render_png(source: Path, dest: Path, size: int, *, png32: bool = True) -> None: dest.parent.mkdir(parents=True, exist_ok=True) prefix = "PNG32:" if png32 else "PNG24:" run( [ "magick", str(source), "-resize", f"{size}x{size}!", "-strip", f"{prefix}{dest}", ] ) def image_entry(idiom: str, size: str, scale: str, filename: str, extra: dict[str, str]) -> dict[str, str]: row = {"filename": filename, "idiom": idiom, "scale": scale, "size": size} row.update(extra) return row def write_appicon_contents(path: Path, rows: list[dict[str, str]]) -> None: write_json(path / "Contents.json", {"images": rows, "info": {"author": "xcode", "version": 1}}) def generate_appicon_set( path: Path, source: Path, prefix: str, rows_def: list[tuple[str, str, str, int, dict[str, str]]], ) -> list[dict[str, str]]: path.mkdir(parents=True, exist_ok=True) rows = [] rendered: set[tuple[str, int]] = set() for idiom, size_label, scale, pixels, extra in rows_def: filename = f"{prefix}-{pixels}.png" rows.append(image_entry(idiom, size_label, scale, filename, extra)) key = (filename, pixels) if key not in rendered: render_png(source, path / filename, pixels) rendered.add(key) return rows def generate_apple_assets() -> None: primary_rows = [] primary_rows.extend(generate_appicon_set(APPICON_SET, master_path(PRIMARY_IOS), "ios-panel-01", IOS_ROWS)) primary_rows.extend(generate_appicon_set(APPICON_SET, master_path(PRIMARY_MACOS), "mac-panel-05", MAC_ROWS)) write_appicon_contents(APPICON_SET, primary_rows) watch_rows = generate_appicon_set( WATCHOS_BRAND_APPICON_SET, master_path(PRIMARY_WATCHOS), "watch-panel-03", WATCH_ROWS, ) write_appicon_contents(WATCHOS_BRAND_APPICON_SET, watch_rows) for asset_id, asset_name, _, _ in VARIANTS[1:]: set_path = APPLE_ASSETS / f"{asset_name}.appiconset" rows = generate_appicon_set(set_path, master_path(asset_id), asset_name, IOS_ROWS) write_appicon_contents(set_path, rows) for asset_id, asset_name, _, _ in VARIANTS: preview_dir = APPLE_ASSETS / f"{asset_name}Preview.imageset" preview_dir.mkdir(parents=True, exist_ok=True) render_png(master_path(asset_id), preview_dir / "preview.png", 256) write_json( preview_dir / "Contents.json", { "images": [{"filename": "preview.png", "idiom": "universal", "scale": "1x"}], "info": {"author": "xcode", "version": 1}, }, ) def generate_brand_outputs() -> None: primary = master_path(PRIMARY_IOS) render_png(primary, BRAND / "burrow-owl-icon-master.png", 1024) render_png(primary, BRAND / "burrowing-owl-icon-flat.png", 1024) render_png(primary, BRAND / "Burrow.icon" / "Assets" / "owl-figure.png", 1024) render_png(primary, BRAND / "Burrow.icon" / "Assets" / "owl-body.png", 1024) transparent = BRAND / "Burrow.icon" / "Assets" / "transparent.png" run(["magick", "-size", "1024x1024", "xc:none", f"PNG32:{transparent}"]) for name in ["burrow-ground", "owl-face", "owl-legs", "owl-spots"]: shutil.copyfile(transparent, BRAND / "Burrow.icon" / "Assets" / f"{name}.png") transparent.unlink() write_json( BRAND / "Burrow.icon" / "icon.json", { "fill": {"type": "solid", "color": "#00000000"}, "layers": [ { "blend-mode": "normal", "image-name": "owl-figure.png", "name": "panel-raster", "position": {"scale": 1, "translation-in-points": [0, 0]}, } ], }, ) low_res = BRAND / "low-res" report_rows = ["size,colors,gray_stddev,status"] preview_inputs = [] pixel_inputs = [] for size in [16, 20, 24, 29, 32, 40, 48, 64, 80, 128, 256, 1024]: icon = low_res / f"burrow-icon-{size}.png" render_png(primary, icon, size) if size <= 256: colors = output(["magick", str(icon), "-format", "%k", "info:"]) stddev = output(["magick", str(icon), "-colorspace", "Gray", "-format", "%[fx:standard_deviation]", "info:"]) status = "ok" if int(colors) >= 8 and float(stddev) >= 0.025 else "check" report_rows.append(f"{size},{colors},{stddev},{status}") preview = low_res / f"preview-{size}.png" filter_name = "Point" if size <= 32 else "Lanczos" run(["magick", str(icon), "-filter", filter_name, "-resize", "128x128", str(preview)]) preview_inputs.append(preview) if size <= 32: pixel = low_res / f"pixel-preview-{size}.png" run(["magick", str(icon), "-filter", "Point", "-resize", "128x128", str(pixel)]) pixel_inputs.append(pixel) write_text(low_res / "report.csv", "\n".join(report_rows) + "\n") preview_inputs.reverse() pixel_inputs.reverse() run(["magick", "-background", "#202020", "-gravity", "center", *map(str, preview_inputs), "+append", str(low_res / "preview-strip.png")]) run(["magick", "-background", "#202020", "-gravity", "center", *map(str, pixel_inputs), "+append", str(low_res / "pixel-preview-strip.png")]) variant_previews = [] for asset_id, _, _, _ in VARIANTS: preview = PANEL_DIR / f"{asset_id}-preview.png" render_png(master_path(asset_id), preview, 256) variant_previews.append(preview) run(["magick", "-background", "#fbfaf6", "-gravity", "center", *map(str, variant_previews), "+append", str(PANEL_DIR / "preview-strip.png")]) lines = [ "# Burrow Raster Panel Icon Variants", "", "The six PNG masters here are cropped from `source-sheet.png` and are the", "locked app icon source. Do not redraw them; regenerate package assets from", "these rasters.", "", "| Variant | Use | Source |", "| --- | --- | --- |", ] for asset_id, _, title, source in VARIANTS: uses = [] if asset_id == PRIMARY_IOS: uses.append("iOS primary") else: uses.append("iOS alternate") if asset_id == PRIMARY_MACOS: uses.append("macOS primary") if asset_id == PRIMARY_WATCHOS: uses.append("watchOS primary") if asset_id == PRIMARY_ANDROID: uses.append("Android primary") if asset_id == PRIMARY_LINUX: uses.append("Linux primary") use_text = ", ".join(uses) lines.append(f"| `{asset_id}` | {use_text} | {title}, {source} |") lines.append("") lines.append("`preview-strip.png` shows the six raster masters side by side.") write_text(PANEL_DIR / "README.md", "\n".join(lines) + "\n") def generate_android_assets() -> None: primary = master_path(PRIMARY_ANDROID) for folder, size in ANDROID_MIPMAPS.items(): out_dir = ANDROID_RES / folder render_png(primary, out_dir / "ic_launcher.png", size) render_png(primary, out_dir / "ic_launcher_round.png", size) render_png(primary, ROOT / "store-metadata" / "android" / "icon.png", 512) def generate_linux_assets() -> None: primary = master_path(PRIMARY_LINUX) for size in LINUX_SIZES: render_png(primary, GTK_ICONS / str(size) / "apps" / "burrow-gtk.png", size) def main(argv: list[str]) -> None: if len(argv) == 3 and argv[1] == "--extract-from": extract_masters(Path(argv[2])) elif len(argv) != 1: raise SystemExit("usage: generate-brand-icons.py [--extract-from /path/to/panel-sheet.png]") ensure_masters() clean_generated() generate_apple_assets() generate_brand_outputs() generate_android_assets() generate_linux_assets() if __name__ == "__main__": main(sys.argv)