"""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 - ``logo-mark.png`` — chick-only mark, 128px tall, transparent RGBA - ``logo-mark.webp`` — same, 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. _LOGO_MARK_TARGET_HEIGHT_PX: int = 128 # ~2x the 56px header-icon display size. _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_logo_mark_png(self) -> Path: """Write the chick-only mark as RGBA PNG.""" mark = self._aspect_resize( self._crop_chick_mark(), height=_LOGO_MARK_TARGET_HEIGHT_PX ) out_path = self._output_dir / "logo-mark.png" mark.save(out_path, format="PNG", optimize=True) return out_path def build_logo_mark_webp(self) -> Path: """Write the chick-only mark as WebP.""" mark = self._aspect_resize( self._crop_chick_mark(), height=_LOGO_MARK_TARGET_HEIGHT_PX ) out_path = self._output_dir / "logo-mark.webp" mark.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) def _crop_chick_mark(self) -> Image.Image: """Return a copy of the source cropped to just the chick artwork. Strategy: scan the source column-by-column for transparency, find the widest contiguous transparent "gap" between opaque columns, and treat the content to the left of that gap as the chick. This is robust to future logo tweaks (new fonts, wider tracking, shifted text) because it does not rely on hardcoded pixel coordinates — only on the visual fact that the brand mark and wordmark are separated by a wider gap than any gap internal to either side. """ img = self._source alpha = img.split()[-1] width, height = img.size # Per-column "has any opaque pixel" flags. getextrema on a 1px # band returns (min, max); max > 0 means at least one opaque # pixel. This is O(width) Pillow calls — fast enough here. opaque = [ alpha.crop((x, 0, x + 1, height)).getextrema()[1] > 0 for x in range(width) ] first_opaque = next((x for x, o in enumerate(opaque) if o), None) last_opaque = next( (x for x in range(width - 1, -1, -1) if opaque[x]), None ) if first_opaque is None or last_opaque is None: raise ValueError("Source logo has no opaque pixels.") # Enumerate transparent runs strictly between first/last opaque. gaps: list[tuple[int, int]] = [] # (gap_start, gap_end_exclusive) run_start: int | None = None for x in range(first_opaque, last_opaque + 1): if not opaque[x] and run_start is None: run_start = x elif opaque[x] and run_start is not None: gaps.append((run_start, x)) run_start = None if not gaps: raise ValueError( "No transparent gap found between chick and wordmark; " "cannot split the mark automatically." ) gap_start, _ = max(gaps, key=lambda g: g[1] - g[0]) chick_slice = img.crop((first_opaque, 0, gap_start, height)) # Trim vertical whitespace so the resulting icon sits flush. inner = chick_slice.getbbox() if inner is None: raise ValueError("Detected chick region is empty.") return chick_slice.crop(inner) @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_logo_mark_png(), builder.build_logo_mark_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())