vitals/tools/make_icons.py

94 lines
3.4 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
make_icons.py — turn one source mark into every icon size the surfaces need.
One file in, the full set out, because the alternative is discovering three
weeks later that the forge unfurl, the GitHub avatar, and the browser tab are
all still showing generated identicons.
python3 tools/make_icons.py brand/mark.svg
python3 tools/make_icons.py brand/mark.png --out-dir icons
Produces, in the output directory:
icon-16.png icon-32.png icon-48.png icon-180.png icon-256.png
icon-512.png icon-1024.png favicon.ico (16/32/48 multi-size)
Where each one goes:
1024 + 512 store listings, social cards, press kit
512 Forgejo org avatar, GitHub org avatar
180 apple-touch-icon (iOS home screen)
16/32/48 favicon.ico, browser tabs, bookmarks
"""
import argparse
import os
import shutil
import subprocess
import sys
from PIL import Image
RESAMPLE = getattr(Image, "Resampling", Image).LANCZOS
SIZES = [16, 32, 48, 180, 256, 512, 1024]
ICO_SIZES = [16, 32, 48, 64]
def load_square(path, size=1024):
"""Return a square RGBA image, rendering SVG at high resolution first."""
ext = os.path.splitext(path)[1].lower()
tmp = None
if ext == ".svg":
tmp = f"/tmp/.icon-src-{os.getpid()}.png"
if shutil.which("inkscape"):
subprocess.run(["inkscape", path, "--export-type=png", f"--export-filename={tmp}",
f"--export-width={size}", f"--export-height={size}"],
check=True, capture_output=True)
else:
subprocess.run(["convert", "-background", "none", "-density", "600", path,
"-resize", f"{size}x{size}", tmp], check=True, capture_output=True)
path = tmp
img = Image.open(path).convert("RGBA")
if tmp:
os.unlink(tmp)
side = max(img.size)
canvas = Image.new("RGBA", (side, side), (0, 0, 0, 0))
canvas.paste(img, ((side - img.width) // 2, (side - img.height) // 2), img)
return canvas.resize((size, size), RESAMPLE)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("source", help="square-ish .svg or .png/.jpg mark")
ap.add_argument("--out-dir", default="icons")
ap.add_argument("--small-from", default="",
help="optional simpler source used for 16/32/48 and the .ico, "
"because fine detail dies at tab size")
ap.add_argument("--small-max", type=int, default=48)
args = ap.parse_args()
if not os.path.exists(args.source):
sys.exit(f"no such file: {args.source}")
master = load_square(args.source)
small = load_square(args.small_from) if args.small_from else None
os.makedirs(args.out_dir, exist_ok=True)
for size in SIZES:
src = small if (small and size <= args.small_max) else master
out = os.path.join(args.out_dir, f"icon-{size}.png")
src.resize((size, size), RESAMPLE).save(out)
print(f" {out}{' (simplified)' if src is small else ''}")
ico_path = os.path.join(args.out_dir, "favicon.ico")
(small or master).resize((256, 256), RESAMPLE).save(
ico_path, sizes=[(s, s) for s in ICO_SIZES])
print(f" {ico_path} ({', '.join(str(s) for s in ICO_SIZES)})"
f"{' (simplified)' if small else ''}")
print(f"\n{len(SIZES) + 1} files in {args.out_dir}/")
print("upload icon-512.png as the org avatar on both forges; the rest ship with the app")
if __name__ == "__main__":
main()