"""Genere les icones PNG de la PWA sans dependance externe. Usage : python scripts/generate_icons.py Les fichiers sont ecrits dans frontend/public/icons/. """ from __future__ import annotations import struct import zlib from pathlib import Path OUTPUT_DIR = Path(__file__).resolve().parent.parent / "frontend" / "public" / "icons" INDIGO = (79, 70, 229, 255) WHITE = (255, 255, 255, 255) LIGHT = (199, 210, 254, 255) MID = (165, 180, 252, 255) SUPERSAMPLE = 3 class Canvas: def __init__(self, size: int) -> None: self.size = size self.buffer = bytearray(size * size * 4) def set_pixel(self, x: int, y: int, color: tuple[int, int, int, int]) -> None: if 0 <= x < self.size and 0 <= y < self.size: offset = (y * self.size + x) * 4 self.buffer[offset : offset + 4] = bytes(color) def fill_rounded_rect( self, x0: float, y0: float, x1: float, y1: float, radius: float, color ) -> None: for y in range(int(y0), int(y1)): for x in range(int(x0), int(x1)): cx = min(max(x, x0 + radius), x1 - radius) cy = min(max(y, y0 + radius), y1 - radius) if (x - cx) ** 2 + (y - cy) ** 2 <= radius * radius: self.set_pixel(x, y, color) def fill_circle(self, cx: float, cy: float, radius: float, color) -> None: for y in range(int(cy - radius), int(cy + radius) + 1): for x in range(int(cx - radius), int(cx + radius) + 1): if (x - cx) ** 2 + (y - cy) ** 2 <= radius * radius: self.set_pixel(x, y, color) def draw_segment(self, x0, y0, x1, y1, width, color) -> None: half = width / 2 dx, dy = x1 - x0, y1 - y0 length_sq = dx * dx + dy * dy or 1 min_x, max_x = int(min(x0, x1) - half - 1), int(max(x0, x1) + half + 1) min_y, max_y = int(min(y0, y1) - half - 1), int(max(y0, y1) + half + 1) for y in range(min_y, max_y + 1): for x in range(min_x, max_x + 1): t = max(0.0, min(1.0, ((x - x0) * dx + (y - y0) * dy) / length_sq)) px, py = x0 + t * dx, y0 + t * dy if (x - px) ** 2 + (y - py) ** 2 <= half * half: self.set_pixel(x, y, color) def downsample(self, factor: int) -> "Canvas": target = Canvas(self.size // factor) area = factor * factor for y in range(target.size): for x in range(target.size): totals = [0, 0, 0, 0] for sy in range(factor): row = (y * factor + sy) * self.size for sx in range(factor): offset = (row + x * factor + sx) * 4 for channel in range(4): totals[channel] += self.buffer[offset + channel] target.set_pixel(x, y, tuple(value // area for value in totals)) return target def to_png(self, path: Path) -> None: rows = bytearray() stride = self.size * 4 for y in range(self.size): rows.append(0) # filtre "None" rows.extend(self.buffer[y * stride : (y + 1) * stride]) def chunk(tag: bytes, data: bytes) -> bytes: return ( struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) ) header = struct.pack(">IIBBBBB", self.size, self.size, 8, 6, 0, 0, 0) png = ( b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(bytes(rows), 9)) + chunk(b"IEND", b"") ) path.write_bytes(png) def render(size: int, content_scale: float, corner_ratio: float) -> Canvas: """Dessine l'icone : un noeud racine relie a trois branches.""" canvas = Canvas(size) canvas.fill_rounded_rect(0, 0, size, size, size * corner_ratio, INDIGO) unit = size / 512 * content_scale center = size / 2 offset = center - 256 * unit def point(x: float, y: float) -> tuple[float, float]: return (offset + x * unit, offset + y * unit) root = point(168, 256) targets = [point(336, 160), point(336, 256), point(336, 352)] for target in targets: canvas.draw_segment(root[0], root[1], target[0], target[1], 18 * unit, WHITE) canvas.fill_circle(root[0], root[1], 46 * unit, WHITE) for index, target in enumerate(targets): canvas.fill_circle(target[0], target[1], 30 * unit, MID if index == 1 else LIGHT) return canvas def build(size: int, filename: str, content_scale: float, corner_ratio: float) -> None: large = render(size * SUPERSAMPLE, content_scale, corner_ratio) large.downsample(SUPERSAMPLE).to_png(OUTPUT_DIR / filename) print(f"ecrit {filename} ({size}x{size})") def main() -> None: OUTPUT_DIR.mkdir(parents=True, exist_ok=True) build(192, "icon-192.png", 1.0, 0.22) build(512, "icon-512.png", 1.0, 0.22) # Icone maskable : contenu reduit pour rester dans la zone sure. build(512, "icon-maskable-512.png", 0.62, 0.5) if __name__ == "__main__": main()