1 parent
16d8426119
commit
66158e222e
398 files changed
+85259
-270637
No files matched your search
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate icon assets for web UI/UX (favicons + PWA icons) and optional UE packaging.
|
||||
|
||||
This script is designed to be simple, predictable, and cross-platform.
|
||||
|
||||
Inputs:
|
||||
- A single source image: preferably a square 1024x1024 PNG.
|
||||
- SVG is accepted only if optional rasterizers are installed.
|
||||
|
||||
Outputs (by default):
|
||||
- Web/PWA: favicon.ico (multi-size), apple-touch-icon.png, icon-192.png, icon-512.png,
|
||||
icon-maskable-512.png (padded + background).
|
||||
- UE (optional): Windows icon.ico (multi-size), macOS .iconset folder, Linux PNGs.
|
||||
|
||||
Dependencies:
|
||||
- Pillow (required): pip install pillow
|
||||
|
||||
Usage:
|
||||
python scripts/generate_icons.py --input icon.png --out ./dist --targets web
|
||||
python scripts/generate_icons.py --input icon.png --out ./dist --targets web,ue
|
||||
|
||||
Notes:
|
||||
- For SVG input: export to a 1024x1024 PNG first (recommended) unless you install CairoSVG.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError as e: # pragma: no cover
|
||||
raise SystemExit(
|
||||
"Pillow is required. Install it with: pip install pillow"
|
||||
) from e
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Options:
|
||||
input_path: Path
|
||||
out_dir: Path
|
||||
targets: set[str]
|
||||
pad_to_square: bool
|
||||
background: str
|
||||
maskable_scale: float
|
||||
resample: int
|
||||
|
||||
|
||||
def _parse_hex_color(value: str) -> tuple[int, int, int, int]:
|
||||
v = value.strip().lstrip("#")
|
||||
if len(v) == 6:
|
||||
r = int(v[0:2], 16)
|
||||
g = int(v[2:4], 16)
|
||||
b = int(v[4:6], 16)
|
||||
return (r, g, b, 255)
|
||||
if len(v) == 8:
|
||||
r = int(v[0:2], 16)
|
||||
g = int(v[2:4], 16)
|
||||
b = int(v[4:6], 16)
|
||||
a = int(v[6:8], 16)
|
||||
return (r, g, b, a)
|
||||
raise ValueError(f"Invalid color '{value}'. Use #RRGGBB or #RRGGBBAA")
|
||||
|
||||
|
||||
def _is_svg(path: Path) -> bool:
|
||||
return path.suffix.lower() == ".svg"
|
||||
|
||||
|
||||
def _load_image(path: Path) -> Image.Image:
|
||||
if _is_svg(path):
|
||||
# Best-effort: try cairosvg if installed, else fail with actionable guidance.
|
||||
try:
|
||||
import cairosvg # type: ignore
|
||||
|
||||
png_bytes = cairosvg.svg2png(url=str(path))
|
||||
from io import BytesIO
|
||||
|
||||
return Image.open(BytesIO(png_bytes)).convert("RGBA")
|
||||
except ImportError as e:
|
||||
raise SystemExit(
|
||||
"SVG input requires CairoSVG (pip install cairosvg) or export SVG to a 1024x1024 PNG first."
|
||||
) from e
|
||||
return Image.open(path).convert("RGBA")
|
||||
|
||||
|
||||
def _ensure_square(img: Image.Image, pad_to_square: bool, background_rgba: tuple[int, int, int, int]) -> Image.Image:
|
||||
w, h = img.size
|
||||
if w == h:
|
||||
return img
|
||||
if not pad_to_square:
|
||||
side = min(w, h)
|
||||
left = (w - side) // 2
|
||||
top = (h - side) // 2
|
||||
return img.crop((left, top, left + side, top + side))
|
||||
side = max(w, h)
|
||||
canvas = Image.new("RGBA", (side, side), background_rgba)
|
||||
x = (side - w) // 2
|
||||
y = (side - h) // 2
|
||||
canvas.alpha_composite(img, (x, y))
|
||||
return canvas
|
||||
|
||||
|
||||
def _resize(img: Image.Image, size: int, resample: int) -> Image.Image:
|
||||
if img.size == (size, size):
|
||||
return img
|
||||
return img.resize((size, size), resample=resample)
|
||||
|
||||
|
||||
def _fit_with_padding(img: Image.Image, size: int, content_scale: float, background_rgba: tuple[int, int, int, int], resample: int) -> Image.Image:
|
||||
"""Create a square icon with padding.
|
||||
|
||||
For maskable icons, we want important content inside a safe zone.
|
||||
A pragmatic default is to fit the original artwork inside a centered square
|
||||
with side length = size * content_scale.
|
||||
"""
|
||||
|
||||
content_scale = max(0.1, min(content_scale, 1.0))
|
||||
inner = int(round(size * content_scale))
|
||||
inner = max(1, min(inner, size))
|
||||
inner_img = _resize(img, inner, resample)
|
||||
canvas = Image.new("RGBA", (size, size), background_rgba)
|
||||
x = (size - inner) // 2
|
||||
y = (size - inner) // 2
|
||||
canvas.alpha_composite(inner_img, (x, y))
|
||||
return canvas
|
||||
|
||||
|
||||
def _write_png(img: Image.Image, path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(path, format="PNG", optimize=True)
|
||||
|
||||
|
||||
def _write_ico(source_square: Image.Image, path: Path, sizes: Iterable[int], resample: int) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
icon_sizes = [(s, s) for s in sizes]
|
||||
|
||||
# Pillow expects the base image to be at least as large as the biggest size.
|
||||
max_size = max(sizes)
|
||||
base = _resize(source_square, max_size, resample)
|
||||
base.save(path, format="ICO", sizes=icon_sizes)
|
||||
|
||||
|
||||
def generate_web(opts: Options, src_square: Image.Image) -> None:
|
||||
# Minimal, modern set (good coverage without going crazy):
|
||||
# - favicon.ico (16/32/48)
|
||||
# - apple-touch-icon.png (180)
|
||||
# - icon-192.png, icon-512.png
|
||||
# - icon-maskable-512.png (opaque background + safe padding)
|
||||
out = opts.out_dir
|
||||
|
||||
_write_ico(src_square, out / "favicon.ico", sizes=[16, 32, 48], resample=opts.resample)
|
||||
_write_png(_resize(src_square, 180, opts.resample), out / "apple-touch-icon.png")
|
||||
_write_png(_resize(src_square, 192, opts.resample), out / "icon-192.png")
|
||||
_write_png(_resize(src_square, 512, opts.resample), out / "icon-512.png")
|
||||
|
||||
bg = _parse_hex_color(opts.background)
|
||||
maskable = _fit_with_padding(
|
||||
img=src_square,
|
||||
size=512,
|
||||
content_scale=opts.maskable_scale,
|
||||
background_rgba=bg,
|
||||
resample=opts.resample,
|
||||
)
|
||||
_write_png(maskable, out / "icon-maskable-512.png")
|
||||
|
||||
|
||||
def generate_ue(opts: Options, src_square: Image.Image) -> None:
|
||||
out = opts.out_dir
|
||||
|
||||
# Windows: multi-size .ico. Minimum recommended set includes 16/24/32/48/256.
|
||||
_write_ico(src_square, out / "ue" / "windows" / "icon.ico", sizes=[16, 24, 32, 48, 64, 128, 256], resample=opts.resample)
|
||||
|
||||
# macOS: generate a .iconset folder; creating .icns requires iconutil on macOS.
|
||||
iconset_dir = out / "ue" / "mac" / "AppIcon.iconset"
|
||||
iconset_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Standard mac iconset filenames.
|
||||
mac_entries: list[tuple[str, int]] = [
|
||||
("icon_16x16.png", 16),
|
||||
("icon_16x16@2x.png", 32),
|
||||
("icon_32x32.png", 32),
|
||||
("icon_32x32@2x.png", 64),
|
||||
("icon_128x128.png", 128),
|
||||
("icon_128x128@2x.png", 256),
|
||||
("icon_256x256.png", 256),
|
||||
("icon_256x256@2x.png", 512),
|
||||
("icon_512x512.png", 512),
|
||||
("icon_512x512@2x.png", 1024),
|
||||
]
|
||||
for filename, size in mac_entries:
|
||||
_write_png(_resize(src_square, size, opts.resample), iconset_dir / filename)
|
||||
|
||||
# Linux: PNG is typically fine.
|
||||
_write_png(_resize(src_square, 256, opts.resample), out / "ue" / "linux" / "icon-256.png")
|
||||
_write_png(_resize(src_square, 512, opts.resample), out / "ue" / "linux" / "icon-512.png")
|
||||
|
||||
|
||||
def _parse_targets(value: str) -> set[str]:
|
||||
targets = {t.strip().lower() for t in value.split(",") if t.strip()}
|
||||
allowed = {"web", "ue"}
|
||||
unknown = targets - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown targets: {', '.join(sorted(unknown))}. Use: web, ue")
|
||||
return targets
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Generate web/PWA and UE icon assets from a single source image.")
|
||||
parser.add_argument("--input", required=True, help="Path to source image (PNG recommended; SVG supported with cairosvg).")
|
||||
parser.add_argument("--out", required=True, help="Output directory.")
|
||||
parser.add_argument("--targets", default="web", help="Comma-separated: web, ue (default: web)")
|
||||
parser.add_argument(
|
||||
"--no-pad",
|
||||
action="store_true",
|
||||
help="Do not pad to square. If the input is not square, crop instead.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--background",
|
||||
default="#000000",
|
||||
help="Background color for maskable icons and padding. Use #RRGGBB or #RRGGBBAA (default: #000000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--maskable-scale",
|
||||
type=float,
|
||||
default=0.80,
|
||||
help="Scale of the artwork inside maskable icon canvas (default: 0.80).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
input_path = Path(args.input)
|
||||
out_dir = Path(args.out)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
targets = _parse_targets(args.targets)
|
||||
|
||||
# Pillow resampling: prefer high-quality downsampling.
|
||||
# Use getattr to satisfy type checkers across Pillow versions/stubs.
|
||||
resample = (
|
||||
getattr(getattr(Image, "Resampling", None), "LANCZOS", None)
|
||||
or getattr(Image, "LANCZOS", None)
|
||||
or getattr(Image, "ANTIALIAS", None)
|
||||
or getattr(Image, "BICUBIC", 3)
|
||||
)
|
||||
|
||||
opts = Options(
|
||||
input_path=input_path,
|
||||
out_dir=out_dir,
|
||||
targets=targets,
|
||||
pad_to_square=not args.no_pad,
|
||||
background=args.background,
|
||||
maskable_scale=args.maskable_scale,
|
||||
resample=resample,
|
||||
)
|
||||
|
||||
bg = _parse_hex_color(opts.background)
|
||||
src = _load_image(opts.input_path)
|
||||
src_square = _ensure_square(src, pad_to_square=opts.pad_to_square, background_rgba=bg)
|
||||
|
||||
if "web" in opts.targets:
|
||||
generate_web(opts, src_square)
|
||||
if "ue" in opts.targets:
|
||||
generate_ue(opts, src_square)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user