make_icon.py
1 #!/usr/bin/env python3 2 """Generate Autoreview app icon (PNG iconset + optional .icns via iconutil).""" 3 from __future__ import annotations 4 5 import argparse 6 import subprocess 7 import sys 8 from pathlib import Path 9 10 try: 11 from PIL import Image, ImageDraw, ImageFont 12 except ImportError: 13 print("Install Pillow: pip install Pillow", file=sys.stderr) 14 sys.exit(1) 15 16 REPO = Path(__file__).resolve().parent.parent 17 ASSETS = REPO / "assets" 18 19 # Tokyo Night–adjacent tile colors (match GUI chrome) 20 COLOR_TILE_FILL = (36, 40, 59, 255) 21 COLOR_TILE_OUTLINE = (122, 162, 247, 255) 22 COLOR_TEXT = (192, 202, 245, 255) 23 24 # filename -> pixel dimension (square) 25 MAC_ICONSET: list[tuple[str, int]] = [ 26 ("icon_16x16.png", 16), 27 ("icon_16x16@2x.png", 32), 28 ("icon_32x32.png", 32), 29 ("icon_32x32@2x.png", 64), 30 ("icon_128x128.png", 128), 31 ("icon_128x128@2x.png", 256), 32 ("icon_256x256.png", 256), 33 ("icon_256x256@2x.png", 512), 34 ("icon_512x512.png", 512), 35 ("icon_512x512@2x.png", 1024), 36 ] 37 38 # Draw once at high resolution, then downscale for crisp edges 39 _BASE_SIZE = 1024 40 _cached_base: Image.Image | None = None 41 _font_cache: dict[int, ImageFont.ImageFont | ImageFont.FreeTypeFont] = {} 42 43 44 def _mono_font_at(font_px: int) -> ImageFont.ImageFont | ImageFont.FreeTypeFont: 45 """Monospace font at ``font_px`` pt; cached by pixel size.""" 46 font_px = max(6, int(font_px)) 47 if font_px not in _font_cache: 48 for path in ( 49 "/System/Library/Fonts/SFNSMono.ttf", 50 "/Library/Fonts/Courier New.ttf", 51 ): 52 try: 53 _font_cache[font_px] = ImageFont.truetype(path, font_px) 54 break 55 except OSError: 56 continue 57 else: 58 _font_cache[font_px] = ImageFont.load_default() 59 return _font_cache[font_px] 60 61 62 def _font_for_text_in_circle( 63 draw: ImageDraw.ImageDraw, 64 text: str, 65 mr: int, 66 ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont: 67 """Largest monospace size so ``text`` fits comfortably inside a circle of radius ``mr``.""" 68 # Inscribed box inside the lens; stay inward from the rim (pad + small inner margin). 69 pad = max(3, int(mr * 0.08)) 70 inner = max(4, int(mr * 0.035)) 71 max_w = max(8, int(2 * mr * 0.42) - pad - inner) 72 max_h = max(8, int(2 * mr * 0.38) - pad - inner) 73 # Slightly undershoot limits so glyphs are not mathematically flush to the box edge. 74 lim_slack = max(2, int(mr * 0.03)) 75 lim_w, lim_h = max_w - lim_slack, max_h - lim_slack 76 lo, hi = 6, max(14, mr * 3) 77 best: ImageFont.ImageFont | ImageFont.FreeTypeFont = _mono_font_at(6) 78 while lo <= hi: 79 mid = (lo + hi) // 2 80 font = _mono_font_at(mid) 81 bbox = draw.textbbox((0, 0), text, font=font) 82 tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] 83 if tw <= lim_w and th <= lim_h: 84 best = font 85 lo = mid + 1 86 else: 87 hi = mid - 1 88 return best 89 90 91 def _draw_at(size: int) -> Image.Image: 92 """Dark rounded tile with magnifier + </> motif at ``size``×``size``.""" 93 img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) 94 draw = ImageDraw.Draw(img) 95 pad = max(2, size // 14) 96 rad = max(6, size // 6) 97 draw.rounded_rectangle( 98 [pad, pad, size - pad, size - pad], 99 radius=rad, 100 fill=COLOR_TILE_FILL, 101 outline=COLOR_TILE_OUTLINE, 102 width=max(1, size // 64), 103 ) 104 mr = max(4, size // 7) 105 cx, cy = size // 2 - mr // 3, size // 2 - mr // 3 106 draw.ellipse( 107 [cx - mr, cy - mr, cx + mr, cy + mr], 108 outline=COLOR_TILE_OUTLINE, 109 width=max(1, size // 48), 110 ) 111 hx0, hy0 = cx + int(mr * 0.55), cy + int(mr * 0.55) 112 hx1 = hx0 + int(mr * 0.9) 113 hy1 = hy0 + int(mr * 0.9) 114 draw.line([(hx0, hy0), (hx1, hy1)], fill=COLOR_TILE_OUTLINE, width=max(2, size // 28)) 115 t = "</>" 116 font = _font_for_text_in_circle(draw, t, mr) 117 # Align with lens center (cx, cy), not tile center — matches magnifier geometry. 118 oy = max(1, size // 128) # slight downward nudge: monospace often looks optically high 119 draw.text((cx, cy + oy), t, fill=COLOR_TEXT, font=font, anchor="mm") 120 return img 121 122 123 def draw_icon(size: int) -> Image.Image: 124 """Return icon bitmap; large master is drawn once and resized for smaller sizes.""" 125 global _cached_base 126 if _cached_base is None: 127 _cached_base = _draw_at(_BASE_SIZE) 128 if size == _BASE_SIZE: 129 return _cached_base.copy() 130 return _cached_base.resize((size, size), Image.Resampling.LANCZOS) 131 132 133 def main() -> None: 134 parser = argparse.ArgumentParser(description="Generate Autoreview PNG iconset and optional .icns.") 135 parser.add_argument( 136 "--assets-dir", 137 type=Path, 138 default=ASSETS, 139 help=f"Output directory (default: {ASSETS})", 140 ) 141 args = parser.parse_args() 142 assets_dir = args.assets_dir.resolve() 143 iconset_dir = assets_dir / "Autoreview.iconset" 144 145 assets_dir.mkdir(parents=True, exist_ok=True) 146 draw_icon(256).save(assets_dir / "app_icon_256.png") 147 148 iconset_dir.mkdir(parents=True, exist_ok=True) 149 for name, dim in MAC_ICONSET: 150 draw_icon(dim).save(iconset_dir / name) 151 152 icns_path = assets_dir / "Autoreview.icns" 153 try: 154 subprocess.run( 155 ["iconutil", "-c", "icns", str(iconset_dir), "-o", str(icns_path)], 156 check=True, 157 capture_output=True, 158 ) 159 print(f"Wrote {icns_path}") 160 except (subprocess.CalledProcessError, FileNotFoundError) as e: 161 print(f"iconutil skipped ({e!r}); PNGs still generated.", file=sys.stderr) 162 163 print(f"Wrote {assets_dir / 'app_icon_256.png'} and iconset.") 164 165 166 if __name__ == "__main__": 167 main()