vitals/tools/make_icons.py
Christian Sass e5fc0a1d35 icons: ship the leather mark, with a simplified version for tab size
Christian's leather patch art is the app icon at 180px and up. It cannot be
the favicon: leather grain and stitching turn to mud below 48px, and the
pulse is black on brown, which is not enough contrast in a tab bar.

So the set is two-tier. Sizes 180 and above come from the leather art;
16, 32, 48 and the .ico come from a flat source whose spike geometry was
measured off the original (left tail at 52% height, peak at 42% width,
trough at 54%, right tail at 84%). Same colours sampled from the leather
body, so the small mark reads as the same object rather than a different
logo.

make_icons.py gained --small-from for this: one command builds both tiers.

Signed-off-by: Christian Sass <csass@binaryranch.com>
2026-10-21 22:24:01 -05:00

93 lines
3.4 KiB
Python

#!/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()