"""Export the site's favicon as PNG assets (Discord icon, avatars, banners). The source of truth is src/app/icon.svg - the blue rounded square with the cream wave that the site actually serves as its icon: It is redrawn here rather than rasterised through a converter: the shape is two primitives, cairosvg needs a system cairo that is not installed here, and svglib misreads the viewBox as 24x24 (it honours width/height attributes that this file does not set). Drawing it directly also means the wave can be supersampled, which matters - a 3.4/32 stroke scaled to 1024px is 109px wide, and a naive polyline shows its segment joints at that size. Geometry is taken verbatim from the SVG and scaled, so the output is the favicon at any size rather than something that resembles it. Outputs to ~/Downloads: vibemathed-icon-512.png Discord's recommended server-icon size vibemathed-icon-1024.png 2x, for banners and upscaling vibemathed-icon-256.png small avatars vibemathed-icon-round-512.png circular crop, for platforms that mask vibemathed-icon-square-512.png hard corners, for tiles that round it themselves """ import os from PIL import Image, ImageDraw # Verbatim from icon.svg. VIEWBOX = 32.0 BLUE = (42, 120, 214, 255) # #2a78d6 CREAM = (243, 239, 227, 255) # #f3efe3 RADIUS = 7.0 STROKE = 3.4 CURVE = [ # (start, c1, c2, end) in viewBox units - the two cubics of the path. ((4, 16), (7, 9), (11, 9), (16, 16)), ((16, 16), (21, 23), (25, 23), (28, 16)), ] SS = 4 # supersampling factor OUT = os.path.join(os.environ["USERPROFILE"], "Downloads") def bezier(p0, p1, p2, p3, steps): """Points along one cubic Bezier.""" pts = [] for i in range(steps + 1): t = i / steps u = 1 - t x = u**3 * p0[0] + 3 * u**2 * t * p1[0] + 3 * u * t**2 * p2[0] + t**3 * p3[0] y = u**3 * p0[1] + 3 * u**2 * t * p1[1] + 3 * u * t**2 * p2[1] + t**3 * p3[1] pts.append((x, y)) return pts def render(size, corners="rounded"): """The favicon at `size` px. corners: rounded | square | circle.""" s = size * SS scale = s / VIEWBOX img = Image.new("RGBA", (s, s), (0, 0, 0, 0)) d = ImageDraw.Draw(img) if corners == "square": d.rectangle([0, 0, s - 1, s - 1], fill=BLUE) elif corners == "circle": d.ellipse([0, 0, s - 1, s - 1], fill=BLUE) else: d.rounded_rectangle([0, 0, s - 1, s - 1], radius=RADIUS * scale, fill=BLUE) # The wave, as one point list through both cubics so the join at (16,16) # is continuous rather than two strokes meeting. pts = [] for seg in CURVE: part = bezier(*seg, steps=600) pts.extend(part if not pts else part[1:]) pts = [(x * scale, y * scale) for x, y in pts] # Stamp a disc at every sample rather than calling d.line with a width. # A round-capped, round-joined stroke IS the union of discs along the # path, and PIL's thick polyline is not: it draws each segment as its # own quad, so at this width (109px at 1024) the seams between segments # show as hairlines across the wave. r = STROKE * scale / 2 for x, y in pts: d.ellipse([x - r, y - r, x + r, y + r], fill=CREAM) return img.resize((size, size), Image.LANCZOS) def main(): jobs = [ ("vibemathed-icon-512.png", render(512)), ("vibemathed-icon-1024.png", render(1024)), ("vibemathed-icon-256.png", render(256)), ("vibemathed-icon-round-512.png", render(512, corners="circle")), ("vibemathed-icon-square-512.png", render(512, corners="square")), ] for name, img in jobs: path = os.path.join(OUT, name) img.save(path, "PNG") print(f" {name} {img.size[0]}x{img.size[1]} {os.path.getsize(path) / 1024:.0f} KB") print(f"\nwritten to {OUT}") if __name__ == "__main__": main()