98 lines
4.0 KiB
Python
98 lines
4.0 KiB
Python
#!/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("<i", frame, 8, size * 2)
|
|
frame.extend(bytes(((size + 31) // 32) * 4 * size)) # Opaque, padded AND rows
|
|
directory.extend(struct.pack("<BBBBHHII", size, size, 0, 0, 1, 24, len(frame), offset))
|
|
frames.extend(frame)
|
|
offset += len(frame)
|
|
path.write_bytes(struct.pack("<HHH", 0, 1, len(sizes)) + directory + frames)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--out", type=Path, default=ROOT / "public/icons")
|
|
args = parser.parse_args()
|
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
for size in (192, 512):
|
|
for maskable in (False, True):
|
|
name = f"app-{'maskable-' if maskable else ''}{size}.png"
|
|
render(size, maskable).save(args.out / name)
|
|
for size in (16, 32, 48):
|
|
render(size).save(args.out / f"favicon-{size}.png")
|
|
render(180).save(args.out / "apple-touch-icon.png")
|
|
# BMP-backed frames also work in older ICO readers; PNG favicons ship separately.
|
|
write_ico(args.out / "favicon.ico")
|
|
print(f"Exported 9 icon files to {args.out}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|