| | |
| | | #!/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__": |