- website wordmarks (logo.png/logo-dark.png) drop the black/white frame: the girl alone on transparency, black for light navbar / white for dark (docusaurus srcDark stays) - BrandMark marks are now the app-icon squircle itself (transparent corners, 824px safe-zone composition) instead of plain tiles; the components no longer paint a tile/rounding - generated icon outputs are NOT committed anymore: all 35 targets are gitignored and regenerated on demand by the consuming pipelines via the new scripts/generate-icons.mjs (website prebuild, desktop prebuild+dev, installer prebuild, web prebuild) - freshness lane switched from byte-compare to structural verification (sizes, squircle corner transparency, ICO frame sets via header parse) — no more windows/ubuntu byte drift dance - marks saved as 8-bit palette PNGs (FASTOCTREE quantize keeps per-index alpha tRNS -> 2.5KB, AA edges preserved); compose_svg renders in the background's native space so resvg scales per output size
516 lines
23 KiB
Python
516 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate every app icon in the repo from the nous-girl art + platform backgrounds.
|
|
|
|
Usage (from repo root):
|
|
.venv/Scripts/python.exe scripts/generate_icons.py # write
|
|
.venv/Scripts/python.exe scripts/generate_icons.py --check # verify structure
|
|
|
|
Sources of truth — two axes, composed per target:
|
|
Girl art (vector): assets/nous-girl-black.svg (black positive space)
|
|
assets/nous-girl-white.svg (white positive space)
|
|
straight from the Nous brand kit (Inkscape exports,
|
|
5487^2 viewBox, one path each).
|
|
|
|
Backgrounds (per platform surface, light/dark):
|
|
assets/backgrounds/squircle-light.svg white rounded
|
|
assets/backgrounds/squircle-dark.svg #0d1117 rounded
|
|
|
|
The master SVGs (assets/icon-master.svg light, assets/icon-master-dark.svg
|
|
dark) are GENERATED artifacts — squircle background + girl nested into the
|
|
824px HIG content safe zone. The light master drives every squircle target;
|
|
the dark master drives the dark-appearance targets.
|
|
|
|
The girl is nested via its art bbox as viewBox, so it always lands centered in
|
|
the box (824 safe zone for squircles / height-fitted for the marks) without
|
|
distortion. The girl art corners sit ~185px from the squircle arc centers vs
|
|
the 245px radius, so no art touches the rounded corners on any OS mask.
|
|
|
|
GENERATED OUTPUTS ARE NOT COMMITTED. Everything this script writes is
|
|
gitignored and regenerated on demand by the consuming pipelines (website
|
|
prebuild, desktop prebuild/predev, installer prebuild, web prebuild — via
|
|
scripts/generate-icons.mjs). The freshness lane (icons-freshness-check.yml)
|
|
runs --check, which regenerates in memory and asserts structural invariants
|
|
(sizes, transparency, container frame sets) — there are no committed bytes to
|
|
byte-compare against.
|
|
|
|
Rendering: resvg (resvg-py) for SVG -> PNG fidelity at every size.
|
|
Containers: Pillow for multi-size .ico and .icns.
|
|
|
|
Deps:
|
|
Pillow (core dependency), resvg-py (dev extra):
|
|
uv sync --extra dev
|
|
|
|
Outputs (30 files):
|
|
assets/icon-master.svg generated light master
|
|
assets/icon-master-dark.svg generated dark master
|
|
apps/desktop/assets/icon.png 1024x1024 squircle (light)
|
|
apps/desktop/assets/icon.ico 16,24,32,48,64,128,256
|
|
apps/desktop/assets/icon.icns 16..1024 (real ICNS)
|
|
apps/desktop/assets/icon-dark.png 1024x1024 squircle (dark)
|
|
apps/desktop/assets/icon-dark.ico 16,24,32,48,64,128,256
|
|
apps/desktop/assets/icon-dark.icns 16..1024 (real ICNS)
|
|
apps/desktop/assets/appx/Wide310x150Logo.png 310x150, squircle 100 centered
|
|
apps/desktop/assets/appx/StoreLogo.png 50x50 squircle
|
|
apps/desktop/assets/appx/Square44x44Logo.png 44x44 squircle
|
|
apps/desktop/assets/appx/Square150x150Logo.png 150x150 squircle
|
|
apps/desktop/assets/appx/*-dark.png dark-appearance logos
|
|
apps/desktop/public/apple-touch-icon.png 1024x1024 squircle
|
|
apps/desktop/public/nous-girl.png 256x256 squircle, black girl (light mark)
|
|
apps/desktop/public/nous-girl-dark.png 256x256 squircle, white girl (dark mark)
|
|
apps/bootstrap-installer/src-tauri/icons/32x32.png 32x32
|
|
apps/bootstrap-installer/src-tauri/icons/128x128.png 128x128
|
|
apps/bootstrap-installer/src-tauri/icons/128x128@2x.png 256x256
|
|
apps/bootstrap-installer/src-tauri/icons/icon.ico 16,32,64,128,256
|
|
apps/bootstrap-installer/src-tauri/icons/icon.icns 16..1024
|
|
apps/bootstrap-installer/public/nous-girl.png 256x256 squircle mark (light)
|
|
website/static/img/logo.png 1772x1799 girl alone, transparent (light)
|
|
website/static/img/logo-dark.png 1772x1799 girl alone, transparent (dark)
|
|
website/static/img/nous-logo.png 150x150 on white (opaque)
|
|
website/static/img/nous-logo-dark.png 150x150 on #0d1117 (opaque)
|
|
website/static/img/favicon-16x16.png 16x16
|
|
website/static/img/favicon-32x32.png 32x32
|
|
website/static/img/apple-touch-icon.png 180x180
|
|
website/static/img/favicon.ico 16,32,48
|
|
website/static/img/favicon.svg copy of the light master
|
|
web/public/favicon.ico 16,32,48
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
|
|
try:
|
|
import resvg_py
|
|
except ImportError:
|
|
sys.exit(
|
|
"resvg-py is missing. Install it with:\n"
|
|
" uv sync --extra dev"
|
|
)
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
ASSETS = ROOT / "assets"
|
|
BACKGROUNDS = ASSETS / "backgrounds"
|
|
GIRL_BLACK = ASSETS / "nous-girl-black.svg"
|
|
GIRL_WHITE = ASSETS / "nous-girl-white.svg"
|
|
GIRLS = {"black": GIRL_BLACK, "white": GIRL_WHITE}
|
|
MASTER = ASSETS / "icon-master.svg"
|
|
MASTER_DARK = ASSETS / "icon-master-dark.svg"
|
|
|
|
# The squircle corner radius measured on the real 1024 icon (~245px).
|
|
SQUIRCLE_RADIUS = 245
|
|
# The nous dark background (#0d1117) — fixed dark tile/background everywhere.
|
|
DARK_HEX = "#0d1117"
|
|
DARK_RGB = (13, 17, 23)
|
|
|
|
# Girl placement per background: (x, y, w, h) in that background's coordinate
|
|
# space. Squircle backgrounds put the girl in the 824px HIG content safe zone
|
|
# (centered, 100px pad on a 1024 canvas). Marks reuse the same squircles.
|
|
GIRL_BOXES = {
|
|
"squircle-light.svg": (100, 100, 824, 824),
|
|
"squircle-dark.svg": (100, 100, 824, 824),
|
|
}
|
|
# The brand-kit SVG canvas (both girl svgs share this viewBox).
|
|
GIRL_VIEWBOX = 5487.0615
|
|
|
|
# Target sizes for --check's structural verification: relpath -> (format, size)
|
|
CHECK_SIZES: dict[str, tuple[str, tuple[int, int]]] = {
|
|
"apps/desktop/assets/icon.png": ("PNG", (1024, 1024)),
|
|
"apps/desktop/assets/icon-dark.png": ("PNG", (1024, 1024)),
|
|
"apps/desktop/assets/appx/Wide310x150Logo.png": ("PNG", (310, 150)),
|
|
"apps/desktop/assets/appx/Wide310x150Logo-dark.png": ("PNG", (310, 150)),
|
|
"apps/desktop/assets/appx/StoreLogo.png": ("PNG", (50, 50)),
|
|
"apps/desktop/assets/appx/StoreLogo-dark.png": ("PNG", (50, 50)),
|
|
"apps/desktop/assets/appx/Square44x44Logo.png": ("PNG", (44, 44)),
|
|
"apps/desktop/assets/appx/Square44x44Logo-dark.png": ("PNG", (44, 44)),
|
|
"apps/desktop/assets/appx/Square150x150Logo.png": ("PNG", (150, 150)),
|
|
"apps/desktop/assets/appx/Square150x150Logo-dark.png": ("PNG", (150, 150)),
|
|
"apps/desktop/public/apple-touch-icon.png": ("PNG", (1024, 1024)),
|
|
"apps/desktop/public/nous-girl.png": ("PNG", (256, 256)),
|
|
"apps/desktop/public/nous-girl-dark.png": ("PNG", (256, 256)),
|
|
"apps/bootstrap-installer/src-tauri/icons/32x32.png": ("PNG", (32, 32)),
|
|
"apps/bootstrap-installer/src-tauri/icons/128x128.png": ("PNG", (128, 128)),
|
|
"apps/bootstrap-installer/src-tauri/icons/128x128@2x.png": ("PNG", (256, 256)),
|
|
"apps/bootstrap-installer/public/nous-girl.png": ("PNG", (256, 256)),
|
|
"website/static/img/logo.png": ("PNG", (1772, 1799)),
|
|
"website/static/img/logo-dark.png": ("PNG", (1772, 1799)),
|
|
"website/static/img/nous-logo.png": ("PNG", (150, 150)),
|
|
"website/static/img/nous-logo-dark.png": ("PNG", (150, 150)),
|
|
"website/static/img/favicon-16x16.png": ("PNG", (16, 16)),
|
|
"website/static/img/favicon-32x32.png": ("PNG", (32, 32)),
|
|
"website/static/img/apple-touch-icon.png": ("PNG", (180, 180)),
|
|
}
|
|
|
|
# (relpath, kind, arg)
|
|
TARGETS: list[tuple[str, str, object]] = [
|
|
("assets/icon-master.svg", "svg", None),
|
|
("assets/icon-master-dark.svg", "svg_dark", None),
|
|
("apps/desktop/assets/icon.png", "png", 1024),
|
|
("apps/desktop/assets/icon.ico", "ico", [16, 24, 32, 48, 64, 128, 256]),
|
|
("apps/desktop/assets/icon.icns", "icns", None),
|
|
("apps/desktop/assets/icon-dark.png", "png_dark", 1024),
|
|
("apps/desktop/assets/icon-dark.ico", "ico_dark", [16, 24, 32, 48, 64, 128, 256]),
|
|
("apps/desktop/assets/icon-dark.icns", "icns_dark", None),
|
|
("apps/desktop/assets/appx/Wide310x150Logo.png", "wide", (310, 150)),
|
|
("apps/desktop/assets/appx/StoreLogo.png", "png", 50),
|
|
("apps/desktop/assets/appx/Square44x44Logo.png", "png", 44),
|
|
("apps/desktop/assets/appx/Square150x150Logo.png", "png", 150),
|
|
("apps/desktop/assets/appx/Wide310x150Logo-dark.png", "wide_dark", (310, 150)),
|
|
("apps/desktop/assets/appx/StoreLogo-dark.png", "png_dark", 50),
|
|
("apps/desktop/assets/appx/Square44x44Logo-dark.png", "png_dark", 44),
|
|
("apps/desktop/assets/appx/Square150x150Logo-dark.png", "png_dark", 150),
|
|
("apps/desktop/public/apple-touch-icon.png", "png", 1024),
|
|
("apps/desktop/public/nous-girl.png", "girl_light", 256),
|
|
("apps/desktop/public/nous-girl-dark.png", "girl_dark", 256),
|
|
("apps/bootstrap-installer/src-tauri/icons/32x32.png", "png", 32),
|
|
("apps/bootstrap-installer/src-tauri/icons/128x128.png", "png", 128),
|
|
("apps/bootstrap-installer/src-tauri/icons/128x128@2x.png", "png", 256),
|
|
("apps/bootstrap-installer/src-tauri/icons/icon.ico", "ico", [16, 32, 64, 128, 256]),
|
|
("apps/bootstrap-installer/src-tauri/icons/icon.icns", "icns", None),
|
|
("apps/bootstrap-installer/public/nous-girl.png", "girl_light", 256),
|
|
("website/static/img/logo.png", "logo", None),
|
|
("website/static/img/logo-dark.png", "logo_dark", None),
|
|
("website/static/img/nous-logo.png", "png_white", 150),
|
|
("website/static/img/nous-logo-dark.png", "png_dark_white", 150),
|
|
("website/static/img/favicon-16x16.png", "png", 16),
|
|
("website/static/img/favicon-32x32.png", "png", 32),
|
|
("website/static/img/apple-touch-icon.png", "png", 180),
|
|
("website/static/img/favicon.ico", "ico", [16, 32, 48]),
|
|
("website/static/img/favicon.svg", "svg_copy", None),
|
|
("web/public/favicon.ico", "ico", [16, 32, 48]),
|
|
]
|
|
|
|
# ─── girl art extraction ────────────────────────────────────────────────────
|
|
|
|
_path_cache: dict[str, str] = {}
|
|
_bbox_cache: dict[str, tuple[float, float, float, float]] = {}
|
|
|
|
|
|
def girl_path(girl: str) -> str:
|
|
"""The girl `<path>` element with editor metadata stripped (resvg rejects
|
|
undeclared inkscape/sodipodi prefixes)."""
|
|
if girl not in _path_cache:
|
|
src = GIRLS[girl].read_text(encoding="utf-8")
|
|
m = re.search(r"<path\b.*?/>", src, re.S)
|
|
assert m, f"no <path> found in {GIRLS[girl].name}"
|
|
path = re.sub(r'\s+(inkscape|sodipodi):[a-zA-Z-]+="[^"]*"', "", m.group(0))
|
|
_path_cache[girl] = path
|
|
return _path_cache[girl]
|
|
|
|
|
|
def girl_bbox(girl: str) -> tuple[float, float, float, float]:
|
|
"""Art bounding box in the girl SVG's coordinate space, measured by
|
|
rendering once and taking the alpha bbox (robust to art changes)."""
|
|
if girl not in _bbox_cache:
|
|
data = resvg_py.svg_to_bytes(svg_path=str(GIRLS[girl]), width=512, height=512)
|
|
im = Image.open(io.BytesIO(data))
|
|
bx, by, bx2, by2 = im.getchannel("A").point(lambda v: 255 if v > 0 else 0).getbbox()
|
|
s = GIRL_VIEWBOX / 512.0
|
|
_bbox_cache[girl] = (bx * s, by * s, (bx2 - bx) * s, (by2 - by) * s)
|
|
return _bbox_cache[girl]
|
|
|
|
|
|
def girl_layer(girl: str, box: tuple[float, float, float, float]) -> str:
|
|
"""Nested-svg layer: girl art (bbox as viewBox) placed into `box` — the
|
|
box's aspect is preserved via 'meet', so the girl never distorts."""
|
|
bx, by, bw, bh = girl_bbox(girl)
|
|
x, y, w, h = box
|
|
return (
|
|
f'<svg x="{x}" y="{y}" width="{w}" height="{h}" viewBox="{bx} {by} {bw} {bh}">\n'
|
|
f" {girl_path(girl)}\n"
|
|
" </svg>"
|
|
)
|
|
|
|
|
|
def background_inner(name: str) -> tuple[str, int, int]:
|
|
"""Inner content + (width, height) of a background SVG asset."""
|
|
text = (BACKGROUNDS / name).read_text(encoding="utf-8")
|
|
m = re.search(r'<svg\b[^>]*viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"[^>]*>', text)
|
|
assert m, f"cannot parse viewBox of {name}"
|
|
w, h = float(m.group(1)), float(m.group(2))
|
|
inner = re.sub(r"^.*?>\s*", "", text, count=1, flags=re.S)
|
|
inner = re.sub(r"\s*</svg>\s*$", "", inner, flags=re.S)
|
|
return inner, int(w), int(h)
|
|
|
|
|
|
def compose_svg(girl: str, bg: str) -> str:
|
|
"""Full svg text: background + girl layer, in the background's native
|
|
coordinate space (resvg scales to whatever output size is requested, so
|
|
the composition is size-agnostic — no manual box scaling)."""
|
|
inner, w, h = background_inner(bg)
|
|
return (
|
|
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}">\n'
|
|
f" {inner.strip()}\n"
|
|
f" {girl_layer(girl, GIRL_BOXES[bg])}\n"
|
|
"</svg>\n"
|
|
)
|
|
|
|
|
|
def ensure_masters(check: bool = False) -> None:
|
|
"""Write the generated light/dark masters; placeholder fallback if the
|
|
girl art is missing."""
|
|
missing = [p for p in (GIRL_BLACK, GIRL_WHITE) if not p.exists()]
|
|
if missing:
|
|
if check:
|
|
sys.exit(f"[check] girl art missing: {[p.name for p in missing]}")
|
|
placeholder = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
|
<rect x="0" y="0" width="1024" height="1024" rx="{SQUIRCLE_RADIUS}" fill="#ffffff"/>
|
|
<circle cx="512" cy="512" r="420" fill="#e34c4c"/>
|
|
</svg>
|
|
'''
|
|
for path in (MASTER, MASTER_DARK):
|
|
path.write_text(placeholder, encoding="utf-8")
|
|
print(f"[master] wrote placeholder masters ({[p.name for p in missing]})")
|
|
return
|
|
MASTER.write_text(compose_svg("black", "squircle-light.svg"), encoding="utf-8")
|
|
MASTER_DARK.write_text(compose_svg("white", "squircle-dark.svg"), encoding="utf-8")
|
|
|
|
|
|
# ─── rendering ──────────────────────────────────────────────────────────────
|
|
|
|
def render(master: Path, size: int, *, background: str | None = None) -> Image.Image:
|
|
"""Render a master to an RGBA PNG of `size`x`size`."""
|
|
data = resvg_py.svg_to_bytes(
|
|
svg_path=str(master), width=size, height=size, background=background
|
|
)
|
|
return Image.open(io.BytesIO(data)).convert("RGBA")
|
|
|
|
|
|
def render_svg(svg: str, size: int | tuple[int, int]) -> Image.Image:
|
|
if isinstance(size, int):
|
|
w = h = size
|
|
else:
|
|
w, h = size
|
|
data = resvg_py.svg_to_bytes(svg_string=svg, width=w, height=h)
|
|
return Image.open(io.BytesIO(data)).convert("RGBA")
|
|
|
|
|
|
def paste_centered(canvas: Image.Image, img: Image.Image) -> None:
|
|
x = (canvas.width - img.width) // 2
|
|
y = (canvas.height - img.height) // 2
|
|
canvas.alpha_composite(img, (x, y))
|
|
|
|
|
|
def save_png(img: Image.Image, buf: io.BytesIO) -> None:
|
|
"""Save with alpha preserved. Flat art quantizes losslessly to an 8-bit
|
|
palette (tRNS per-index alpha keeps the AA edges), so use that when the
|
|
palette round-trips pixel-identically; fall back to RGBA otherwise."""
|
|
if img.mode != "RGBA":
|
|
img.convert("RGBA").save(buf, "PNG", optimize=True)
|
|
return
|
|
quantized = img.quantize(colors=256, method=Image.FASTOCTREE, dither=Image.NONE)
|
|
if quantized.convert("RGBA").tobytes() == img.tobytes():
|
|
quantized.save(buf, "PNG", optimize=True)
|
|
else:
|
|
img.save(buf, "PNG", optimize=True)
|
|
|
|
|
|
def girl_mark(kind: str, size: int) -> Image.Image:
|
|
"""The girl in the app-icon squircle (BrandMark asset) — the mark IS the
|
|
icon shape. girl_light: black girl on white squircle. girl_dark: white
|
|
girl on #0d1117 squircle."""
|
|
if kind == "girl_light":
|
|
girl, bg = "black", "squircle-light.svg"
|
|
else:
|
|
girl, bg = "white", "squircle-dark.svg"
|
|
if not GIRLS[girl].exists():
|
|
print(f" [girl] {GIRLS[girl].name} missing — placeholder fallback")
|
|
return render(MASTER if kind == "girl_light" else MASTER_DARK, size)
|
|
return render_svg(compose_svg(girl, bg), size)
|
|
|
|
|
|
def build_logo_image(master: Path, dark: bool = False) -> Image.Image:
|
|
"""1772x1799 wordmark: the girl alone on transparency (no frame), centered.
|
|
Light = black girl, dark = white girl — the consuming surface's background
|
|
(navbar light/dark) shows through."""
|
|
W, H = 1772, 1799
|
|
svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}">\n {girl_layer("white" if dark else "black", (0, 0, W, H))}\n</svg>\n'
|
|
return render_svg(svg, (W, H))
|
|
|
|
|
|
def target_bytes(kind: str, arg: object) -> bytes:
|
|
"""Produce the exact bytes for one target. Shared by write + check."""
|
|
if kind == "svg":
|
|
return MASTER.read_bytes()
|
|
if kind == "svg_dark":
|
|
return MASTER_DARK.read_bytes()
|
|
if kind == "svg_copy":
|
|
return MASTER.read_bytes()
|
|
|
|
buf = io.BytesIO()
|
|
if kind == "png":
|
|
save_png(render(MASTER, arg), buf)
|
|
elif kind == "png_dark":
|
|
save_png(render(MASTER_DARK, arg), buf)
|
|
elif kind == "png_white":
|
|
render(MASTER, arg, background="#ffffff").convert("RGB").save(buf, "PNG", optimize=True)
|
|
elif kind == "png_dark_white":
|
|
render(MASTER_DARK, arg, background=DARK_HEX).convert("RGB").save(buf, "PNG", optimize=True)
|
|
elif kind in ("girl_light", "girl_dark"):
|
|
save_png(girl_mark(kind, arg), buf)
|
|
elif kind == "ico":
|
|
img = render(MASTER, max(arg))
|
|
img.save(buf, format="ICO", sizes=[(s, s) for s in arg])
|
|
elif kind == "ico_dark":
|
|
img = render(MASTER_DARK, max(arg))
|
|
img.save(buf, format="ICO", sizes=[(s, s) for s in arg])
|
|
elif kind == "icns":
|
|
img = render(MASTER, 1024)
|
|
frames = [img.resize((s, s), Image.LANCZOS) for s in (16, 32, 64, 128, 256, 512, 1024)]
|
|
img.save(buf, format="ICNS", append_images=frames[1:])
|
|
elif kind == "icns_dark":
|
|
img = render(MASTER_DARK, 1024)
|
|
frames = [img.resize((s, s), Image.LANCZOS) for s in (16, 32, 64, 128, 256, 512, 1024)]
|
|
img.save(buf, format="ICNS", append_images=frames[1:])
|
|
elif kind == "wide":
|
|
w, h = arg
|
|
canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
paste_centered(canvas, render(MASTER, 100))
|
|
canvas.save(buf, "PNG")
|
|
elif kind == "wide_dark":
|
|
w, h = arg
|
|
canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
paste_centered(canvas, render(MASTER_DARK, 100))
|
|
canvas.save(buf, "PNG")
|
|
elif kind == "logo":
|
|
build_logo_image(MASTER, dark=False).save(buf, "PNG")
|
|
elif kind == "logo_dark":
|
|
build_logo_image(MASTER_DARK, dark=True).save(buf, "PNG")
|
|
else:
|
|
raise ValueError(f"unknown kind {kind!r}")
|
|
return buf.getvalue()
|
|
|
|
|
|
def cmd_write() -> None:
|
|
ensure_masters()
|
|
written = 0
|
|
for rel, kind, arg in TARGETS:
|
|
path = ROOT / rel
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
path.write_bytes(target_bytes(kind, arg))
|
|
written += 1
|
|
except Exception as exc: # noqa: BLE001 - report and keep going
|
|
print(f" !! {rel}: FAILED ({exc})")
|
|
print(f"[ok] wrote {written}/{len(TARGETS)} files")
|
|
|
|
print("\n[verify]")
|
|
for rel, kind, arg in TARGETS:
|
|
path = ROOT / rel
|
|
try:
|
|
if kind in ("svg", "svg_dark", "svg_copy"):
|
|
print(f" {rel}: {path.stat().st_size} bytes SVG")
|
|
continue
|
|
im = Image.open(path)
|
|
if kind in ("ico", "ico_dark"):
|
|
sizes = []
|
|
try:
|
|
for i in range(im.n_frames):
|
|
im.seek(i)
|
|
sizes.append(im.size)
|
|
except Exception:
|
|
sizes = [im.size]
|
|
print(f" {rel}: ICO {sorted(set(sizes))}")
|
|
elif kind in ("icns", "icns_dark"):
|
|
print(f" {rel}: ICNS {im.size} (container)")
|
|
else:
|
|
print(f" {rel}: {im.format} {im.size}")
|
|
except Exception as exc:
|
|
print(f" {rel}: VERIFY FAILED ({exc})")
|
|
|
|
|
|
def cmd_check() -> int:
|
|
"""Structural verification: regenerate every target in memory and assert
|
|
the invariants that actually matter (there are no committed bytes to
|
|
byte-compare — outputs are generated on demand)."""
|
|
ensure_masters(check=True)
|
|
problems: list[str] = []
|
|
|
|
# every target must generate without error
|
|
for rel, kind, arg in TARGETS:
|
|
try:
|
|
target_bytes(kind, arg)
|
|
except Exception as exc: # noqa: BLE001
|
|
problems.append(f"{rel}: REGENERATE FAILED ({exc})")
|
|
|
|
# PNG targets must have the expected format + size
|
|
for rel, (fmt, size) in CHECK_SIZES.items():
|
|
path = ROOT / rel
|
|
if not path.exists():
|
|
problems.append(f"{rel}: MISSING (expected generated file)")
|
|
continue
|
|
try:
|
|
im = Image.open(path)
|
|
if im.format != fmt or im.size != size:
|
|
problems.append(f"{rel}: got {im.format} {im.size}, expected {fmt} {size}")
|
|
except Exception as exc: # noqa: BLE001
|
|
problems.append(f"{rel}: UNREADABLE ({exc})")
|
|
|
|
# squircles must keep transparent corners (alpha extrema include 0)
|
|
for rel in (
|
|
"apps/desktop/assets/icon.png",
|
|
"apps/desktop/assets/icon-dark.png",
|
|
"apps/desktop/public/nous-girl.png",
|
|
"apps/desktop/public/nous-girl-dark.png",
|
|
"apps/desktop/public/apple-touch-icon.png",
|
|
):
|
|
path = ROOT / rel
|
|
if not path.exists():
|
|
continue
|
|
alpha = Image.open(path).convert("RGBA").getchannel("A")
|
|
lo, hi = alpha.getextrema()
|
|
if lo != 0 or hi != 255:
|
|
problems.append(f"{rel}: alpha extrema {alpha.getextrema()}, expected (0, 255) transparent corners")
|
|
|
|
# containers must have the right frame sets (parse ICO headers directly —
|
|
# PIL's ICO n_frames is unreliable across versions)
|
|
def ico_sizes(path: Path) -> list[int]:
|
|
data = path.read_bytes()
|
|
count = int.from_bytes(data[4:6], "little")
|
|
sizes = []
|
|
for i in range(count):
|
|
entry = data[6 + i * 16 : 6 + (i + 1) * 16]
|
|
w = entry[0] or 256
|
|
h = entry[1] or 256
|
|
sizes.append(w)
|
|
return sorted(set(sizes))
|
|
|
|
for rel, sizes in (
|
|
("apps/desktop/assets/icon.ico", [16, 24, 32, 48, 64, 128, 256]),
|
|
("apps/desktop/assets/icon-dark.ico", [16, 24, 32, 48, 64, 128, 256]),
|
|
("apps/bootstrap-installer/src-tauri/icons/icon.ico", [16, 32, 64, 128, 256]),
|
|
):
|
|
path = ROOT / rel
|
|
if not path.exists():
|
|
problems.append(f"{rel}: MISSING")
|
|
continue
|
|
try:
|
|
got = ico_sizes(path)
|
|
if got != sizes:
|
|
problems.append(f"{rel}: ICO frames {got}, expected {sizes}")
|
|
except Exception as exc: # noqa: BLE001
|
|
problems.append(f"{rel}: UNREADABLE ({exc})")
|
|
|
|
if not problems:
|
|
print(f"[ok] all {len(TARGETS)} targets generate and pass structural checks")
|
|
return 0
|
|
|
|
print(f"[check] {len(problems)} problem(s):")
|
|
for line in problems:
|
|
print(f" - {line}")
|
|
return 1
|
|
|
|
|
|
def main() -> None:
|
|
if "--check" in sys.argv:
|
|
sys.exit(cmd_check())
|
|
cmd_write()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|