Files
libretro/scripts/fetch_site_images.py
T
Abdessamad Derraz 046c376448 feat: serve platform logos as 80 px webp
Shown at 40 px, the 96 px PNG logos were 136 KiB of the home page. WebP at twice the display size is 27 KiB for the twelve raster marks. SVG stays as each project publishes it.
2026-10-23 10:46:58 +02:00

176 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Download the platform logos and system icons the site shows, once.
The site served these from other hosts: GitHub avatars and files on the
``master`` branch of upstream repositories. A rename upstream broke an image
in silence, and every page view hit third-party hosts. This tool copies them
into ``docs_assets/logos/`` and ``docs_assets/system-icons/``, which are
committed, and records where each came from in a ``sources.json`` beside
them. The site build only reads those copies (``sitediagrams.localize_images``).
Raster logos are normalised to an 80 px square WebP, twice the 40 px they
are shown at, and system icons to a 48 px PNG, so every tile has the same
weight. SVG is kept as published.
Normalising needs Pillow. This is a maintenance tool run by hand when a
logo changes, not a build step: the build stays on the standard library
and PyYAML.
python scripts/fetch_site_images.py # logos and icons
python scripts/fetch_site_images.py --only logos
"""
from __future__ import annotations
import argparse
import io
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
LOGO_DIR = ROOT / "docs_assets" / "logos"
ICON_DIR = ROOT / "docs_assets" / "system-icons"
LOGO_SIZE = 80
ICON_SIZE = 48
AVATAR_HOST = "avatars.githubusercontent.com"
USER_AGENT = "retrobios-site-images"
def fetch(url: str) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=60) as response:
return response.read()
def square_image(data: bytes, size: int, kind: str = "PNG") -> bytes:
try:
from PIL import Image
except ImportError as exc:
raise SystemExit("Pillow is needed to normalise raster images: pip install Pillow") from exc
image = Image.open(io.BytesIO(data)).convert("RGBA")
image.thumbnail((size, size), Image.LANCZOS)
tile = Image.new("RGBA", (size, size), (0, 0, 0, 0))
tile.paste(image, ((size - image.width) // 2, (size - image.height) // 2), image)
out = io.BytesIO()
if kind == "WEBP":
# Quality 90: half the bytes of lossless, edges still clean at 40 px.
tile.save(out, "WEBP", quality=90, method=6)
else:
tile.save(out, "PNG", optimize=True)
return out.getvalue()
def is_svg(data: bytes) -> bool:
head = data[:512].lstrip().lower()
return head.startswith(b"<?xml") or head.startswith(b"<svg")
def safe_svg(data: bytes, url: str) -> bytes:
lowered = data.lower()
for token in (b"<script", b"javascript:", b"onload=", b"<foreignobject"):
if token in lowered:
raise SystemExit(f"{url}: SVG carries {token.decode()}, not copied")
return data
def write_manifest(directory: Path, note: str, entries: list[dict]) -> None:
entries.sort(key=lambda entry: entry["file"])
document = {"note": note, "images": entries}
(directory / "sources.json").write_text(
json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
def fetch_logos() -> None:
from siteguides import GUIDES
with open(ROOT / "platforms" / "_registry.yml", encoding="utf-8") as handle:
platforms = yaml.safe_load(handle)["platforms"]
LOGO_DIR.mkdir(parents=True, exist_ok=True)
entries = []
for name, entry in sorted(platforms.items()):
url = entry.get("logo")
if not url:
continue
download = f"{url}?s={LOGO_SIZE * 2}" if AVATAR_HOST in url else url
data = fetch(download)
if is_svg(data):
file_name, payload = f"{name}.svg", safe_svg(data, url)
else:
file_name, payload = f"{name}.webp", square_image(data, LOGO_SIZE, "WEBP")
for stale in LOGO_DIR.glob(f"{name}.*"):
if stale.name != file_name:
stale.unlink()
(LOGO_DIR / file_name).write_bytes(payload)
# Who owns each mark: logo_owner and logo_licence in the guides.
guide = GUIDES.get(name)
owner = (guide and guide.logo_owner) or name
licence = (guide and guide.logo_licence) or "ownership not recorded"
entries.append({
"file": file_name, "platform": name, "source": url,
"owner": owner, "licence": licence,
})
print(f" {file_name} from {url}")
write_manifest(
LOGO_DIR,
"Each platform's own logo, owned by its project and shown only to name that "
"platform. Copied once by scripts/fetch_site_images.py.",
entries,
)
def fetch_icons() -> None:
from generate_site import _group_by_manufacturer, load_database
from siterender import SYSTEM_ICON_BASE, _icon_name
db = load_database(str(ROOT / "database.json"))
names = sorted({
_icon_name(manufacturer, console)
for manufacturer, consoles in _group_by_manufacturer(db).items()
for console in consoles
})
ICON_DIR.mkdir(parents=True, exist_ok=True)
entries = []
for name in names:
url = f"{SYSTEM_ICON_BASE}/{urllib.parse.quote(name)}.png"
try:
data = fetch(url)
except urllib.error.HTTPError as exc:
if exc.code == 404:
continue
raise
file_name = f"{name}.png"
(ICON_DIR / file_name).write_bytes(square_image(data, ICON_SIZE))
entries.append({"file": file_name, "source": url})
print(f" {file_name}")
write_manifest(
ICON_DIR,
"System icons from libretro/retroarch-assets (xmb/systematic), "
"licensed CC-BY-4.0 by the libretro team. Resized to 48 px.",
entries,
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0], allow_abbrev=False)
parser.add_argument("--only", choices=("logos", "icons"), help="fetch one kind only")
args = parser.parse_args()
if args.only in (None, "logos"):
print("Platform logos:")
fetch_logos()
if args.only in (None, "icons"):
print("System icons:")
fetch_icons()
if __name__ == "__main__":
from common import run_cli
run_cli(main)