#!/usr/bin/env python3 """Generador de fuentes bitmap (.gif + .fnt) desde TTF o GIF existente. Herramienta standalone con dos interfaces: - CLI: python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont - GUI: python3 font_gen.py (sin argumentos abre la interfaz Tk) Genera un GIF indexado (índice 0 = fondo, índice 1 = glifo) más un .fnt de texto con el ancho visual de cada caracter, listos para motores de texto bitmap. Dependencias: pip install Pillow Opcional: imagemagick (binario `magick`) para forzar paleta de 2 colores. """ from __future__ import annotations import argparse import os import shutil import sys from dataclasses import dataclass, field from math import ceil try: from PIL import Image, ImageDraw, ImageFont except ImportError: print("Error: Pillow no está instalado. Ejecuta: pip install Pillow", file=sys.stderr) sys.exit(1) # Conjunto de caracteres en el mismo orden que los ficheros .fnt del juego. # ASCII 32-126 primero, luego extensiones para castellano, catalán y valenciano. _ASCII_CHARS = [chr(i) for i in range(32, 127)] # 95 chars: espacio … ~ _EXTENDED_CHARS = list("ÀÁÈÉÌÍÒÓÙÚÑÇàáèéìíòóùúñç¡¿«»·") # 29 chars ES/CA/VA ALL_CHARS = _ASCII_CHARS + _EXTENDED_CHARS # 124 total # Caracteres de fallback para TTFs sin soporte de acentos/especiales. # Si el TTF no tiene el char, se dibuja el fallback en su celda del bitmap. # El .fnt sigue registrando el codepoint original → texto localizado funciona. CHAR_FALLBACKS: dict[str, str] = { "À": "A", "Á": "A", "È": "E", "É": "E", "Ì": "I", "Í": "I", "Ò": "O", "Ó": "O", "Ù": "U", "Ú": "U", "Ñ": "N", "Ç": "C", "à": "a", "á": "a", "è": "e", "é": "e", "ì": "i", "í": "i", "ò": "o", "ó": "o", "ù": "u", "ú": "u", "ñ": "n", "ç": "c", "¡": "!", "¿": "?", "«": "<", "»": ">", "·": ".", } @dataclass class FontBitmapResult: """Resultado puro del rasterizado de un TTF (sin tocar disco). Devuelto por build_font_bitmap() y consumido por save_font_files() y por la GUI para mostrar el preview. """ img: "Image.Image" # PIL P-mode, idx 0 = fondo, idx 1 = glifo chars: list[str] # caracteres incluidos en orden char_widths: dict[str, int] # ancho visual por caracter box_width: int box_height: int columns: int fallback_used: dict[str, str] = field(default_factory=dict) # ch → ch dibujado truly_skipped: list[str] = field(default_factory=list) def _write_fnt( output_fnt: str, output_name: str, source_name: str, box_width: int, box_height: int, columns: int, cell_spacing: int, row_spacing: int, chars: list[str], char_widths: dict[str, int], ) -> None: """Escribe el fichero .fnt.""" with open(output_fnt, "w", encoding="utf-8") as f: f.write(f"# Font: {output_name} — generado desde {source_name}\n") f.write(f"# Generado con font_gen.py\n\n") f.write(f"box_width {box_width}\n") f.write(f"box_height {box_height}\n") f.write(f"columns {columns}\n") if cell_spacing: f.write(f"cell_spacing {cell_spacing}\n") if row_spacing and row_spacing != cell_spacing: f.write(f"row_spacing {row_spacing}\n") f.write("\n# codepoint_decimal ancho_visual\n") for ch in chars: cp = ord(ch) w = char_widths[ch] name = ch if ch.isprintable() and not ch.isspace() else f"U+{cp:04X}" f.write(f"{cp} {w} # {name}\n") def build_font_bitmap( ttf_path: str, size: int, columns: int = 15, box_width_override: int | None = None, box_height_override: int | None = None, ) -> FontBitmapResult: """Rasteriza un TTF a un bitmap de fuente en memoria. No toca disco. Lanza FileNotFoundError si el TTF no existe; RuntimeError si Pillow no puede cargarlo. """ if not os.path.isfile(ttf_path): raise FileNotFoundError(f"No se encuentra el archivo TTF: {ttf_path}") try: font = ImageFont.truetype(ttf_path, size) except OSError as e: raise RuntimeError(f"No se pudo cargar la fuente: {e}") from e # Scratch tentativo para la detección de .notdef. Sus dimensiones solo # afectan a la comparación pixel-perfect entre el char candidato y el # glifo .notdef; lo único que importa es la consistencia interna entre # ambas renders. El scratch definitivo del bitmap se recalcula después. ascent, descent = font.getmetrics() notdef_scratch_h = ascent + abs(descent) cap_tops = [font.getbbox(ch)[1] for ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if font.getbbox(ch)] notdef_y_offset = -round(sum(cap_tops) / len(cap_tops)) if cap_tops else 0 # Detectar el glifo .notdef: algunos TTF devuelven un glifo sustituto (con # advance > 0 y bbox válido) para chars que no tienen en su cmap. Esto provoca # falsos positivos en el test getlength >= 1. Se renderiza U+FFFE (garantizado # ausente en cualquier fuente de uso normal) y se guardan sus píxeles como # referencia. Cualquier char con píxeles idénticos se considera no soportado. _tmp_w = box_width_override or 32 _nd_bbox = font.getbbox(chr(0xFFFE)) if _nd_bbox: _nd_img = Image.new("RGBA", (_tmp_w, notdef_scratch_h), (0, 0, 0, 0)) ImageDraw.Draw(_nd_img).text((-_nd_bbox[0], notdef_y_offset), chr(0xFFFE), font=font, fill=(255, 255, 255, 255)) _notdef_bytes = _nd_img.tobytes() else: _notdef_bytes = None def _is_notdef(ch: str) -> bool: if _notdef_bytes is None: return False bbox = font.getbbox(ch) if not bbox: return True img = Image.new("RGBA", (_tmp_w, notdef_scratch_h), (0, 0, 0, 0)) ImageDraw.Draw(img).text((-bbox[0], notdef_y_offset), ch, font=font, fill=(255, 255, 255, 255)) return img.tobytes() == _notdef_bytes # Clasificar chars: soporte nativo, fallback ASCII, o sin soporte. chars_to_render: list[str] = [] char_render_as: dict[str, str] = {} truly_skipped: list[str] = [] for ch in ALL_CHARS: if ch.isspace(): chars_to_render.append(ch) char_render_as[ch] = ch elif font.getlength(ch) >= 1.0 and not _is_notdef(ch): chars_to_render.append(ch) char_render_as[ch] = ch elif ch in CHAR_FALLBACKS and font.getlength(CHAR_FALLBACKS[ch]) >= 1.0: chars_to_render.append(ch) char_render_as[ch] = CHAR_FALLBACKS[ch] else: truly_skipped.append(ch) fallback_used = {ch: r for ch, r in char_render_as.items() if r != ch} # Anchos iniciales basados en advance — solo se usan para dimensionar el canvas; # el ancho real del .fnt se mide desde píxeles tras renderizar. char_widths: dict[str, int] = {ch: max(1, int(font.getlength(char_render_as[ch]))) for ch in chars_to_render} box_width = box_width_override if box_width_override is not None else max(char_widths.values()) # Scratch definitivo: lo dimensionamos al span vertical real (bbox) # de los caracteres que vamos a dibujar, INCLUIDOS los acentos. Si solo # nos basásemos en el top de las mayúsculas (cap_tops), À/É/Ç quedarían # con el acento por encima de y=0 y se recortarían. y_offset se ajusta # para que el píxel más alto de cualquier glifo caiga en y=0. draw_chars = {char_render_as[ch] for ch in chars_to_render if not ch.isspace()} glyph_bboxes = [b for b in (font.getbbox(c) for c in draw_chars) if b] if glyph_bboxes: top_y = min(b[1] for b in glyph_bboxes) bot_y = max(b[3] for b in glyph_bboxes) scratch_h = max(1, bot_y - top_y) y_offset = -top_y else: scratch_h = notdef_scratch_h y_offset = 0 # --- Pase 1: rasterizar cada glifo a un scratch RGBA --- # POSICIONAMIENTO VERTICAL: y_offset alinea el píxel más alto de # cualquier glifo (incluido el de un acento) con el borde superior del # scratch. El recorte pixel-tight posterior compensa que las mayúsculas # sin acento queden ligeramente más bajas en su celda. # POSICIONAMIENTO HORIZONTAL: -bbox[0] compensa el bearing izquierdo. glyph_bytes: dict[str, bytes] = {} for ch in chars_to_render: draw_ch = char_render_as[ch] bbox = font.getbbox(draw_ch) if not bbox: continue # sin glifo (ej. espacio): celda vacía, correcto. char_img = Image.new("RGBA", (box_width, scratch_h), (0, 0, 0, 0)) ImageDraw.Draw(char_img).text((-bbox[0], y_offset), draw_ch, font=font, fill=(255, 255, 255, 255)) glyph_bytes[ch] = char_img.tobytes() # --- Alto pixel-tight --- # Como con el ancho visual: tomamos solo las filas con algún píxel # opaco en cualquier caracter. Recorta leading sobrante por arriba y # descenders no usados por abajo. if box_height_override is not None: box_height = box_height_override y_crop = 0 elif not glyph_bytes: box_height = scratch_h y_crop = 0 else: min_y, max_y = scratch_h, -1 for b in glyph_bytes.values(): for py in range(scratch_h): row = py * box_width * 4 if any(b[row + px * 4 + 3] > 128 for px in range(box_width)): if py < min_y: min_y = py if py > max_y: max_y = py if max_y < 0: box_height = scratch_h y_crop = 0 else: box_height = max_y - min_y + 1 y_crop = min_y rows = ceil(len(chars_to_render) / columns) img_width = columns * box_width img_height = rows * box_height # Buffer de píxeles: 0 = fondo, 1 = glifo pixels = bytearray(img_width * img_height) # --- Pase 2: componer cada glifo en el bitmap final, recortado --- for i, ch in enumerate(chars_to_render): col = i % columns row = i // columns cell_x = col * box_width cell_y = row * box_height b = glyph_bytes.get(ch) if b is None: continue for py in range(box_height): src_y = py + y_crop if src_y >= scratch_h: break for px in range(box_width): src = (src_y * box_width + px) * 4 if b[src + 3] > 128: pixels[(cell_y + py) * img_width + (cell_x + px)] = 1 # Ancho visual real: última columna con algún píxel opaco en la # zona visible. Reemplaza el advance tipográfico de getlength(). for px in range(box_width - 1, -1, -1): if any(b[((y_crop + py) * box_width + px) * 4 + 3] > 128 for py in range(box_height) if y_crop + py < scratch_h): char_widths[ch] = px + 1 break img = Image.frombytes("P", (img_width, img_height), bytes(pixels)) palette = [0] * 768 palette[3] = palette[4] = palette[5] = 255 # índice 1 → blanco img.putpalette(palette) return FontBitmapResult( img=img, chars=chars_to_render, char_widths=char_widths, box_width=box_width, box_height=box_height, columns=columns, fallback_used=fallback_used, truly_skipped=truly_skipped, ) def save_font_files( result: FontBitmapResult, output_name: str, output_dir: str, source_desc: str, cell_spacing: int = 0, row_spacing: int = 0, ) -> tuple[str, str]: """Escribe el GIF + .fnt de un FontBitmapResult. Devuelve (gif_path, fnt_path). """ os.makedirs(output_dir, exist_ok=True) output_gif = os.path.join(output_dir, f"{output_name}.gif") output_fnt = os.path.join(output_dir, f"{output_name}.fnt") # Pillow sin transparency escribe GIF87a. Luego magick reduce la paleta a # 2 colores (lzw_min=2) para compatibilidad exacta con parsers GIF estrictos. result.img.save(output_gif, optimize=False) try: import subprocess subprocess.run( ["magick", output_gif, "-colors", "2", f"GIF87:{output_gif}"], check=True, capture_output=True, ) except (subprocess.CalledProcessError, FileNotFoundError): pass # si magick no está disponible se queda como GIF87a de 256 colores _write_fnt(output_fnt, output_name, source_desc, result.box_width, result.box_height, result.columns, cell_spacing, row_spacing, result.chars, result.char_widths) return output_gif, output_fnt def render_gif_font( gif_path: str, output_name: str, output_dir: str, columns: int, box_width: int, box_height: int, cell_spacing: int, row_spacing: int, ) -> None: """Genera el .fnt a partir de un GIF de fuente existente con cuadrícula. Fórmula de posición de cada celda (col, row): x0 = col * (box_width + cell_spacing) + cell_spacing y0 = row * (box_height + row_spacing) + cell_spacing """ if not os.path.isfile(gif_path): print(f"Error: No se encuentra el archivo GIF: {gif_path}", file=sys.stderr) sys.exit(1) os.makedirs(output_dir, exist_ok=True) output_gif = os.path.join(output_dir, f"{output_name}.gif") output_fnt = os.path.join(output_dir, f"{output_name}.fnt") img = Image.open(gif_path) if img.mode != "P": img = img.convert("P") img_w, img_h = img.size pixels = img.load() num_rows = ceil(len(ALL_CHARS) / columns) stride_x = box_width + cell_spacing stride_y = box_height + row_spacing print(f"GIF fuente: {os.path.basename(gif_path)} ({img_w}×{img_h} px)") print(f"Caja : {box_width}×{box_height} px | {columns} cols, {num_rows} filas | spacing x={cell_spacing} y={row_spacing}") char_widths: dict[str, int] = {} supported_chars: list[str] = [] skipped: list[str] = [] for i, ch in enumerate(ALL_CHARS): col = i % columns row = i // columns x0 = col * stride_x + cell_spacing y0 = row * stride_y + cell_spacing if y0 + box_height > img_h or x0 + box_width > img_w: skipped.append(ch) continue if all(pixels[x0 + px, y0 + py] == 0 for py in range(box_height) for px in range(box_width)): if ch.isspace(): char_widths[ch] = max(1, box_width // 2) supported_chars.append(ch) else: skipped.append(ch) continue pixel_width = 0 for px in range(box_width - 1, -1, -1): if any(pixels[x0 + px, y0 + py] != 0 for py in range(box_height)): pixel_width = px + 1 break char_widths[ch] = max(1, pixel_width) supported_chars.append(ch) if skipped: names = "".join(c if c.isprintable() and not c.isspace() else f"[U+{ord(c):04X}]" for c in skipped) print(f"Omitidos : {len(skipped)} chars vacíos/fuera de rango: {names}") print(f"Soportados: {len(supported_chars)} caracteres") shutil.copy2(gif_path, output_gif) print(f"GIF : {output_gif}") _write_fnt(output_fnt, output_name, os.path.basename(gif_path), box_width, box_height, columns, cell_spacing, row_spacing, supported_chars, char_widths) print(f"FNT : {output_fnt}") def render_font( ttf_path: str, size: int, output_name: str, output_dir: str, columns: int, box_width_override: int | None, box_height_override: int | None, ) -> None: """Wrapper CLI: rasteriza el TTF y escribe los ficheros, con logging.""" try: result = build_font_bitmap(ttf_path, size, columns, box_width_override, box_height_override) except (FileNotFoundError, RuntimeError) as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) if result.truly_skipped: print(f"Omitidos : {len(result.truly_skipped)} chars sin soporte ni fallback: " f"{''.join(result.truly_skipped)}") if result.fallback_used: print(f"Fallback : {len(result.fallback_used)} chars con fallback: " + " ".join(f"{ch}→{r}" for ch, r in result.fallback_used.items())) rows = ceil(len(result.chars) / result.columns) print(f"Fuente : {os.path.basename(ttf_path)}, size={size}") print(f"Caja : {result.box_width}×{result.box_height} px | {result.columns} columnas, {rows} filas") print(f"Bitmap : {result.img.width}×{result.img.height} px | {len(result.chars)} caracteres") gif_path, fnt_path = save_font_files( result, output_name, output_dir, f"{os.path.basename(ttf_path)} size {size}", ) print(f"GIF : {gif_path}") print(f"FNT : {fnt_path}") def _launch_gui() -> None: """Importa y arranca la GUI Tk. Importación perezosa para no exigir tkinter en uso CLI puro.""" try: from font_gen_gui import run_gui except ImportError as e: print(f"Error: no se pudo cargar la GUI ({e}).\n" f"Asegúrate de que font_gen_gui.py está junto a font_gen.py " f"y que tkinter está disponible.", file=sys.stderr) sys.exit(1) run_gui() def main() -> None: script_dir = os.path.dirname(os.path.abspath(__file__)) parser = argparse.ArgumentParser( description="Genera fuentes bitmap (.gif + .fnt) desde un .ttf o un GIF existente.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Ejemplos (desde TTF): python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont --box-width 8 python3 font_gen.py --ttf myfont.ttf --size 16 --output myfont --dir ./out Ejemplos (desde GIF con cuadrícula): python3 font_gen.py --gif myfont.gif --output myfont --columns 16 --box-width 10 --box-height 7 --cell-spacing 1 GUI: python3 font_gen.py (sin argumentos abre la interfaz Tk) python3 font_gen.py --gui Notas: - Para pixel fonts (.ttf bitmap), usa el tamaño exacto del bitmap strike. - El GIF resultante usa índice 1 = glifo, índice 0 = transparente. - Se procesan 124 caracteres: ASCII 32-126 + extensiones ES/CA/VA. - En modo --gif, --box-width y --box-height son obligatorios. - cell_spacing = spacing horizontal entre columnas (y borde izquierdo/superior). - row_spacing = spacing vertical entre filas (si difiere de cell_spacing). """, ) parser.add_argument("--gui", action="store_true", help="Abre la interfaz gráfica Tk") source_group = parser.add_mutually_exclusive_group() source_group.add_argument("--ttf", help="Ruta al archivo .ttf") source_group.add_argument("--gif", help="Ruta a un GIF de fuente existente con cuadrícula") parser.add_argument("--size", type=int, help="Tamaño en píxeles (solo con --ttf)") parser.add_argument("--output", help="Nombre base de salida (sin extensión)") parser.add_argument("--dir", default=script_dir, help="Directorio de salida (default: junto al script)") parser.add_argument("--columns", default=15, type=int, help="Columnas en el bitmap (default: 15)") parser.add_argument("--box-width", default=None, type=int, help="Anchura fija de celda en px (default: auto)") parser.add_argument("--box-height", default=None, type=int, help="Altura fija de celda en px (default: auto)") parser.add_argument("--cell-spacing", default=0, type=int, help="Píxeles de separación entre columnas, y borde (default: 0)") parser.add_argument("--row-spacing", default=None, type=int, help="Píxeles de separación entre filas (default: igual a --cell-spacing)") args = parser.parse_args() if args.gui: _launch_gui() return if not args.ttf and not args.gif: parser.error("se requiere --ttf, --gif, o --gui") if not args.output: parser.error("--output es obligatorio en modo CLI") if args.gif: if args.box_width is None or args.box_height is None: parser.error("--box-width y --box-height son obligatorios con --gif") row_sp = args.row_spacing if args.row_spacing is not None else args.cell_spacing render_gif_font( gif_path = args.gif, output_name = args.output, output_dir = args.dir, columns = args.columns, box_width = args.box_width, box_height = args.box_height, cell_spacing = args.cell_spacing, row_spacing = row_sp, ) else: if args.size is None: parser.error("--size es obligatorio cuando se usa --ttf") render_font( ttf_path = args.ttf, size = args.size, output_name = args.output, output_dir = args.dir, columns = args.columns, box_width_override = args.box_width, box_height_override= args.box_height, ) if __name__ == "__main__": if len(sys.argv) == 1: _launch_gui() else: main()