Files

550 lines
22 KiB
Python
Raw Permalink 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("ÀÁÈÉÌÍÒÓÙÚÑÇàáèéìíòóùúñç¡¿«»·©") # 30 chars ES/CA/VA + ©
ALL_CHARS = _ASCII_CHARS + _EXTENDED_CHARS # 125 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,
strip_accents: bool = False,
) -> 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 strip_accents and ch in CHAR_FALLBACKS and font.getlength(CHAR_FALLBACKS[ch]) >= 1.0:
chars_to_render.append(ch)
char_render_as[ch] = CHAR_FALLBACKS[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,
strip_accents: bool = False,
) -> 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,
strip_accents)
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)")
parser.add_argument("--no-accents", action="store_true",
help="Renderiza À/É/ñ/Ç… como A/E/n/C (sin acentos). El codepoint original se conserva en el .fnt.")
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,
strip_accents = args.no_accents,
)
if __name__ == "__main__":
if len(sys.argv) == 1:
_launch_gui()
else:
main()