~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atirc-ios-client

0
1
main
155 lines · 4.7 KB Raw History
#!/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())