#!/usr/bin/env python3 """Generate Aligner's macOS app icon assets.""" 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" APPICONSET = RESOURCES / "Assets.xcassets" / "AppIcon.appiconset" ICNS_PATH = RESOURCES / "AppIcon.icns" BASE_SIZE = 1024 SCALE = 4 CANVAS_SIZE = BASE_SIZE * SCALE def s(value: float) -> int: return round(value * SCALE) def sb(bbox: tuple[float, float, float, float]) -> tuple[int, int, int, int]: return tuple(s(value) for value in bbox) 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, } 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) 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 main() -> None: icon = draw_icon() write_icon_family(icon) write_contents_json() write_icns() if __name__ == "__main__": main()