treballant en el generador de .fnt

This commit is contained in:
2026-03-22 18:40:51 +01:00
parent d0ed49d192
commit 0c116665bc
15 changed files with 767 additions and 1550 deletions

242
tools/font_gen/font_gen.py Normal file
View File

@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Generador de fuentes bitmap para JailDoctor's Dilemma.
Convierte un archivo .ttf en un GIF indexado + fichero .fnt compatibles
con el sistema de texto del juego.
Dependencias: pip install Pillow
Uso:
python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont
python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont --dir data/font --box-width 8
Notas:
- Para fuentes bitmap (pixel fonts) en TTF, usa el tamaño exacto del bitmap strike.
- Los glifos se almacenan como índice de paleta 1 (blanco) sobre fondo transparente (índice 0).
- Esto es compatible con SurfaceSprite::render(1, color) del motor del juego.
- Los caracteres no incluidos en la fuente aparecerán como celdas vacías en el GIF.
"""
import argparse
import os
import sys
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
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:
"""Genera el GIF indexado y el .fnt a partir de un archivo .ttf."""
if not os.path.isfile(ttf_path):
print(f"Error: No se encuentra el archivo TTF: {ttf_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")
# --- Cargar fuente ---
try:
font = ImageFont.truetype(ttf_path, size)
except OSError as e:
print(f"Error al cargar la fuente: {e}", file=sys.stderr)
sys.exit(1)
# --- Calcular dimensiones de la caja ---
# box_height = línea completa: ascent (sobre la línea base) + descent (bajo ella)
ascent, descent = font.getmetrics()
box_height = box_height_override if box_height_override is not None else (ascent + abs(descent))
# Filtrar chars: solo incluir los que el TTF soporta realmente.
# Un char se descarta si su advance es 0 (el TTF no lo tiene) y no es un
# espacio. Evita que chars sin glifo aparezcan con width=1 en el .fnt,
# lo que causaría solapamiento masivo al renderizar texto localizado.
chars_to_render = [
ch for ch in ALL_CHARS
if ch.isspace() or font.getlength(ch) >= 1.0
]
skipped = [ch for ch in ALL_CHARS if ch not in chars_to_render]
if skipped:
print(f"Omitidos : {len(skipped)} chars sin soporte en este TTF: {''.join(skipped)}")
# Medir advance width tipográfico: solo se usa para calcular box_width del canvas
# cuando el usuario no lo especifica. El ancho real del .fnt se mide desde píxeles.
char_widths: dict[str, int] = {}
for ch in chars_to_render:
char_widths[ch] = max(1, int(font.getlength(ch)))
# Calcular el offset vertical para eliminar el espacio en blanco en la parte
# superior de la celda. Muchas fuentes bitmap tienen un em-box más grande que
# los píxeles visibles (ascent incluye espacio interno). Usamos las letras
# mayúsculas como referencia de "cap height": su bbox[1] indica cuántos
# píxeles en blanco hay sobre los caracteres más altos, y restamos ese valor
# para que las mayúsculas queden alineadas al borde superior de la celda.
cap_tops = [font.getbbox(ch)[1] for ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if font.getbbox(ch)]
y_offset = -round(sum(cap_tops) / len(cap_tops)) if cap_tops else 0
# box_width: anchura de cada celda en el bitmap.
# Si el usuario la especifica, se usa tal cual. Si no, se calcula como el
# advance máximo (sin padding extra, ya que getlength incluye el espaciado).
if box_width_override is not None:
box_width = box_width_override
else:
box_width = max(char_widths.values())
# --- Calcular dimensiones del bitmap completo ---
rows = ceil(len(chars_to_render) / columns)
img_width = columns * box_width
img_height = rows * box_height
print(f"Fuente : {os.path.basename(ttf_path)}, size={size}")
print(f"Caja : {box_width}×{box_height} px | {columns} columnas, {rows} filas")
print(f"Bitmap : {img_width}×{img_height} px | {len(chars_to_render)} caracteres")
# --- Buffer de píxeles: 0 = fondo transparente, 1 = glifo ---
pixels = bytearray(img_width * img_height)
# --- Renderizar cada carácter ---
for i, ch in enumerate(chars_to_render):
col = i % columns
row = i // columns
cell_x = col * box_width
cell_y = row * box_height
bbox = font.getbbox(ch)
if not bbox:
# Sin glifos visibles (ej. espacio): celda vacía, correcto.
continue
# Renderizar a imagen RGBA con fondo transparente, texto blanco.
#
# POSICIONAMIENTO VERTICAL: se usa y=0 para respetar la posición del
# carácter relativa a la línea base del em-box. draw.text((x, 0), ch)
# coloca la parte superior del em-box en y=0, de modo que la línea base
# queda en y=ascent y los signos de puntuación (que están cerca de la
# línea base) aparecen en la parte inferior de la celda, como es correcto.
#
# POSICIONAMIENTO HORIZONTAL: -bbox[0] alinea el borde izquierdo del
# glifo al inicio de la celda, compensando el bearing izquierdo.
char_img = Image.new("RGBA", (box_width, box_height), (0, 0, 0, 0))
draw = ImageDraw.Draw(char_img)
draw.text((-bbox[0], y_offset), ch, font=font, fill=(255, 255, 255, 255))
# Umbralizar alpha y volcar al buffer de índices
char_bytes = char_img.tobytes()
for py in range(box_height):
for px in range(box_width):
src = (py * box_width + px) * 4
if char_bytes[src + 3] > 128:
pixels[(cell_y + py) * img_width + (cell_x + px)] = 1
# Medir ancho visual real: última columna con algún píxel opaco.
# Reemplaza el advance tipográfico de getlength() que incluye side-bearings.
for px in range(box_width - 1, -1, -1):
if any(char_bytes[(py * box_width + px) * 4 + 3] > 128 for py in range(box_height)):
char_widths[ch] = px + 1
break
# --- Crear imagen P (paleta indexada 8 bits) ---
img = Image.frombytes("P", (img_width, img_height), bytes(pixels))
# Paleta mínima: índice 0 = negro (transparente), índice 1 = blanco (glifo)
palette = [0] * 768
palette[3] = palette[4] = palette[5] = 255 # índice 1 → blanco
img.putpalette(palette)
# Guardar GIF: índice 0 = fondo, índice 1 = glifo.
# Pillow sin transparency escribe GIF87a. Luego magick reduce la paleta a
# 2 colores (lzw_min=2) para compatibilidad exacta con el parser gif.cpp.
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
print(f"GIF : {output_gif}")
# --- Generar fichero .fnt ---
ttf_name = os.path.basename(ttf_path)
with open(output_fnt, "w", encoding="utf-8") as f:
f.write(f"# Font: {output_name} — generado desde {ttf_name} size {size}\n")
f.write(f"# Generado con tools/font_gen/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\n")
f.write("# codepoint_decimal ancho_visual\n")
for ch in chars_to_render:
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")
print(f"FNT : {output_fnt}")
def main() -> None:
# Directorio del proyecto: dos niveles arriba de este script (tools/font_gen/)
script_dir = os.path.dirname(os.path.abspath(__file__))
project_dir = os.path.dirname(os.path.dirname(script_dir))
default_dir = os.path.join(project_dir, "data", "font")
parser = argparse.ArgumentParser(
description="Genera fuentes bitmap (.gif + .fnt) desde un archivo .ttf.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Ejemplos:
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 data/font
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 generan 124 caracteres: ASCII 32-126 + extensiones ES/CA/VA.
- Usa --box-width para forzar una anchura de celda fija (útil para fuentes
cuadradas donde la celda coincide con box_height).
""",
)
parser.add_argument("--ttf", required=True, help="Ruta al archivo .ttf")
parser.add_argument("--size", required=True, type=int, help="Tamaño en píxeles")
parser.add_argument("--output", required=True, help="Nombre base de salida (sin extensión)")
parser.add_argument("--dir", default=default_dir, help="Directorio de salida (default: data/font/)")
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: ascent + abs(descent))")
args = parser.parse_args()
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__":
main()