#!/usr/bin/env python3 import xml.etree.ElementTree as ET from pathlib import Path from typing import Callable, TypedDict from PIL import Image, ImageDraw class Polygon(TypedDict): coords: list[tuple[float, float]] fill: str SCRIPT_DIR = Path(__file__).parent PROJECT_ROOT = SCRIPT_DIR.parent SOURCE_DIR = SCRIPT_DIR / "source" LOGOS_DIR = SCRIPT_DIR / "logos" def parse_svg_polygons(svg_path: Path) -> list[Polygon]: tree = ET.parse(svg_path) root = tree.getroot() ns = {"svg": "http://www.w3.org/2000/svg"} polygons = root.findall(".//svg:polygon", ns) if not polygons: polygons = root.findall(".//polygon") result: list[Polygon] = [] for poly in polygons: points_str = poly.get("points", "").strip() fill = poly.get("fill", "#000000") coords: list[tuple[float, float]] = [] for pair in points_str.split(): x, y = pair.split(",") coords.append((float(x), float(y))) result.append({"coords": coords, "fill": fill}) return result def get_bbox(polygons: list[Polygon]) -> dict[str, float]: all_coords: list[tuple[float, float]] = [] for poly in polygons: all_coords.extend(poly["coords"]) xs = [c[0] for c in all_coords] ys = [c[1] for c in all_coords] return { "min_x": min(xs), "max_x": max(xs), "min_y": min(ys), "max_y": max(ys), "width": max(xs) - min(xs), "height": max(ys) - min(ys), } def scale_and_center( polygons: list[Polygon], viewbox_size: float, target_width: float ) -> list[Polygon]: bbox = get_bbox(polygons) scale = target_width / bbox["width"] center = viewbox_size / 2 scaled_polys: list[Polygon] = [] for poly in polygons: scaled_coords = [(x * scale, y * scale) for x, y in poly["coords"]] scaled_polys.append({"coords": scaled_coords, "fill": poly["fill"]}) scaled_bbox = get_bbox(scaled_polys) current_center_x = (scaled_bbox["min_x"] + scaled_bbox["max_x"]) / 2 current_center_y = (scaled_bbox["min_y"] + scaled_bbox["max_y"]) / 2 offset_x = center - current_center_x offset_y = center - current_center_y final_polys: list[Polygon] = [] for poly in scaled_polys: final_coords = [(x + offset_x, y + offset_y) for x, y in poly["coords"]] final_polys.append({"coords": final_coords, "fill": poly["fill"]}) return final_polys def format_svg_points(coords: list[tuple[float, float]]) -> str: return " ".join(f"{x:.3f},{y:.3f}" for x, y in coords) def format_android_path(coords: list[tuple[float, float]]) -> str: points = " ".join(f"{x:.3f},{y:.3f}" for x, y in coords) pairs = points.split() return f"M{pairs[0]} L{pairs[1]} L{pairs[2]} Z" def generate_svg(polygons: list[Polygon], width: int, height: int) -> str: lines = [ f'' ] for poly in polygons: points = format_svg_points(poly["coords"]) lines.append(f' ') lines.append("") return "\n".join(lines) def generate_android_vector( polygons: list[Polygon], width: int, height: int, viewbox: int ) -> str: lines = [ '', '', ] for poly in polygons: path = format_android_path(poly["coords"]) lines.append( f' ' ) lines.append("") return "\n".join(lines) def rasterize_svg( svg_path: Path, output_path: Path, size: int, bg_color: tuple[int, int, int, int] | None = None, circular: bool = False, ) -> None: from xml.dom import minidom doc = minidom.parse(str(svg_path)) img = Image.new( "RGBA", (size, size), (255, 255, 255, 0) if bg_color is None else bg_color ) draw = ImageDraw.Draw(img) svg_elem = doc.getElementsByTagName("svg")[0] viewbox = svg_elem.getAttribute("viewBox").split() if viewbox: vb_width = float(viewbox[2]) vb_height = float(viewbox[3]) scale_x = size / vb_width scale_y = size / vb_height else: scale_x = scale_y = 1 def parse_transform( transform_str: str, ) -> Callable[[float, float], tuple[float, float]]: import re if not transform_str: return lambda x, y: (x, y) transforms: list[tuple[str, list[float]]] = [] for match in re.finditer(r"(\w+)\(([^)]+)\)", transform_str): func, args_str = match.groups() args = [float(x) for x in args_str.replace(",", " ").split()] transforms.append((func, args)) def apply_transforms(x: float, y: float) -> tuple[float, float]: for func, args in transforms: if func == "translate": x += args[0] y += args[1] if len(args) > 1 else args[0] elif func == "scale": x *= args[0] y *= args[1] if len(args) > 1 else args[0] return x, y return apply_transforms for g in doc.getElementsByTagName("g"): transform = parse_transform(g.getAttribute("transform")) for poly in g.getElementsByTagName("polygon"): points_str = poly.getAttribute("points").strip() fill = poly.getAttribute("fill") if not fill: fill = "#000000" coords: list[tuple[float, float]] = [] for pair in points_str.split(): x, y = pair.split(",") x, y = float(x), float(y) x, y = transform(x, y) coords.append((x * scale_x, y * scale_y)) draw.polygon(coords, fill=fill) for poly in doc.getElementsByTagName("polygon"): if poly.parentNode and getattr(poly.parentNode, "tagName", None) == "g": continue points_str = poly.getAttribute("points").strip() fill = poly.getAttribute("fill") if not fill: fill = "#000000" coords = [] for pair in points_str.split(): x, y = pair.split(",") coords.append((float(x) * scale_x, float(y) * scale_y)) draw.polygon(coords, fill=fill) if circular: mask = Image.new("L", (size, size), 0) mask_draw = ImageDraw.Draw(mask) mask_draw.ellipse((0, 0, size, size), fill=255) img.putalpha(mask) img.save(output_path) def main() -> None: print("Generating branding assets...") logo_svg = SOURCE_DIR / "logo.svg" icon_light = SOURCE_DIR / "icon-light.svg" icon_dark = SOURCE_DIR / "icon-dark.svg" icon_tinted = SOURCE_DIR / "icon-tinted.svg" polygons = parse_svg_polygons(logo_svg) print(" iOS...") ios_assets = PROJECT_ROOT / "ios/Ascently/Assets.xcassets/AppIcon.appiconset" for src, dst in [ (icon_light, ios_assets / "app_icon_light_template.svg"), (icon_dark, ios_assets / "app_icon_dark_template.svg"), (icon_tinted, ios_assets / "app_icon_tinted_template.svg"), ]: with open(src) as f: content = f.read() with open(dst, "w") as f: f.write(content) img_light = Image.new("RGB", (1024, 1024), (255, 255, 255)) draw_light = ImageDraw.Draw(img_light) scaled = scale_and_center(polygons, 1024, int(1024 * 0.7)) for poly in scaled: coords = [(x, y) for x, y in poly["coords"]] draw_light.polygon(coords, fill=poly["fill"]) img_light.save(ios_assets / "app_icon_1024.png") img_dark = Image.new("RGB", (1024, 1024), (26, 26, 26)) draw_dark = ImageDraw.Draw(img_dark) for poly in scaled: coords = [(x, y) for x, y in poly["coords"]] draw_dark.polygon(coords, fill=poly["fill"]) img_dark.save(ios_assets / "app_icon_1024_dark.png") img_tinted = Image.new("RGB", (1024, 1024), (0, 0, 0)) draw_tinted = ImageDraw.Draw(img_tinted) for i, poly in enumerate(scaled): coords = [(x, y) for x, y in poly["coords"]] draw_tinted.polygon(coords, fill=(0, 0, 0)) img_tinted.save(ios_assets / "app_icon_1024_tinted.png") print(" Android...") polys_108 = scale_and_center(polygons, 108, 60) android_xml = generate_android_vector(polys_108, 108, 108, 108) ( PROJECT_ROOT / "android/app/src/main/res/drawable/ic_launcher_foreground.xml" ).write_text(android_xml) polys_24 = scale_and_center(polygons, 24, 20) mountains_xml = generate_android_vector(polys_24, 24, 24, 24) (PROJECT_ROOT / "android/app/src/main/res/drawable/ic_mountains.xml").write_text( mountains_xml ) for density, size in [ ("mdpi", 48), ("hdpi", 72), ("xhdpi", 96), ("xxhdpi", 144), ("xxxhdpi", 192), ]: mipmap_dir = PROJECT_ROOT / f"android/app/src/main/res/mipmap-{density}" img = Image.new("RGBA", (size, size), (255, 255, 255, 255)) draw = ImageDraw.Draw(img) scaled = scale_and_center(polygons, size, int(size * 0.6)) for poly in scaled: coords = [(x, y) for x, y in poly["coords"]] draw.polygon(coords, fill=poly["fill"]) img.save(mipmap_dir / "ic_launcher.webp") img_round = Image.new("RGBA", (size, size), (255, 255, 255, 255)) draw_round = ImageDraw.Draw(img_round) for poly in scaled: coords = [(x, y) for x, y in poly["coords"]] draw_round.polygon(coords, fill=poly["fill"]) mask = Image.new("L", (size, size), 0) mask_draw = ImageDraw.Draw(mask) mask_draw.ellipse((0, 0, size, size), fill=255) img_round.putalpha(mask) img_round.save(mipmap_dir / "ic_launcher_round.webp") print(" Docs...") polys_32 = scale_and_center(polygons, 32, 26) logo_svg_32 = generate_svg(polys_32, 32, 32) (PROJECT_ROOT / "docs/src/assets/logo.svg").write_text(logo_svg_32) (PROJECT_ROOT / "docs/src/assets/logo-dark.svg").write_text(logo_svg_32) polys_256 = scale_and_center(polygons, 256, 208) logo_svg_256 = generate_svg(polys_256, 256, 256) (PROJECT_ROOT / "docs/src/assets/logo-highres.svg").write_text(logo_svg_256) logo_32_path = PROJECT_ROOT / "docs/src/assets/logo.svg" rasterize_svg(logo_32_path, PROJECT_ROOT / "docs/public/favicon.png", 32) sizes = [16, 32, 48] imgs = [] for size in sizes: img = Image.new("RGBA", (size, size), (255, 255, 255, 0)) draw = ImageDraw.Draw(img) scaled = scale_and_center(polygons, size, int(size * 0.8)) for poly in scaled: coords = [(x, y) for x, y in poly["coords"]] draw.polygon(coords, fill=poly["fill"]) imgs.append(img) imgs[0].save( PROJECT_ROOT / "docs/public/favicon.ico", format="ICO", sizes=[(s, s) for s in sizes], append_images=imgs[1:], ) print(" Logos...") LOGOS_DIR.mkdir(exist_ok=True) sizes = [64, 128, 256, 512, 1024, 2048] for size in sizes: img = Image.new("RGBA", (size, size), (255, 255, 255, 0)) draw = ImageDraw.Draw(img) scaled = scale_and_center(polygons, size, int(size * 0.8)) for poly in scaled: coords = [(x, y) for x, y in poly["coords"]] draw.polygon(coords, fill=poly["fill"]) img.save(LOGOS_DIR / f"logo-{size}.png") for size in sizes: img = Image.new("RGBA", (size, size), (255, 255, 255, 255)) draw = ImageDraw.Draw(img) scaled = scale_and_center(polygons, size, int(size * 0.8)) for poly in scaled: coords = [(x, y) for x, y in poly["coords"]] draw.polygon(coords, fill=poly["fill"]) img.save(LOGOS_DIR / f"logo-{size}-white.png") for size in sizes: img = Image.new("RGBA", (size, size), (26, 26, 26, 255)) draw = ImageDraw.Draw(img) scaled = scale_and_center(polygons, size, int(size * 0.8)) for poly in scaled: coords = [(x, y) for x, y in poly["coords"]] draw.polygon(coords, fill=poly["fill"]) img.save(LOGOS_DIR / f"logo-{size}-dark.png") print("Done.") if __name__ == "__main__": main()