b9716a7cd6
Snapshot de WIP acumulado de sesiones previas antes de merge wave 1 del flow 0008 (kanban_cpp + agent_runner_api + DoD schema). Incluye: - dev/flows/0008-kanban-cpp-and-agent-workflows.md - dev/issues/0112-0119*.md (7 sub-issues) - WIP previo en cmd/fn/doctor.go, registry/*, modules/, cpp/, etc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
295 lines
11 KiB
Python
295 lines
11 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 _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: <registry_root>/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())
|