Files
jaildoctors_dilemma/tools/font_gen/font_gen.py
Sergio Valor b876ccbb09 afegit fallback a la font_gen
afegida deteccio de caracters no definits a font_gen
2026-03-22 19:19:00 +01:00

435 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Generador de fuentes bitmap para JailDoctor's Dilemma.
Convierte un archivo .ttf (o un GIF existente) en un GIF indexado + fichero .fnt
compatibles con el sistema de texto del juego.
Dependencias: pip install Pillow
Uso (desde TTF):
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
Uso (desde GIF existente con cuadrícula):
python3 font_gen.py --gif myfont.gif --output myfont --columns 16 --cell-spacing 1
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.
- El modo --gif acepta un GIF cuya cuadrícula siga el orden de ALL_CHARS.
Celdas vacías (todos los píxeles = índice 0) se marcan como no soportadas.
"""
import argparse
import os
import shutil
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
# 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",
"¡": "!", "¿": "?", "«": "<", "»": ">", "·": ".",
}
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 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")
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 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
# Comprobar límites (el GIF puede tener más o menos filas que ALL_CHARS)
if y0 + box_height > img_h or x0 + box_width > img_w:
skipped.append(ch)
continue
# Celda vacía (todos los píxeles = índice 0)
if all(pixels[x0 + px, y0 + py] == 0 for py in range(box_height) for px in range(box_width)):
if ch.isspace():
# El espacio no tiene píxeles visibles: asignar ancho por defecto
char_widths[ch] = max(1, box_width // 2)
supported_chars.append(ch)
else:
skipped.append(ch)
continue
# Medir ancho visual: última columna con algún píxel no-fondo
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")
# Copiar el GIF al directorio de salida
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:
"""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))
# Calcular y_offset antes de la clasificación (necesario para detectar .notdef)
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
# 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, box_height), (0, 0, 0, 0))
ImageDraw.Draw(_nd_img).text((-_nd_bbox[0], 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:
"""True si el char renderiza el glifo .notdef en lugar de un glifo real."""
if _notdef_bytes is None:
return False
bbox = font.getbbox(ch)
if not bbox:
return True
img = Image.new("RGBA", (_tmp_w, box_height), (0, 0, 0, 0))
ImageDraw.Draw(img).text((-bbox[0], y_offset), ch, font=font, fill=(255, 255, 255, 255))
return img.tobytes() == _notdef_bytes
# Clasificar chars: soportados nativamente, con fallback, o sin soporte.
# Un char tiene soporte nativo si su advance >= 1 Y no renderiza .notdef.
# Si no, se busca en CHAR_FALLBACKS. Sin soporte y sin fallback, se omite.
chars_to_render: list[str] = []
char_render_as: dict[str, str] = {} # char → qué char dibujar realmente
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)
if truly_skipped:
print(f"Omitidos : {len(truly_skipped)} chars sin soporte ni fallback: {''.join(truly_skipped)}")
fallback_used = [ch for ch, r in char_render_as.items() if r != ch]
if fallback_used:
print(f"Fallback : {len(fallback_used)} chars con fallback: " +
" ".join(f"{ch}{char_render_as[ch]}" for ch in fallback_used))
# 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:
render_ch = char_render_as[ch]
char_widths[ch] = max(1, int(font.getlength(render_ch)))
# 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
draw_ch = char_render_as[ch] # char que realmente se dibuja (puede ser fallback)
bbox = font.getbbox(draw_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), draw_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}")
_write_fnt(output_fnt, output_name, f"{os.path.basename(ttf_path)} size {size}",
box_width, box_height, columns, 0, 0, chars_to_render, char_widths)
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 .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 data/font
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
python3 font_gen.py --gif myfont.gif --output myfont --columns 16 --box-width 10 --box-height 7 --cell-spacing 1 --row-spacing 4
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.
- Las celdas completamente vacías (índice 0) se marcan como no soportadas.
- cell_spacing = spacing horizontal entre columnas (y borde izquierdo/superior).
- row_spacing = spacing vertical entre filas (si difiere de cell_spacing).
""",
)
# Fuente: TTF o GIF (mutuamente excluyentes)
source_group = parser.add_mutually_exclusive_group(required=True)
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", 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: 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.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__":
main()