"""Genera un icono .ico multi-resolucion para apps C++ del registry. Rasteriza un icono Phosphor SVG sobre un fondo redondeado del color accent y exporta un .ico con multiples resoluciones (16, 24, 32, 48, 64, 128, 256). Funciones publicas reutilizables: _find_registry_root — localiza la raiz del registry _render_glyph_white — rasteriza un SVG Phosphor como glyph blanco RGBA _make_icon_image — compone fondo redondeado + glyph en un PIL.Image """ import io import os from pathlib import Path import cairosvg from PIL import Image, ImageDraw DEFAULT_SIZES = [16, 24, 32, 48, 64, 128, 256] def _find_registry_root(registry_root: str | None = None) -> Path: """Descubre la raiz del registry. Prioridad: argumento explicito > FN_REGISTRY_ROOT env > caminar hacia arriba desde el archivo hasta encontrar registry.db. Args: registry_root: Ruta absoluta opcional. Si se pasa, se devuelve directamente. Returns: Path resuelto a la raiz del registry. Raises: FileNotFoundError: Si no se puede localizar registry.db. """ if registry_root is not None: return Path(registry_root).resolve() env_root = os.environ.get("FN_REGISTRY_ROOT") if env_root: return Path(env_root).resolve() current = Path(__file__).resolve() for parent in current.parents: if (parent / "registry.db").exists(): return parent raise FileNotFoundError( "No se encontro registry.db caminando desde el archivo hasta la raiz. " "Define FN_REGISTRY_ROOT en el entorno." ) def _hex_to_rgb(h: str) -> tuple[int, int, int]: h = h.lstrip("#") return tuple(int(h[i: i + 2], 16) for i in (0, 2, 4)) def _render_glyph_white(svg_path: Path, size: int) -> Image.Image: """Renderiza el SVG Phosphor como glyph blanco sobre fondo transparente. Args: svg_path: Ruta al archivo .svg de Phosphor. size: Ancho/alto en pixels del output. Returns: Imagen RGBA con el glyph en blanco sobre fondo transparente. """ svg = svg_path.read_text(encoding="utf-8") # Phosphor usa fill="currentColor" — forzar blanco. svg = svg.replace('fill="currentColor"', 'fill="#ffffff"') png_bytes = cairosvg.svg2png( bytestring=svg.encode("utf-8"), output_width=size, output_height=size, ) return Image.open(io.BytesIO(png_bytes)).convert("RGBA") def _make_icon_image(svg_path: Path, accent_hex: str, size: int) -> Image.Image: """Compone fondo redondeado con color accent + glyph blanco centrado al 70%. Args: svg_path: Ruta al archivo .svg de Phosphor. accent_hex: Color de fondo en formato "#RRGGBB". size: Ancho/alto en pixels del canvas de salida. Returns: Imagen RGBA lista para guardar como PNG o ICO. """ bg_color = _hex_to_rgb(accent_hex) + (255,) canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) draw = ImageDraw.Draw(canvas) radius = max(2, size // 6) # ~16% de radio redondeado draw.rounded_rectangle( [(0, 0), (size - 1, size - 1)], radius=radius, fill=bg_color, ) # El glyph ocupa ~70% del canvas (padding ~15% en cada lado). glyph_size = int(size * 0.7) if glyph_size < 8: glyph_size = max(8, size - 2) glyph = _render_glyph_white(svg_path, glyph_size) off = ((size - glyph_size) // 2, (size - glyph_size) // 2) canvas.alpha_composite(glyph, dest=off) return canvas def generate_app_icon( phosphor_icon_name: str, accent_hex: str, out_ico_path: str, *, weight: str = "fill", sizes: list[int] = None, phosphor_root: str = None, ) -> str: """Genera un icono .ico multi-resolucion a partir de un SVG Phosphor. Rasteriza el icono Phosphor indicado sobre un fondo redondeado del color accent y exporta un .ico con multiples resoluciones ordenadas de mayor a menor para maxima compatibilidad con Windows. Args: phosphor_icon_name: Nombre del icono Phosphor sin sufijo de weight (ej. "chart-bar", "tree-structure", "gauge"). accent_hex: Color de fondo en formato hexadecimal "#RRGGBB" (ej. "#0ea5e9", "#7c3aed"). out_ico_path: Ruta de salida para el archivo .ico. Puede ser absoluta o relativa al directorio de trabajo actual. El directorio padre se crea si no existe. weight: Variante del icono Phosphor. Default "fill". Otros valores validos segun el repositorio: "regular", "bold", "light", "thin", "duotone". sizes: Lista de resoluciones a incluir en el .ico. Default [16, 24, 32, 48, 64, 128, 256]. El orden no importa; se renderiza cada tamano individualmente para maxima crispness. phosphor_root: Ruta a la carpeta raiz de assets de phosphor-core (la que contiene subdirectorios "fill", "regular", etc.). Default: /sources/phosphor-core/assets. Returns: Ruta absoluta del archivo .ico generado. Raises: FileNotFoundError: Si el SVG del icono no existe en phosphor_root. ValueError: Si accent_hex no tiene el formato "#RRGGBB". """ if sizes is None: sizes = DEFAULT_SIZES # Resolver raiz de phosphor if phosphor_root is None: _root = _find_registry_root() phosphor_assets = _root / "sources" / "phosphor-core" / "assets" else: phosphor_assets = Path(phosphor_root) svg_file = phosphor_assets / weight / f"{phosphor_icon_name}-{weight}.svg" if not svg_file.exists(): raise FileNotFoundError( f"Icono Phosphor no encontrado: {svg_file}\n" f"Asegurate de que sources/phosphor-core/ existe. Si no:\n" f" git clone --depth=1 https://github.com/phosphor-icons/core.git " f"sources/phosphor-core" ) # Validar formato del color h = accent_hex.lstrip("#") if len(h) != 6: raise ValueError(f"accent_hex debe tener formato #RRGGBB, recibido: {accent_hex!r}") # Renderizar cada resolucion individualmente para crispness en tamanos pequeños images = {s: _make_icon_image(svg_file, accent_hex, s) for s in sorted(sizes, reverse=True)} out = Path(out_ico_path) if not out.is_absolute(): out = Path.cwd() / out out.parent.mkdir(parents=True, exist_ok=True) sorted_sizes = sorted(sizes, reverse=True) biggest = images[sorted_sizes[0]] others = [images[s] for s in sorted_sizes[1:]] biggest.save( out, format="ICO", sizes=[(s, s) for s in sorted_sizes], append_images=others, ) return str(out.resolve())