Files
font-gen/font_gen.py
T

487 lines
19 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 (.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
# --- 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))
# y_offset: media de los tops de las mayúsculas, para alinear la cima del glifo
# con el borde superior de la celda y dejar puntuación/descenders dentro.
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:
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: 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())
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)
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]
bbox = font.getbbox(draw_ch)
if not bbox:
continue # sin glifo (ej. espacio): celda vacía, correcto.
# POSICIONAMIENTO VERTICAL: y_offset alinea la cima de las mayúsculas
# con el borde superior de la celda; descenders y puntuación quedan
# dentro de la caja sin recortes.
# POSICIONAMIENTO HORIZONTAL: -bbox[0] compensa 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))
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
# 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
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()