feat(infra): auto-commit con 88 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
"""Genera un avatar circular de iniciales tipo foto de perfil.
|
||||
|
||||
Dibuja un circulo relleno de color con 1-2 iniciales blancas centradas sobre
|
||||
fondo transparente y lo exporta como PNG cuadrado. El color de fondo se puede
|
||||
fijar explicitamente o derivar de forma DETERMINISTA del texto (mismo texto ->
|
||||
mismo color siempre), lo que produce avatares reconocibles y distintos por
|
||||
nombre sin necesidad de una imagen real.
|
||||
|
||||
Funciones publicas reutilizables:
|
||||
derive_initials — extrae 1-2 iniciales en mayusculas de un nombre
|
||||
derive_bg_color — mapea un texto a un color de paleta de forma estable
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Fuente bold preinstalada en este Linux. Si no existe, se cae al default de PIL.
|
||||
DEFAULT_FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||
|
||||
# Paleta agradable tipo Tailwind 500-600 (12 colores). El indice se elige de
|
||||
# forma determinista a partir del hash del texto -> mismo texto, mismo color.
|
||||
PALETTE = [
|
||||
"#0ea5e9", # sky-500
|
||||
"#10b981", # emerald-500
|
||||
"#8b5cf6", # violet-500
|
||||
"#f59e0b", # amber-500
|
||||
"#f43f5e", # rose-500
|
||||
"#6366f1", # indigo-500
|
||||
"#14b8a6", # teal-500
|
||||
"#f97316", # orange-500
|
||||
"#d946ef", # fuchsia-500
|
||||
"#84cc16", # lime-500
|
||||
"#06b6d4", # cyan-500
|
||||
"#ef4444", # red-500
|
||||
]
|
||||
|
||||
# Factor de supersampling para antialiasing: se renderiza a NxN veces el tamano
|
||||
# final y se reduce con LANCZOS para obtener bordes suaves.
|
||||
_SUPERSAMPLE = 4
|
||||
|
||||
# Margen del circulo respecto al canvas (~4% por lado).
|
||||
_MARGIN_RATIO = 0.04
|
||||
|
||||
# Tamano de fuente como fraccion del lado del canvas.
|
||||
_FONT_RATIO = 0.46
|
||||
|
||||
|
||||
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
|
||||
"""Convierte un color "#RRGGBB" (o "RRGGBB") a una tupla RGB."""
|
||||
h = h.lstrip("#")
|
||||
if len(h) != 6:
|
||||
raise ValueError(f"color hex invalido, se espera #RRGGBB: {h!r}")
|
||||
return tuple(int(h[i: i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def derive_initials(text: str) -> str:
|
||||
"""Extrae 1-2 iniciales en mayusculas a partir de un nombre.
|
||||
|
||||
Trocea el texto por espacios, guiones y guiones bajos. Toma la primera
|
||||
letra alfabetica de los dos primeros tokens que empiecen por letra. Si solo
|
||||
un token tiene letra inicial, devuelve 1 inicial. Si ninguno empieza por
|
||||
letra, usa el primer caracter alfanumerico del texto completo.
|
||||
|
||||
Args:
|
||||
text: Nombre del que derivar las iniciales (ej. "John Doe", "osint_01").
|
||||
|
||||
Returns:
|
||||
1 o 2 caracteres en mayusculas. Cadena vacia si no hay nada alfanumerico.
|
||||
"""
|
||||
# Normaliza separadores a espacios.
|
||||
normalized = text.replace("-", " ").replace("_", " ")
|
||||
tokens = [t for t in normalized.split() if t]
|
||||
|
||||
initials = []
|
||||
for token in tokens:
|
||||
# Primera letra alfabetica del token (el token debe empezar por letra).
|
||||
if token[0].isalpha():
|
||||
initials.append(token[0].upper())
|
||||
if len(initials) == 2:
|
||||
break
|
||||
|
||||
if initials:
|
||||
return "".join(initials)
|
||||
|
||||
# Fallback: primer caracter alfanumerico del texto entero.
|
||||
for ch in text:
|
||||
if ch.isalnum():
|
||||
return ch.upper()
|
||||
return ""
|
||||
|
||||
|
||||
def derive_bg_color(text: str) -> str:
|
||||
"""Mapea un texto a un color de la paleta de forma estable y determinista.
|
||||
|
||||
Usa md5 del texto para indexar la paleta, de modo que el mismo texto
|
||||
produce siempre el mismo color entre ejecuciones y procesos.
|
||||
|
||||
Args:
|
||||
text: Texto del que derivar el color.
|
||||
|
||||
Returns:
|
||||
Color en formato "#RRGGBB" de la paleta interna.
|
||||
"""
|
||||
digest = hashlib.md5(text.encode("utf-8")).hexdigest()
|
||||
idx = int(digest, 16) % len(PALETTE)
|
||||
return PALETTE[idx]
|
||||
|
||||
|
||||
def _load_font(font_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
"""Carga la fuente DejaVu Bold al tamano dado, con fallback al default."""
|
||||
try:
|
||||
return ImageFont.truetype(DEFAULT_FONT_PATH, font_size)
|
||||
except OSError:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def generate_initials_avatar(
|
||||
text: str,
|
||||
out_path: str,
|
||||
bg_hex: str = "",
|
||||
size: int = 256,
|
||||
fg_hex: str = "#FFFFFF",
|
||||
) -> str:
|
||||
"""Genera un avatar circular de iniciales y lo guarda como PNG.
|
||||
|
||||
Dibuja un circulo relleno con `bg_hex` (o un color derivado de `text` si va
|
||||
vacio) y centra 1-2 iniciales en `fg_hex` sobre el. El fondo fuera del
|
||||
circulo queda transparente. Renderiza a 4x y reduce con LANCZOS para bordes
|
||||
suaves.
|
||||
|
||||
Args:
|
||||
text: Nombre del que derivar las iniciales (ej. "Aurgi", "John Doe").
|
||||
out_path: Ruta de salida del PNG. El directorio padre se crea si falta.
|
||||
bg_hex: Color de fondo del circulo en "#RRGGBB". Si va vacio (""), se
|
||||
deriva de forma determinista de `text`.
|
||||
size: Lado del PNG cuadrado en pixels (default 256).
|
||||
fg_hex: Color del texto en "#RRGGBB" (default blanco "#FFFFFF").
|
||||
|
||||
Returns:
|
||||
La misma `out_path` recibida.
|
||||
|
||||
Raises:
|
||||
ValueError: Si algun color no tiene formato "#RRGGBB" o `size` <= 0.
|
||||
OSError: Si falla la escritura del archivo a disco.
|
||||
"""
|
||||
if size <= 0:
|
||||
raise ValueError(f"size debe ser positivo, recibido: {size!r}")
|
||||
|
||||
background = bg_hex if bg_hex else derive_bg_color(text)
|
||||
bg_rgb = _hex_to_rgb(background)
|
||||
fg_rgb = _hex_to_rgb(fg_hex)
|
||||
|
||||
initials = derive_initials(text)
|
||||
|
||||
# Render a 4x para antialiasing, luego se reduce con LANCZOS.
|
||||
big = size * _SUPERSAMPLE
|
||||
canvas = Image.new("RGBA", (big, big), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
margin = int(big * _MARGIN_RATIO)
|
||||
circle_box = [margin, margin, big - margin - 1, big - margin - 1]
|
||||
draw.ellipse(circle_box, fill=bg_rgb + (255,))
|
||||
|
||||
if initials:
|
||||
font_size = max(1, int(big * _FONT_RATIO))
|
||||
font = _load_font(font_size)
|
||||
|
||||
# Bounding box real del glyph (no solo ascent) para centrado optico.
|
||||
bbox = draw.textbbox((0, 0), initials, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
text_h = bbox[3] - bbox[1]
|
||||
# El origen del texto se desplaza por el offset del bbox para que el
|
||||
# glyph quede centrado tanto horizontal como verticalmente.
|
||||
text_x = (big - text_w) / 2 - bbox[0]
|
||||
text_y = (big - text_h) / 2 - bbox[1]
|
||||
draw.text((text_x, text_y), initials, font=font, fill=fg_rgb + (255,))
|
||||
|
||||
final = canvas.resize((size, size), Image.LANCZOS)
|
||||
|
||||
out = Path(out_path)
|
||||
if not out.is_absolute():
|
||||
out = Path.cwd() / out
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
final.save(out, format="PNG")
|
||||
except OSError as exc:
|
||||
raise OSError(f"no se pudo escribir el avatar en {out}: {exc}") from exc
|
||||
|
||||
return out_path
|
||||
Reference in New Issue
Block a user