/ scripts / make_icon.py
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()