#!/usr/bin/env python3 from __future__ import annotations import argparse import io import os import sys import zipfile try: from PIL import Image except ImportError: print("FAIL: Pillow is required (python3 -m pip install Pillow)") sys.exit(2) STAMP_COUNT = 32 MAX_PNG_BYTES = 1024 * 1024 MAX_ZIP_BYTES = 20 * 1024 * 1024 STAMP_EDGE_MARGIN = 18 MAIN_EDGE_MARGIN = 18 TAB_EDGE_MARGIN = 6 REQUIRED = ["main.png", "tab.png"] + [f"{i:02d}.png" for i in range(1, STAMP_COUNT + 1)] def read_entries(path: str) -> tuple[dict[str, bytes], list[str]]: issues: list[str] = [] entries: dict[str, bytes] = {} if os.path.isdir(path): for name in os.listdir(path): full = os.path.join(path, name) if os.path.isfile(full): with open(full, "rb") as handle: entries[name] = handle.read() return entries, issues if not zipfile.is_zipfile(path): return {}, [f"not a directory or ZIP: {path}"] if os.path.getsize(path) > MAX_ZIP_BYTES: issues.append(f"ZIP exceeds 20 MB: {os.path.getsize(path)} bytes") with zipfile.ZipFile(path) as archive: for info in archive.infolist(): if info.is_dir() or info.filename.startswith("__MACOSX/"): continue name = os.path.basename(info.filename) if not name: continue if name in entries: issues.append(f"duplicate filename in ZIP: {name}") continue entries[name] = archive.read(info) return entries, issues def has_alpha(image: Image.Image) -> bool: return image.mode in {"RGBA", "LA"} or (image.mode == "P" and "transparency" in image.info) def alpha_bbox(image: Image.Image) -> tuple[int, int, int, int] | None: return image.convert("RGBA").getchannel("A").getbbox() def inspect_edge_margin(name: str, image: Image.Image, margin: int) -> list[str]: bbox = alpha_bbox(image) if bbox is None: return [f"{name}: no visible non-transparent pixels"] width, height = image.size left, top, right, bottom = bbox too_close: list[str] = [] if left <= margin: too_close.append(f"left {left}px") if top <= margin: too_close.append(f"top {top}px") if width - right <= margin: too_close.append(f"right {width - right}px") if height - bottom <= margin: too_close.append(f"bottom {height - bottom}px") if too_close: return [ f"{name}: visible content is too close to image edge " f"(minimum {margin}px required; {', '.join(too_close)})" ] return [] def inspect_png(name: str, data: bytes) -> list[str]: issues: list[str] = [] if len(data) > MAX_PNG_BYTES: issues.append(f"{name}: file exceeds 1 MB") try: image = Image.open(io.BytesIO(data)) image.load() except Exception as exc: return [f"{name}: cannot open image ({exc})"] if image.format != "PNG": issues.append(f"{name}: not PNG format") width, height = image.size if not has_alpha(image): issues.append(f"{name}: missing alpha transparency") if name == "main.png": if (width, height) != (240, 240): issues.append(f"{name}: expected 240x240, got {width}x{height}") issues.extend(inspect_edge_margin(name, image, MAIN_EDGE_MARGIN)) elif name == "tab.png": if (width, height) != (96, 74): issues.append(f"{name}: expected 96x74, got {width}x{height}") issues.extend(inspect_edge_margin(name, image, TAB_EDGE_MARGIN)) else: if width > 370 or height > 320: issues.append(f"{name}: exceeds 370x320, got {width}x{height}") if width % 2 or height % 2: issues.append(f"{name}: dimensions should be even, got {width}x{height}") issues.extend(inspect_edge_margin(name, image, STAMP_EDGE_MARGIN)) return issues def validate(path: str) -> list[str]: entries, issues = read_entries(path) for name in REQUIRED: if name not in entries: issues.append(f"missing required file: {name}") allowed = set(REQUIRED) for name in sorted(entries): if name not in allowed: issues.append(f"unexpected file at package root: {name}") for name in REQUIRED: if name in entries: issues.extend(inspect_png(name, entries[name])) return issues def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("path") args = parser.parse_args() issues = validate(args.path) if issues: print("FAIL") for issue in issues: print(f"- {issue}") return 1 print("PASS") return 0 if __name__ == "__main__": raise SystemExit(main())