"""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 _luminance(accent_hex: str) -> float: """Luminancia percibida Rec.601 normalizada [0,1].""" r, g, b = _hex_to_rgb(accent_hex) return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0 def _render_glyph_colored(svg_path: Path, size: int, fill: str) -> Image.Image: """Renderiza un SVG Phosphor reemplazando currentColor por `fill`.""" svg = svg_path.read_text(encoding="utf-8") svg = svg.replace('fill="currentColor"', f'fill="{fill}"') 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 _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 ADAPTIVE_LUMINANCE_THRESHOLD = 0.5 ADAPTIVE_DARK_GLYPH = "#0f0f12" ADAPTIVE_LIGHT_GLYPH = "#f4f4f5" WHITE_DUOTONE_GLYPH = "#ffffff" def _make_icon_image_duotone( phosphor_icon_name: str, accent_hex: str, size: int, phosphor_assets: Path, glyph_color: str, ) -> Image.Image: """Compone bg accent + glyph duotone Phosphor con `glyph_color` como fill. Usa la variante `duotone` de Phosphor cuando exista (el path con `opacity=0.2` crea automaticamente el segundo tono atenuado al mezclarse con el accent). Fallback a `fill` si no hay duotone. """ 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) draw.rounded_rectangle([(0, 0), (size - 1, size - 1)], radius=radius, fill=bg_color) glyph_size = int(size * 0.7) if glyph_size < 8: glyph_size = max(8, size - 2) svg_duo = phosphor_assets / "duotone" / f"{phosphor_icon_name}-duotone.svg" svg_fill = phosphor_assets / "fill" / f"{phosphor_icon_name}-fill.svg" svg_src = svg_duo if svg_duo.exists() else svg_fill if not svg_src.exists(): raise FileNotFoundError(f"Icono Phosphor no encontrado: {svg_src}") glyph = _render_glyph_colored(svg_src, glyph_size, glyph_color) off = ((size - glyph_size) // 2, (size - glyph_size) // 2) canvas.alpha_composite(glyph, dest=off) return canvas def _make_icon_image_adaptive( phosphor_icon_name: str, accent_hex: str, size: int, phosphor_assets: Path, threshold: float = ADAPTIVE_LUMINANCE_THRESHOLD, ) -> Image.Image: """Adaptive duotone: tono claro/oscuro segun luminancia del accent.""" glyph_color = ADAPTIVE_DARK_GLYPH if _luminance(accent_hex) >= threshold else ADAPTIVE_LIGHT_GLYPH return _make_icon_image_duotone(phosphor_icon_name, accent_hex, size, phosphor_assets, glyph_color) 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, style: str = "fill_white", ) -> 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. style: Estilo de render. Valores: - "fill_white" (default, backwards-compat): fondo accent + glyph blanco solido usando `weight`. - "adaptive_duotone": fondo accent + glyph duotone con tono claro/ oscuro elegido por luminancia del accent. Glyph oscuro `#0f0f12` si L>=0.5; glyph claro `#f4f4f5` si L<0.5. Usa variante `duotone` de Phosphor (el path con opacity=0.2 da el segundo tono). Fallback a `fill` si no hay duotone. - "white_duotone": fondo accent + glyph duotone Phosphor con fill `#ffffff`. Path bg al opacity=0.2 deja translucir el accent y produce un segundo tono mas suave. Legible en cualquier accent (claro u oscuro). Fallback a `fill` si no hay duotone. 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" o style invalido. """ if sizes is None: sizes = DEFAULT_SIZES if style not in ("fill_white", "adaptive_duotone", "white_duotone"): raise ValueError(f"style invalido: {style!r}. Valores: fill_white | adaptive_duotone | white_duotone") # 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) # Validar formato del color (antes de tocar disco) h = accent_hex.lstrip("#") if len(h) != 6: raise ValueError(f"accent_hex debe tener formato #RRGGBB, recibido: {accent_hex!r}") if style in ("adaptive_duotone", "white_duotone"): svg_duo = phosphor_assets / "duotone" / f"{phosphor_icon_name}-duotone.svg" svg_fill = phosphor_assets / "fill" / f"{phosphor_icon_name}-fill.svg" if not svg_duo.exists() and not svg_fill.exists(): raise FileNotFoundError( f"Icono Phosphor no encontrado: {svg_duo} ni {svg_fill}\n" f"Asegurate de que sources/phosphor-core/ existe." ) if style == "adaptive_duotone": images = { s: _make_icon_image_adaptive(phosphor_icon_name, accent_hex, s, phosphor_assets) for s in sorted(sizes, reverse=True) } else: # white_duotone images = { s: _make_icon_image_duotone(phosphor_icon_name, accent_hex, s, phosphor_assets, WHITE_DUOTONE_GLYPH) for s in sorted(sizes, reverse=True) } else: 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" ) 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())