#!/usr/bin/env python3 """Regenerate every app icon from the vector source (src-tauri/icons/icon.svg). The artwork is described once, in 512x512 units, and rasterised per target size with 8x supersampling plus a little per-size hinting: the filmstrip sprocket holes and the play-triangle knockout are dropped at the sizes where they would be sub-pixel and only turn to mush. Everything is produced with a real alpha channel - the rounded-square tile has transparent corners, which is exactly what the old flattened .ico was missing. python tools/make_icons.py Requires: pillow, numpy. """ from __future__ import annotations import math import re import struct from io import BytesIO from pathlib import Path import numpy as np from PIL import Image, ImageDraw ROOT = Path(__file__).resolve().parent.parent ICONS = ROOT / "src-tauri" / "icons" SVG = ICONS / "icon.svg" CANVAS = 512.0 SS = 8 # supersampling factor # --- tile ------------------------------------------------------------------- # The original raster left a 22px margin for a drop shadow. The vector version # has no shadow, so the tile reclaims that space and reads larger in the taskbar. TILE = (14.0, 14.0, 484.0, 484.0) # x, y, w, h TILE_R = 99.0 GRAD_SPAN = (22.0, 468.0) # gradient is still fitted against the original tile # --- filmstrips ------------------------------------------------------------- STRIPS = [(113.0, 147.0), (96.0, 226.0), (113.0, 305.0)] STRIP_W, STRIP_H, STRIP_R = 79.0, 60.0, 12.0 HOLE_DX, HOLE_DY, HOLE_S, HOLE_R = 68.0, 4.0, 7.5, 1.8 HOLE_PITCH, HOLE_N = 11.25, 5 def parse_svg(svg_text): """Pull the gradient stops and the glyph path back out of the vector source.""" lin = re.search(r'id="tile".*?', svg_text, re.S).group(0) lin_stops = [ (float(o), tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))) for o, h in re.findall(r'offset="([\d.]+)" stop-color="#([0-9A-Fa-f]{6})"', lin) ] rad = re.search(r'id="glow".*?', svg_text, re.S).group(0) gc = re.search(r'cx="([-\d.]+)" cy="([-\d.]+)" r="([\d.]+)"', rad) glow_xy = (float(gc.group(1)), float(gc.group(2))) glow_r = float(gc.group(3)) ghex = re.search(r'stop-color="#([0-9A-Fa-f]{6})"', rad).group(1) glow_rgb = tuple(int(ghex[i:i + 2], 16) for i in (0, 2, 4)) rad_stops = [ (float(o), float(a)) for o, a in re.findall( r'offset="([\d.]+)" stop-color="#[0-9A-Fa-f]{6}" stop-opacity="([\d.]+)"', rad ) ] path_d = re.search(r' 2: polys.append(cur) pos = start = (a[0], a[1]) cur = [pos] elif cmd == "L": pos = (a[0], a[1]) cur.append(pos) elif cmd == "C": p0, p1, p2, p3 = pos, (a[0], a[1]), (a[2], a[3]), (a[4], a[5]) span = math.dist(p0, p1) + math.dist(p1, p2) + math.dist(p2, p3) n = max(4, min(96, int(span * scale / 2.5))) for i in range(1, n + 1): t = i / n mt = 1 - t x = mt ** 3 * p0[0] + 3 * mt * mt * t * p1[0] + 3 * mt * t * t * p2[0] + t ** 3 * p3[0] y = mt ** 3 * p0[1] + 3 * mt * mt * t * p1[1] + 3 * mt * t * t * p2[1] + t ** 3 * p3[1] cur.append((x, y)) pos = p3 elif cmd == "Z": if len(cur) > 2: polys.append(cur) cur, pos = [], start if len(cur) > 2: polys.append(cur) return polys def render(size, lin_stops, glow_xy, glow_r, glow_rgb, rad_stops, path_d): n = size * SS k = n / CANVAS # 512-space -> device pixels # per-size hinting: sub-pixel detail is dropped rather than smeared, and the # glyph is nudged up a little where it would otherwise get lost keep_holes = size >= 48 # sprocket holes are < 0.7px below this keep_triangle = size >= 64 # the play knockout reads as a speck below this gscale = 1.09 if size <= 32 else 1.0 def S(v): return v * k def G(v): # glyph coordinate, scaled about the tile centre return (CANVAS / 2 + (v - CANVAS / 2) * gscale) * k # the tile shape is the icon's whole alpha channel - corners stay transparent tile = Image.new("L", (n, n), 0) ImageDraw.Draw(tile).rounded_rectangle( [S(TILE[0]), S(TILE[1]), S(TILE[0] + TILE[2]), S(TILE[1] + TILE[3])], radius=S(TILE_R), fill=255, ) # background: diagonal purple-to-blue ramp with a soft highlight near the top yy, xx = np.mgrid[0:n, 0:n] ux = (xx + 0.5) / k uy = (yy + 0.5) / k t = np.clip(((ux + uy) - 2 * GRAD_SPAN[0]) / (2 * GRAD_SPAN[1]), 0, 1) base = ramp(lin_stops, t) g = np.clip(np.hypot(ux - glow_xy[0], uy - glow_xy[1]) / glow_r, 0, 1) a = ramp1(rad_stops, g)[..., None] rgb = base * (1 - a) + np.array(glow_rgb, dtype=np.float64) * a glyph = Image.new("L", (n, n), 0) gd = ImageDraw.Draw(glyph) polys = flatten_path(path_d, k) outer, holes = polys[0], polys[1:] # outline first, play-triangle knockout after gd.polygon([(G(x), G(y)) for x, y in outer], fill=255) if keep_triangle: for hole in holes: gd.polygon([(G(x), G(y)) for x, y in hole], fill=0) for sx, sy in STRIPS: gd.rounded_rectangle( [G(sx), G(sy), G(sx + STRIP_W), G(sy + STRIP_H)], radius=S(STRIP_R * gscale), fill=255 ) if keep_holes: for i in range(HOLE_N): hx, hy = sx + HOLE_DX, sy + HOLE_DY + i * HOLE_PITCH gd.rounded_rectangle( [G(hx), G(hy), G(hx + HOLE_S), G(hy + HOLE_S)], radius=S(HOLE_R * gscale), fill=0, ) gm = (np.asarray(glyph, dtype=np.float64) / 255.0)[..., None] rgb = rgb * (1 - gm) + 255.0 * gm out = np.dstack([np.clip(rgb, 0, 255), np.asarray(tile, dtype=np.float64)]).astype(np.uint8) return Image.fromarray(out, "RGBA").resize((size, size), Image.LANCZOS) def write_ico(path, images): """Write a PNG-compressed .ico (Vista+ format), preserving full alpha.""" blobs = [] for im in images: buf = BytesIO() im.save(buf, "PNG", optimize=True) blobs.append(buf.getvalue()) with open(path, "wb") as f: f.write(struct.pack("= 256 else im.width h = 0 if im.height >= 256 else im.height f.write(struct.pack("