"""Generate the PNG app icons (pure Python, no dependencies). Run inside Docker from the repo root: docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/w -w /w python:3.13-slim python tools/make_icons.py """ import math import struct import zlib from pathlib import Path OUT = Path(__file__).resolve().parent.parent / "app" / "static" / "icons" BG = (0x4F, 0x46, 0xE5) FG = (0xFF, 0xFF, 0xFF) # Check mark in unit coordinates, matching icon.svg. POINTS = [(0.28, 0.53), (0.44, 0.68), (0.73, 0.36)] STROKE = 0.09 def seg_dist(px, py, ax, ay, bx, by): dx, dy = bx - ax, by - ay t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy))) return math.hypot(px - (ax + t * dx), py - (ay + t * dy)) def rounded_rect_alpha(x, y, size, radius): # Signed-distance antialiasing for a rounded square covering the whole image. cx = min(max(x, radius), size - radius) cy = min(max(y, radius), size - radius) d = math.hypot(x - cx, y - cy) - radius return max(0.0, min(1.0, 0.5 - d)) def render(size, rounded, scale=1.0): radius = size * 0.22 if rounded else 0 pts = [((0.5 + (x - 0.5) * scale) * size, (0.5 + (y - 0.5) * scale) * size) for x, y in POINTS] half = STROKE * scale * size / 2 rows = [] for j in range(size): row = bytearray([0]) for i in range(size): x, y = i + 0.5, j + 0.5 a_bg = rounded_rect_alpha(x, y, size, radius) if rounded else 1.0 d = min(seg_dist(x, y, *pts[0], *pts[1]), seg_dist(x, y, *pts[1], *pts[2])) a_fg = max(0.0, min(1.0, half - d + 0.5)) rgb = [round(b * (1 - a_fg) + f * a_fg) for b, f in zip(BG, FG)] row += bytes(rgb) + bytes([round(255 * a_bg)]) rows.append(bytes(row)) return png(size, size, b"".join(rows)) def png(w, h, raw): def chunk(tag, data): return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data)) ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0) return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"") if __name__ == "__main__": OUT.mkdir(parents=True, exist_ok=True) (OUT / "icon-192.png").write_bytes(render(192, rounded=True)) (OUT / "icon-512.png").write_bytes(render(512, rounded=True)) # Maskable/Apple icons: full-bleed background, check kept inside the safe zone. (OUT / "icon-maskable-512.png").write_bytes(render(512, rounded=False, scale=0.8)) (OUT / "icon-180.png").write_bytes(render(180, rounded=False, scale=0.9)) print("icons written to", OUT)