Ariver
2026-06-18 4e9fef02a574f16bb6fe3f7cef4f967e009d2a5d
C3.tools/generate-app-icon.py
@@ -1,241 +1,82 @@
#!/usr/bin/env python3
"""Generate Aligner's macOS app icon assets."""
"""Sync Aligner's macOS app icon assets from the design handoff."""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(__file__).resolve().parents[1]
RESOURCES = ROOT / "C1.source" / "Resources"
OUTPUT_ROOT = Path(__file__).resolve().parents[1]
PROJECT_ROOT = OUTPUT_ROOT.parent
RESOURCES = OUTPUT_ROOT / "C1.source" / "Resources"
APPICONSET = RESOURCES / "Assets.xcassets" / "AppIcon.appiconset"
ICNS_PATH = RESOURCES / "AppIcon.icns"
BASE_SIZE = 1024
SCALE = 4
CANVAS_SIZE = BASE_SIZE * SCALE
HANDOFF_DIR = (
    PROJECT_ROOT
    / "02-P"
    / "Round01-QuickSwitch-MVP"
    / "02.ui设计稿"
    / "AppIcon-D1-BigBars-Handoff-20260604"
)
HANDOFF_ICONSET = HANDOFF_DIR / "AppIcon-D1-BigBars.iconset"
HANDOFF_ICNS = HANDOFF_DIR / "Aligner-AppIcon-D1-BigBars.icns"
HANDOFF_PREVIEW = HANDOFF_DIR / "Aligner-AppIcon-D1-BigBars-1024.png"
ICON_FILES = [
    "icon_16x16.png",
    "icon_16x16@2x.png",
    "icon_32x32.png",
    "icon_32x32@2x.png",
    "icon_128x128.png",
    "icon_128x128@2x.png",
    "icon_256x256.png",
    "icon_256x256@2x.png",
    "icon_512x512.png",
    "icon_512x512@2x.png",
]
def s(value: float) -> int:
    return round(value * SCALE)
def require_file(path: Path) -> None:
    if not path.is_file():
        raise FileNotFoundError(f"missing app icon source: {path}")
def sb(bbox: tuple[float, float, float, float]) -> tuple[int, int, int, int]:
    return tuple(s(value) for value in bbox)
def load_contents_json() -> dict:
    contents_path = HANDOFF_ICONSET / "Contents.json"
    require_file(contents_path)
    with contents_path.open("r", encoding="utf-8") as file:
        return json.load(file)
def rounded_mask(size: tuple[int, int], bbox: tuple[int, int, int, int], radius: int) -> Image.Image:
    mask = Image.new("L", size, 0)
    ImageDraw.Draw(mask).rounded_rectangle(bbox, radius=radius, fill=255)
    return mask
def composite_masked(base: Image.Image, layer: Image.Image, mask: Image.Image) -> None:
    base.alpha_composite(Image.composite(layer, Image.new("RGBA", base.size), mask))
def vertical_gradient(
    size: tuple[int, int],
    top: tuple[int, int, int, int],
    bottom: tuple[int, int, int, int],
) -> Image.Image:
    image = Image.new("RGBA", size)
    draw = ImageDraw.Draw(image)
    height = max(size[1] - 1, 1)
    for y in range(size[1]):
        t = y / height
        color = tuple(round(top[i] * (1 - t) + bottom[i] * t) for i in range(4))
        draw.line((0, y, size[0], y), fill=color)
    return image
def add_shadow(
    base: Image.Image,
    mask: Image.Image,
    color: tuple[int, int, int, int],
    blur: int,
    dx: int,
    dy: int,
) -> None:
    shadow_mask = mask.filter(ImageFilter.GaussianBlur(blur))
    shifted = Image.new("L", base.size, 0)
    shifted.paste(shadow_mask, (dx, dy))
    composite_masked(base, Image.new("RGBA", base.size, color), shifted)
def draw_outer_shell(canvas: Image.Image) -> None:
    bbox = sb((78, 58, 946, 934))
    radius = s(206)
    mask = rounded_mask(canvas.size, bbox, radius)
    add_shadow(canvas, mask, (55, 72, 98, 22), s(16), 0, s(16))
    add_shadow(canvas, mask, (255, 255, 255, 135), s(8), 0, s(-5))
    shell_fill = vertical_gradient(
        canvas.size,
        (253, 254, 255, 250),
        (228, 236, 246, 247),
    )
    composite_masked(canvas, shell_fill, mask)
    edge = ImageDraw.Draw(canvas)
    edge.rounded_rectangle(bbox, radius=radius, outline=(255, 255, 255, 226), width=s(7))
    edge.rounded_rectangle(
        sb((90, 70, 934, 922)),
        radius=radius - s(12),
        outline=(255, 255, 255, 85),
        width=s(2),
    )
    edge.rounded_rectangle(bbox, radius=radius, outline=(168, 184, 206, 32), width=s(2))
def draw_capsule(
    canvas: Image.Image,
    bbox: tuple[int, int, int, int],
    top: tuple[int, int, int, int],
    bottom: tuple[int, int, int, int],
    outer_edge: tuple[int, int, int, int],
    shadow_color: tuple[int, int, int, int],
    shadow_blur: int,
    is_accent: bool = False,
) -> None:
    radius = (bbox[2] - bbox[0]) // 2
    mask = rounded_mask(canvas.size, bbox, radius)
    add_shadow(canvas, mask, shadow_color, shadow_blur, 0, s(16 if not is_accent else 18))
    fill = vertical_gradient(canvas.size, top, bottom)
    composite_masked(canvas, fill, mask)
    draw = ImageDraw.Draw(canvas)
    draw.rounded_rectangle(bbox, radius=radius, outline=(255, 255, 255, 218), width=s(6))
    draw.rounded_rectangle(
        (bbox[0] + s(8), bbox[1] + s(8), bbox[2] - s(8), bbox[3] - s(8)),
        radius=radius - s(9),
        outline=(255, 255, 255, 102 if not is_accent else 76),
        width=s(2),
    )
    draw.rounded_rectangle(bbox, radius=radius, outline=outer_edge, width=s(2))
    if not is_accent:
        shade = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
        shade_draw = ImageDraw.Draw(shade)
        shade_draw.rounded_rectangle(
            (bbox[0] + s(7), bbox[1] + s(12), bbox[0] + s(30), bbox[3] - s(12)),
            radius=radius // 2,
            fill=(136, 158, 184, 22),
        )
        composite_masked(canvas, shade, mask)
def draw_icon() -> Image.Image:
    canvas = Image.new("RGBA", (CANVAS_SIZE, CANVAS_SIZE), (0, 0, 0, 0))
    draw_outer_shell(canvas)
    left_bar = sb((300, 262, 420, 742))
    middle_bar = sb((452, 262, 572, 742))
    accent_bar = sb((604, 262, 724, 742))
    neutral_edge = (136, 158, 184, 58)
    draw_capsule(
        canvas,
        left_bar,
        (238, 245, 252, 226),
        (228, 237, 247, 226),
        neutral_edge,
        (136, 158, 184, 58),
        s(36),
    )
    draw_capsule(
        canvas,
        middle_bar,
        (232, 241, 249, 232),
        (222, 234, 246, 232),
        neutral_edge,
        (136, 158, 184, 66),
        s(38),
    )
    draw_capsule(
        canvas,
        accent_bar,
        (75, 183, 255, 238),
        (20, 145, 255, 238),
        (28, 133, 255, 72),
        (25, 118, 210, 62),
        s(20),
        is_accent=True,
    )
    return canvas.resize((BASE_SIZE, BASE_SIZE), Image.Resampling.LANCZOS)
def write_contents_json() -> None:
    entries = [
        ("16x16", "1x", "icon_16x16.png"),
        ("16x16", "2x", "icon_16x16@2x.png"),
        ("32x32", "1x", "icon_32x32.png"),
        ("32x32", "2x", "icon_32x32@2x.png"),
        ("128x128", "1x", "icon_128x128.png"),
        ("128x128", "2x", "icon_128x128@2x.png"),
        ("256x256", "1x", "icon_256x256.png"),
        ("256x256", "2x", "icon_256x256@2x.png"),
        ("512x512", "1x", "icon_512x512.png"),
        ("512x512", "2x", "icon_512x512@2x.png"),
    ]
    payload = {
        "images": [
            {"filename": filename, "idiom": "mac", "scale": scale, "size": size}
            for size, scale, filename in entries
        ],
        "info": {"author": "xcode", "version": 1},
    }
    (APPICONSET / "Contents.json").write_text(json.dumps(payload, indent=2) + "\n")
def write_icon_family(source: Image.Image) -> None:
    sizes = {
        "icon_16x16.png": 16,
        "icon_16x16@2x.png": 32,
        "icon_32x32.png": 32,
        "icon_32x32@2x.png": 64,
        "icon_128x128.png": 128,
        "icon_128x128@2x.png": 256,
        "icon_256x256.png": 256,
        "icon_256x256@2x.png": 512,
        "icon_512x512.png": 512,
        "icon_512x512@2x.png": 1024,
    }
def sync_iconset() -> None:
    APPICONSET.mkdir(parents=True, exist_ok=True)
    source.save(APPICONSET / "icon_1024_preview.png")
    for filename, size in sizes.items():
        source.resize((size, size), Image.Resampling.LANCZOS).save(APPICONSET / filename)
    contents = load_contents_json()
    for filename in ICON_FILES:
        source = HANDOFF_ICONSET / filename
        require_file(source)
        shutil.copy2(source, APPICONSET / filename)
    require_file(HANDOFF_PREVIEW)
    shutil.copy2(HANDOFF_PREVIEW, APPICONSET / "icon_1024_preview.png")
    with (APPICONSET / "Contents.json").open("w", encoding="utf-8") as file:
        json.dump(contents, file, indent=2, ensure_ascii=False)
        file.write("\n")
def write_icns() -> None:
    iconset = RESOURCES / "AppIcon.iconset"
    if iconset.exists():
        shutil.rmtree(iconset)
    iconset.mkdir()
    for icon in APPICONSET.glob("icon_*.png"):
        if icon.name == "icon_1024_preview.png":
            continue
        shutil.copy2(icon, iconset / icon.name)
    subprocess.run(["iconutil", "-c", "icns", str(iconset), "-o", str(ICNS_PATH)], check=True)
    shutil.rmtree(iconset)
def sync_icns() -> None:
    require_file(HANDOFF_ICNS)
    shutil.copy2(HANDOFF_ICNS, ICNS_PATH)
def main() -> None:
    icon = draw_icon()
    write_icon_family(icon)
    write_contents_json()
    write_icns()
    sync_iconset()
    sync_icns()
    print(f"Synced AppIcon assets from: {HANDOFF_DIR}")
if __name__ == "__main__":