tools/render_brand.py
Rama main · réplica de lectura del repositorio original.
"""Regenerate every brand artefact from app/branding.py.
Run after changing the geometry:
uv run python tools/render_brand.py
Writes the Jinja partial the app includes, the favicon, and the README banners.
`tests/test_branding.py` re-runs this in memory and fails if anything on disk
has drifted, so a redraw cannot land in the top bar and miss the favicon.
The PNGs need headless Chrome (same dependency as tools/screenshot.py).
"""
from __future__ import annotations
import subprocess
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app import branding # noqa: E402
from tools.browser import find_chrome # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
def partial() -> str:
"""The markup base.html includes — mark plus word, one source."""
return (
"{# GENERATED by tools/render_brand.py from app/branding.py — do not\n"
" edit. The wordmark is drawn paths because the app ships no webfont\n"
" and its CSP blocks remote ones. #}\n"
f"{branding.mark_svg()}\n{branding.wordmark_svg()}\n"
)
def _standalone(inner: str, viewbox: str) -> str:
"""A file that renders outside the page, so it carries its own colours."""
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{viewbox}" '
'role="img" aria-label="Stocktake">\n'
" <style>\n"
f" .c {{ fill: {branding.INK_LIGHT}; stroke: none; }}\n"
f" .s {{ fill: none; stroke: {branding.INK_LIGHT}; }}\n"
f" .top {{ fill: none; stroke: {branding.ACCENT_LIGHT}; }}\n"
" @media (prefers-color-scheme: dark) {\n"
f" .c {{ fill: {branding.INK_DARK}; }}\n"
f" .s {{ stroke: {branding.INK_DARK}; }}\n"
f" .top {{ stroke: {branding.ACCENT_DARK}; }}\n"
" }\n"
" </style>\n"
f" {inner}\n</svg>\n"
)
def icon_svg() -> str:
rects = "".join(
f'<rect class="{"top" if accent else "s"}" x="{x}" y="{y}" '
f'width="5.5" height="5.5" rx="1" stroke-width="2" stroke-linejoin="round"/>'
for x, y, accent in branding.MARK_RECTS
)
return _standalone(rects, "0 0 24 24")
def banner_html(dark: bool) -> str:
"""A README banner: mark and word side by side, on a flat ground."""
ink = branding.INK_DARK if dark else branding.INK_LIGHT
acc = branding.ACCENT_DARK if dark else branding.ACCENT_LIGHT
bg = "#0d1018" if dark else "#eef0f7"
return f"""<!doctype html><html><head><meta charset="utf-8"><style>
html,body{{margin:0;width:1200px;height:260px;background:{bg};
display:flex;align-items:center;justify-content:center;gap:28px}}
svg{{display:block}}
.brandmark{{width:104px;height:104px;fill:none;stroke:{ink};stroke-width:2;
stroke-linecap:round;stroke-linejoin:round}}
.brandmark .top{{stroke:{acc}}}
.brandword{{height:80px;width:auto;fill:{ink};color:{ink}}}
</style></head><body>{branding.mark_svg()}{branding.wordmark_svg()}</body></html>"""
def _chrome() -> str:
found = find_chrome()
if not found:
raise SystemExit("headless Chrome not found — PNGs not regenerated")
return found
def _shoot(html: str, out: Path, width: int, height: int) -> None:
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False) as fh:
fh.write(html)
tmp = fh.name
subprocess.run([_chrome(), "--headless", "--disable-gpu", "--hide-scrollbars",
f"--window-size={width},{height}", "--virtual-time-budget=2000",
f"--screenshot={out}", f"file://{tmp}"],
check=True, capture_output=True)
def main() -> None:
(ROOT / "app/templates/_brand.html").write_text(partial(), encoding="utf-8")
(ROOT / "app/static/icon.svg").write_text(icon_svg(), encoding="utf-8")
square = ('<!doctype html><html><head><meta charset="utf-8"><style>'
'html,body{margin:0;width:512px;height:512px;background:#eef0f7}'
'svg{display:block;width:512px;height:512px}</style></head><body>'
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 28 28" fill="none" '
f'stroke="{branding.INK_LIGHT}" stroke-width="2" stroke-linejoin="round" '
'stroke-linecap="round">'
+ "".join(f'<rect x="{x}" y="{y}" width="5.5" height="5.5" rx="1"'
+ (f' stroke="{branding.ACCENT_LIGHT}"' if accent else "") + "/>"
for x, y, accent in branding.MARK_RECTS)
+ "</svg></body></html>")
_shoot(square, ROOT / "docs/assets/icon.png", 512, 512)
(ROOT / "app/static/icon.png").write_bytes((ROOT / "docs/assets/icon.png").read_bytes())
for dark in (False, True):
name = f"wordmark-{'dark' if dark else 'light'}.png"
_shoot(banner_html(dark), ROOT / "docs/assets" / name, 1200, 260)
print("regenerated: _brand.html, icon.svg, icon.png, docs/assets/{icon,wordmark-*}.png")
if __name__ == "__main__":
main()