#!/usr/bin/env python3 """Export the original app.svg offline: python3 tools/export-app-icons.py. Requires Python 3 + Pillow (also used by local-extract). This deliberately renders only this source's flat rect/polygon vocabulary; unsupported SVG fails loudly. No fonts, game art, browser, network or platform image service is involved. """ import argparse from io import BytesIO from pathlib import Path import struct import xml.etree.ElementTree as ET from PIL import Image, ImageDraw ROOT = Path(__file__).resolve().parents[1] SOURCE = ROOT / "public/icons/app.svg" MASK_SCALE = 0.875 def render(size, maskable=False): svg = ET.parse(SOURCE).getroot() if svg.attrib != {"width": "512", "height": "512", "viewBox": "0 0 512 512"}: raise ValueError("Expected the 512-square SVG master") # Eight samples per output pixel, then area averaging; no ringing beyond the mark. scale = size * 8 / 512 image = Image.new("RGB", (size * 8, size * 8)) draw = ImageDraw.Draw(image) def paint(element, inset=1): tag = element.tag.rsplit("}", 1)[-1] attrs = element.attrib if tag in ("title", "desc"): if attrs or len(element): raise ValueError("Unsupported metadata") return if tag == "g" and attrs == {"id": "mark"}: for child in element: paint(child, MASK_SCALE if maskable else 1) return if tag == "rect" and set(attrs) <= {"x", "y", "width", "height", "fill"}: x, y = float(attrs.get("x", 0)), float(attrs.get("y", 0)) w, h = float(attrs["width"]), float(attrs["height"]) points = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)] elif tag == "polygon" and set(attrs) == {"fill", "points"}: points = [tuple(map(float, p.split(","))) for p in attrs["points"].split()] else: raise ValueError(f"Unsupported SVG element/attributes: {tag} {attrs}") # Pillow addresses pixel centres; shift the vector edges by half a sample. points = [((256 + (x - 256) * inset) * scale - 0.5, (256 + (y - 256) * inset) * scale - 0.5) for x, y in points] draw.polygon(points, fill=attrs["fill"]) for element in svg: paint(element) return image.resize((size, size), Image.Resampling.BOX) def write_ico(path): # Native-size renders preserve the same pixels as each PNG favicon. Write # the DIBs explicitly: Pillow's RGB ICO writer does not pad 1-bit mask rows # to 4 bytes, and can reuse the last size's mask for every smaller frame. sizes = (16, 32, 48) directory, frames = bytearray(), bytearray() offset = 6 + 16 * len(sizes) for size in sizes: dib = BytesIO() render(size).save(dib, format="DIB") frame = bytearray(dib.getvalue()) # ICO DIB height includes both the colour bitmap and the AND mask. struct.pack_into("