"""Generate static image assets (logo + favicons) from the brand source. This script is the single source of truth for every image under ``app/static/img/``. Running it re-derives: - ``logo.png`` — 256px tall, transparent RGBA - ``logo.webp`` — same size, WebP quality=82, method=6 - ``favicon.ico`` — multi-size 16/32/48 from a square crop - ``apple-touch-icon.png`` — 180x180 with a cream background Running it is reproducible and idempotent. The generated files are committed to the repo so a fresh clone can serve the site without a build step, but they should be regenerated (by running this script) any time the brand source in ``Logo/`` changes. Usage ----- python scripts/generate_static_assets.py The script assumes it is run from the repository root. It resolves paths relative to this file's location so the working directory does not matter in practice. """ from __future__ import annotations import sys from pathlib import Path from PIL import Image # --- Configuration constants ------------------------------------------------ # Single-source-of-truth values. Tweaking any of these is intended to be # the *only* change needed to retune the assets — no other code edits. # Paths are resolved relative to the repository root (the parent of the # directory this script lives in) so the script can be run from anywhere. _REPO_ROOT: Path = Path(__file__).resolve().parent.parent _SOURCE_LOGO: Path = _REPO_ROOT / "Logo" / "chicken babies r us.png" _STATIC_IMG_DIR: Path = _REPO_ROOT / "app" / "static" / "img" # Target output sizes. _LOGO_TARGET_HEIGHT_PX: int = 256 # 2x the 48px display height in the header. _WEBP_QUALITY: int = 82 _WEBP_METHOD: int = 6 # 0 = fastest, 6 = best compression. _FAVICON_SIZES: tuple[tuple[int, int], ...] = ((16, 16), (32, 32), (48, 48)) _APPLE_TOUCH_SIZE: int = 180 # Cream background that matches --c-cream in site.css, so the icon does # not show as transparent (iOS squares this asset against a white/black # home screen). _APPLE_TOUCH_BG: tuple[int, int, int, int] = (0xFA, 0xF3, 0xE7, 0xFF) class StaticAssetBuilder: """Build every derived static image from a single PNG source. Encapsulated as a class so each transformation step is an independently-testable method and state (e.g. the loaded source image) is shared without relying on module globals. """ def __init__(self, source_path: Path, output_dir: Path) -> None: """Load the source image and prepare the output directory. Parameters ---------- source_path: Path to the brand-source PNG. Must exist on disk. output_dir: Directory where every generated asset is written. Created if it does not already exist. """ if not source_path.is_file(): raise FileNotFoundError( f"Source logo not found at {source_path}. " "Re-check the Logo/ directory." ) self._source_path = source_path self._output_dir = output_dir self._output_dir.mkdir(parents=True, exist_ok=True) # Always load as RGBA so alpha compositing (apple-touch-icon # background) and WebP export behave predictably. with Image.open(source_path) as raw: self._source: Image.Image = raw.convert("RGBA") # ------------------------------------------------------------------ # Individual asset builders # ------------------------------------------------------------------ def build_logo_png(self) -> Path: """Write the RGBA PNG version of the logo.""" resized = self._aspect_resize(self._source, height=_LOGO_TARGET_HEIGHT_PX) out_path = self._output_dir / "logo.png" resized.save(out_path, format="PNG", optimize=True) return out_path def build_logo_webp(self) -> Path: """Write the WebP version of the logo at the same pixel size.""" resized = self._aspect_resize(self._source, height=_LOGO_TARGET_HEIGHT_PX) out_path = self._output_dir / "logo.webp" resized.save( out_path, format="WEBP", quality=_WEBP_QUALITY, method=_WEBP_METHOD, ) return out_path def build_favicon(self) -> Path: """Write the multi-size ICO favicon built from a square crop. The source logo is landscape, so we center it on a transparent square canvas sized to the longer dimension before downsizing. """ square = self._square_pad(self._source, background=(0, 0, 0, 0)) out_path = self._output_dir / "favicon.ico" # Pillow's .save(..., format="ICO", sizes=...) writes every # requested size into one .ico file; browsers pick the best one. square.save(out_path, format="ICO", sizes=list(_FAVICON_SIZES)) return out_path def build_apple_touch_icon(self) -> Path: """Write the 180x180 apple-touch-icon with a cream background. iOS composites this icon against the home-screen background, so a transparent PNG would bleed through. We paint the cream brand background behind the logo ourselves to lock the look. """ square = self._square_pad(self._source, background=_APPLE_TOUCH_BG) icon = square.resize( (_APPLE_TOUCH_SIZE, _APPLE_TOUCH_SIZE), resample=Image.Resampling.LANCZOS, ) out_path = self._output_dir / "apple-touch-icon.png" icon.save(out_path, format="PNG", optimize=True) return out_path # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ @staticmethod def _aspect_resize(image: Image.Image, *, height: int) -> Image.Image: """Return a new image scaled to the given height, aspect preserved.""" src_w, src_h = image.size # Guard against degenerate input; ratio math would divide by zero. if src_h == 0: raise ValueError("Source image has zero height.") new_w = max(1, round(src_w * (height / src_h))) return image.resize((new_w, height), resample=Image.Resampling.LANCZOS) @staticmethod def _square_pad( image: Image.Image, *, background: tuple[int, int, int, int], ) -> Image.Image: """Center the image on a square canvas of the given background. We pad rather than crop so the whole mark survives at every favicon size. For a landscape logo, padding vertically preserves the design at the cost of a little empty space top and bottom — the correct trade-off for a recognition-first icon. """ src_w, src_h = image.size side = max(src_w, src_h) canvas = Image.new("RGBA", (side, side), background) offset = ((side - src_w) // 2, (side - src_h) // 2) canvas.paste(image, offset, mask=image if image.mode == "RGBA" else None) return canvas def main() -> int: """Generate every static asset and report the output paths. Returns a shell exit code (0 on success) so the script can be chained into CI or build scripts without wrapping. """ builder = StaticAssetBuilder(_SOURCE_LOGO, _STATIC_IMG_DIR) generated: list[Path] = [ builder.build_logo_png(), builder.build_logo_webp(), builder.build_favicon(), builder.build_apple_touch_icon(), ] # Emit a short human-readable report. Using print (not structlog) # because this is a one-shot developer tool, not runtime app code. print("Generated static assets:") for path in generated: rel = path.relative_to(_REPO_ROOT) print(f" - {rel}") return 0 if __name__ == "__main__": sys.exit(main())