"""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