"""export_hub_icons — rasteriza iconos PNG para todas las apps C++/imgui del registry. Para cada app cpp/imgui registrada en registry.db lee el bloque `icon:` de su app.md (phosphor + accent), rasteriza un PNG cuadrado usando la misma logica visual que generate_app_icon y lo escribe en out_dir/.png. El directorio de salida lo consume app_hub_launcher via fn::gl_texture_load. """ from __future__ import annotations import os import sqlite3 import sys from pathlib import Path from typing import Any from infra.generate_app_icon import ( _find_registry_root, _make_icon_image, _make_icon_image_adaptive, _make_icon_image_duotone, WHITE_DUOTONE_GLYPH, ) DEFAULT_PHOSPHOR = "app-window" DEFAULT_ACCENT = "#64748b" DEFAULT_SIZE = 64 DEFAULT_STYLE = "fill_white" VALID_STYLES = ("fill_white", "adaptive_duotone", "white_duotone") def _read_frontmatter(md_path: Path) -> dict[str, Any]: """Parse YAML frontmatter from a .md file. Returns {} on any error.""" try: import yaml text = md_path.read_text(encoding="utf-8") if not text.startswith("---"): return {} end = text.find("\n---", 3) if end == -1: return {} yaml_block = text[3:end].strip() data = yaml.safe_load(yaml_block) return data if isinstance(data, dict) else {} except Exception as exc: print(f"[export_hub_icons] WARN: could not parse {md_path}: {exc}", file=sys.stderr) return {} def export_hub_icons( out_dir: str, *, size: int = DEFAULT_SIZE, registry_root: str | None = None, style: str = DEFAULT_STYLE, ) -> dict: """Rasteriza iconos PNG para todas las apps C++/imgui del registry. Consulta registry.db buscando apps con lang='cpp' AND framework='imgui', lee el bloque icon: de cada app.md (phosphor + accent), y escribe un PNG cuadrado de size x size pixels en out_dir/.png usando el mismo estilo visual de generate_app_icon: fondo redondeado del color accent con glyph Phosphor blanco centrado al 70%. Si una app.md no existe, no tiene icono declarado o el SVG Phosphor no se encuentra, el app se omite (skipped) y el proceso continua con las demas. Args: out_dir: Directorio de salida. Se crea si no existe. Los PNGs existentes se sobreescriben. size: Lado del cuadrado del PNG en pixels. Default 64. registry_root: Ruta a la raiz del fn_registry. Si es None, usa la variable de entorno FN_REGISTRY_ROOT o /home/lucas/fn_registry como fallback. Returns: { "ok": True, "count": N, # PNGs escritos correctamente "out_dir": "", # ruta absoluta del directorio de salida "skipped": [ # apps omitidas con razon {"name": "...", "reason": "..."}, ... ] } """ if style not in VALID_STYLES: raise ValueError(f"style invalido: {style!r}. Valores: {' | '.join(VALID_STYLES)}") root = _find_registry_root(registry_root) db_path = root / "registry.db" if not db_path.exists(): raise FileNotFoundError(f"registry.db not found at {db_path}") con = sqlite3.connect(str(db_path)) con.row_factory = sqlite3.Row try: rows = con.execute( "SELECT id, name, dir_path FROM apps" " WHERE lang='cpp' AND framework='imgui'" " ORDER BY name" ).fetchall() finally: con.close() phosphor_assets = root / "sources" / "phosphor-core" / "assets" out_path = Path(out_dir).resolve() out_path.mkdir(parents=True, exist_ok=True) count = 0 skipped: list[dict[str, str]] = [] for row in rows: app_name: str = row["name"] dir_path: str = row["dir_path"] # --- Leer app.md --- md_path = root / dir_path / "app.md" if not md_path.exists(): msg = f"app.md missing at {md_path}" print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr) skipped.append({"name": app_name, "reason": msg}) continue fm = _read_frontmatter(md_path) if not fm: msg = "app.md missing or malformed frontmatter" print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr) skipped.append({"name": app_name, "reason": msg}) continue icon_block = fm.get("icon") if isinstance(icon_block, dict): phosphor_name = icon_block.get("phosphor") or DEFAULT_PHOSPHOR accent_hex = icon_block.get("accent") or DEFAULT_ACCENT else: phosphor_name = DEFAULT_PHOSPHOR accent_hex = DEFAULT_ACCENT # --- Localizar SVG Phosphor (duotone preferido si style lo necesita) --- svg_fill = phosphor_assets / "fill" / f"{phosphor_name}-fill.svg" svg_duo = phosphor_assets / "duotone" / f"{phosphor_name}-duotone.svg" if style == "fill_white": if not svg_fill.exists(): msg = f"Phosphor SVG not found: {svg_fill}" print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr) skipped.append({"name": app_name, "reason": msg}) continue else: if not svg_duo.exists() and not svg_fill.exists(): msg = f"Phosphor SVG not found: {svg_duo} ni {svg_fill}" print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr) skipped.append({"name": app_name, "reason": msg}) continue # --- Rasterizar y guardar PNG --- try: if style == "fill_white": img = _make_icon_image(svg_fill, accent_hex, size) elif style == "adaptive_duotone": img = _make_icon_image_adaptive(phosphor_name, accent_hex, size, phosphor_assets) else: # white_duotone img = _make_icon_image_duotone(phosphor_name, accent_hex, size, phosphor_assets, WHITE_DUOTONE_GLYPH) png_out = out_path / f"{app_name}.png" img.save(str(png_out), format="PNG") count += 1 except Exception as exc: msg = f"render error: {exc}" print(f"[export_hub_icons] SKIP {app_name}: {msg}", file=sys.stderr) skipped.append({"name": app_name, "reason": msg}) continue return { "ok": True, "count": count, "out_dir": str(out_path), "skipped": skipped, } if __name__ == "__main__": import argparse import json parser = argparse.ArgumentParser( description="Export PNG icons for all cpp/imgui apps to a directory." ) parser.add_argument("out_dir", help="Destination directory for .png files") parser.add_argument( "--size", type=int, default=DEFAULT_SIZE, help=f"Icon side in pixels (default {DEFAULT_SIZE})", ) parser.add_argument( "--registry-root", default=None, help="Path to fn_registry root (default: FN_REGISTRY_ROOT env or /home/lucas/fn_registry)", ) parser.add_argument( "--style", default=DEFAULT_STYLE, choices=VALID_STYLES, help=f"Estilo del icono (default {DEFAULT_STYLE})", ) args = parser.parse_args() result = export_hub_icons( args.out_dir, size=args.size, registry_root=args.registry_root, style=args.style, ) print(json.dumps(result, indent=2))