Files
jdd_opendingux/tools/font_gen/font_gen.py
Sergio Valor 94684e8758 ferramenta de text pot importar gifs
ferramenta de text accepta separació entre quadricules de lletres
2026-03-22 19:06:01 +01:00

384 lines
16 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
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))
# 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}")
_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()