fad4006f60
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
6.6 KiB
Python
191 lines
6.6 KiB
Python
"""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: <registry_root>/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())
|