450 lines
16 KiB
Python
Executable file
450 lines
16 KiB
Python
Executable file
#!/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)
|