Versió inicial: CLI i GUI Tk per a fonts bitmap

This commit is contained in:
2026-04-26 15:22:07 +02:00
parent 49b8917a46
commit c862f5aab8
6 changed files with 959 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Python
__pycache__/
*.py[cod]
# venv
.venv/
venv/
# Salidas generadas en la raíz (junto al script).
# Anclado con / para no afectar a .gif/.fnt que viva en subcarpetas
# (p. ej. ejemplos o tests).
/*.gif
/*.fnt
+60
View File
@@ -0,0 +1,60 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Standalone tool that produces bitmap fonts (`.gif` + `.fnt`) for the "Projecte 2026" game engine.
- [`font_gen.py`](font_gen.py) — pure rasterising core + CLI. The two public functions are [`build_font_bitmap`](font_gen.py) (TTF → in-memory `FontBitmapResult`, no disk I/O) and [`save_font_files`](font_gen.py) (writes `.gif` + `.fnt`). The CLI wrappers `render_font` (TTF) and `render_gif_font` (GIF) layer logging on top.
- [`font_gen_gui.py`](font_gen_gui.py) — Tkinter GUI that imports the core, runs `build_font_bitmap` on every input change (debounced 200 ms) for live preview, and calls `save_font_files` on GENERAR.
The two files are independent: copy them anywhere and run. There is no install step, no test suite, no linter config.
## Running
```sh
pip install Pillow # only mandatory dependency
python3 font_gen.py # GUI (no args)
python3 font_gen.py --ttf foo.ttf --size 8 --output foo # CLI from TTF
python3 font_gen.py --gif foo.gif --output foo --columns 16 \
--box-width 10 --box-height 7 --cell-spacing 1 # CLI from existing GIF
```
`magick` (ImageMagick) is invoked optionally inside `save_font_files` to clamp the GIF palette to 2 colours; if missing it is silently skipped.
Default output directory is **next to the script** (`os.path.dirname(__file__)`). The CLI accepts `--dir` to override; the GUI always writes alongside the script.
## Output contract (don't break these)
These properties are consumed by the engine and must hold for any change to the rasteriser:
- **Indexed GIF, palette index 0 = background, index 1 = glyph.** Compatible with `SurfaceSprite::render(1, color)` in the engine. The post-process `magick … -colors 2 GIF87:` enforces a 2-colour palette when available.
- **Character set order is fixed.** [`ALL_CHARS`](font_gen.py) = ASCII 32126 followed by the ES/CA/VA extension list, in that exact sequence. `.fnt` rows and GIF cells are addressed by this order; reordering breaks every existing `.fnt` consumer.
- **`.fnt` format**: header keys (`box_width`, `box_height`, `columns`, optional `cell_spacing`, optional `row_spacing` only when it differs from `cell_spacing`), blank line, then `<codepoint_decimal> <visual_width> # <name>` per supported char. See [`_write_fnt`](font_gen.py).
- **Visual width is measured from pixels**, not from the font's typographic advance — last column containing an opaque pixel + 1. `getlength()` is only used to size the canvas.
## TTF rendering: the non-obvious bits
- **`.notdef` detection**: some TTFs return a substitute glyph (with positive advance and a valid bbox) for missing codepoints, which would falsely pass the `getlength(ch) >= 1` check. The tool renders `U+FFFE` as a reference and rejects any char whose pixels match it. See `_is_notdef` inside `build_font_bitmap`.
- **Fallback table** (`CHAR_FALLBACKS`) maps accented ES/CA/VA chars to ASCII equivalents (`À→A`, `ñ→n`, …). When a TTF lacks the accented glyph, the fallback is drawn into the cell **but the original codepoint is recorded in the `.fnt`**, so localised text still resolves at runtime.
- **Vertical placement**: `draw.text((-bbox[0], y_offset), …)` where `y_offset` is computed from the average top of capital letters. Don't "fix" this by removing the offset — it keeps descenders inside the cell and aligns punctuation to the baseline.
- **`box_width` auto-mode** uses `max(getlength)` across rendered chars; override with `--box-width` for pixel-exact bitmap strikes.
## GIF mode quirks (CLI-only)
- `cell_spacing` is both the inter-column gap **and** the top/left border. Origin of cell `(col, row)` is `(col*(bw+cs)+cs, row*(bh+rs)+cs)` — see `render_gif_font`.
- Empty cells (all index 0) are marked unsupported and dropped from the `.fnt`, except whitespace chars which get a default width of `box_width // 2`.
- The source GIF is `shutil.copy2`'d unchanged to the output dir; only the `.fnt` is regenerated.
- This mode is intentionally **not exposed in the GUI** — it's a maintenance path for re-measuring an existing bitmap font, not a primary workflow.
## GUI specifics
- **Live preview**: every change to TTF / size / advanced options schedules `_update_preview` via `self.after(200, …)` (debounced). Render runs on the main thread — for typical pixel-font sizes it is sub-second.
- **`ImageTk.PhotoImage` lifetime**: stored on `self._preview_img_tk` to prevent Tk from garbage-collecting the image and showing a blank label. If you refactor preview rendering, keep this reference alive.
- **Preview palette is for display only**: index 0 is recoloured white and index 1 black so the user can read the glyphs on screen. The image saved to disk uses the original palette (index 1 = white) — `_render_preview` operates on a copy.
- **Preview scale**: nearest-neighbor upscale by `min(8, PREVIEW_MAX_W // w, PREVIEW_MAX_H // h)`. Pixel-perfect, never blurred.
## Entry point dispatch
In `font_gen.py` `__main__`: `len(sys.argv) == 1` launches the GUI directly (no argparse). Anything else goes through `main()`, which honours `--gui` to launch the GUI explicitly. The GUI is imported lazily inside `_launch_gui()` so CLI-only invocations don't require tkinter.
+93
View File
@@ -0,0 +1,93 @@
# font-gen
Generador de fuentes bitmap (`.gif` + `.fnt`) desde TrueType, pensado para
motores de juego con sistema de texto indexado por paleta.
El GIF de salida usa una paleta de 2 colores: índice 0 = fondo, índice 1
= glifo. El `.fnt` acompañante registra el ancho visual real (medido en
píxeles) de cada caracter.
Procesa 124 caracteres: ASCII 32126 más extensiones para castellano,
catalán y valenciano (`ÀÁÈÉÌÍÒÓÙÚÑÇ` y minúsculas, `¡¿«»·`).
## Instalación
En Debian/Ubuntu, todo via apt en una sola línea (recomendado):
```sh
sudo apt install python3-tk python3-pil python3-pil.imagetk
```
- `python3-pil` es Pillow (no en PyPI con ese nombre).
- `python3-pil.imagetk` es el bridge PIL ↔ Tk que la GUI usa para el preview.
- `python3-tk` es tkinter; **no se puede instalar con pip ni un venv lo soluciona**, porque es un binding C que vive en la stdlib del sistema.
Alternativa con pip (Pillow en venv, tkinter sigue siendo apt):
```sh
sudo apt install python3-tk
pip install -r requirements.txt # o: pip install Pillow
```
Opcional: tener `imagemagick` (binario `magick`) en el `PATH` para que el
GIF se reduzca a una paleta estricta de 2 colores tras guardarlo. Si no
está, el script funciona igualmente.
## Uso
### GUI
```sh
python3 font_gen.py # sin argumentos abre la interfaz Tk
python3 font_gen.py --gui # explícito
```
Selecciona un TTF, ajusta el tamaño y pulsa **GENERAR**. Los ficheros
`.gif` y `.fnt` se escriben junto a `font_gen.py`. El preview se
actualiza en vivo mostrando la cuadrícula completa de caracteres.
Las opciones avanzadas (`columns`, `box_width`, `box_height`) están
ocultas por defecto; ábrelas si necesitas tamaños de celda fijos —
útil para fuentes de pixel-art donde el TTF tiene un bitmap strike
exacto.
### CLI
Desde TTF:
```sh
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
```
Desde un GIF de fuente existente con cuadrícula (regenera el `.fnt`
midiendo anchos desde los píxeles, sin re-rasterizar):
```sh
python3 font_gen.py --gif myfont.gif --output myfont \
--columns 16 --box-width 10 --box-height 7 --cell-spacing 1
```
Lista completa de opciones: `python3 font_gen.py --help`.
## Cómo se eligen los caracteres
1. **Glifo nativo**: si el TTF tiene el caracter en su `cmap`, se dibuja tal cual.
2. **Fallback ASCII**: si no, y el caracter está en la tabla de fallbacks
(`Á → A`, `ñ → n`, …), se dibuja la versión ASCII en la celda **pero
el `.fnt` registra el codepoint original**, así el texto localizado
sigue resolviendo aunque visualmente se vea sin acento.
3. **Omitido**: si no hay glifo ni fallback, el caracter se descarta y
no aparece en el `.fnt`.
Esto permite usar fuentes pixel-art genéricas con texto en
castellano/catalán/valenciano sin que el TTF tenga explícitamente cada
acento.
## Estructura
- `font_gen.py` — núcleo de rasterizado + CLI.
- `font_gen_gui.py` — interfaz Tk; importa el núcleo.
Ambos ficheros standalone: cópialos a cualquier directorio y ejecútalos.
+486
View File
@@ -0,0 +1,486 @@
#!/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()
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""Interfaz gráfica Tk para font_gen.
Selecciona un TTF, ajusta el tamaño y propiedades de la cuadrícula, ve un
preview en vivo del GIF resultante, y genera los ficheros .gif/.fnt junto
a este script al pulsar GENERAR.
"""
from __future__ import annotations
import os
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from PIL import Image, ImageTk
import font_gen
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PREVIEW_MAX_W = 720
PREVIEW_MAX_H = 320
PREVIEW_DEBOUNCE_MS = 200
AUTO_MAX_SCALE = 8 # techo del fit automático
MANUAL_MAX_SCALE = 16 # techo cuando el usuario pulsa +
class FontGenApp(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("font-gen")
self.minsize(640, 480)
self._preview_job: str | None = None
# Mantener una referencia viva a la PhotoImage; si no, Tk la libera
# en cuanto sale del scope local y el preview aparece en blanco.
self._preview_img_tk: ImageTk.PhotoImage | None = None
self._last_result: font_gen.FontBitmapResult | None = None
self._zoom: int | None = None # None = auto-fit; int = escala explícita
self.ttf_var = tk.StringVar()
self.size_var = tk.IntVar(value=8)
self.output_var = tk.StringVar()
self.columns_var = tk.IntVar(value=15)
self.bw_var = tk.StringVar() # "" = auto
self.bh_var = tk.StringVar() # "" = auto
self._adv_visible = False
self._build_ui()
for v in (self.ttf_var, self.size_var, self.columns_var, self.bw_var, self.bh_var):
v.trace_add("write", lambda *_: self._schedule_preview())
# ------------------------------------------------------------------ UI
def _build_ui(self) -> None:
root = ttk.Frame(self, padding=8)
root.pack(fill="both", expand=True)
row = ttk.Frame(root)
row.pack(fill="x", pady=(0, 4))
ttk.Label(row, text="TTF:", width=8).pack(side="left")
ttk.Entry(row, textvariable=self.ttf_var).pack(side="left", fill="x", expand=True, padx=(0, 4))
ttk.Button(row, text="Examinar…", command=self._browse_ttf).pack(side="left")
row = ttk.Frame(root)
row.pack(fill="x", pady=4)
ttk.Label(row, text="Tamaño:", width=8).pack(side="left")
ttk.Spinbox(row, from_=4, to=128, width=5, textvariable=self.size_var).pack(side="left")
ttk.Label(row, text="Salida:").pack(side="left", padx=(16, 4))
ttk.Entry(row, textvariable=self.output_var, width=24).pack(side="left")
self.adv_btn = ttk.Button(root, text="▸ Opciones avanzadas", command=self._toggle_advanced)
self.adv_btn.pack(anchor="w", pady=(8, 0))
self.adv_frame = ttk.Frame(root)
for label, var, w in [
("Columnas:", self.columns_var, 5),
("box_width (auto):", self.bw_var, 6),
("box_height (auto):", self.bh_var, 6),
]:
ttk.Label(self.adv_frame, text=label).pack(side="left", padx=(8, 2))
ttk.Entry(self.adv_frame, textvariable=var, width=w).pack(side="left")
prev = ttk.LabelFrame(root, text="Preview", padding=8)
prev.pack(fill="both", expand=True, pady=8)
# Controles de zoom
zoom_row = ttk.Frame(prev)
zoom_row.pack(fill="x", pady=(0, 6))
ttk.Button(zoom_row, text="", width=3, command=self._zoom_out).pack(side="left")
self.zoom_label = ttk.Label(zoom_row, text="Auto", width=6, anchor="center")
self.zoom_label.pack(side="left", padx=4)
ttk.Button(zoom_row, text="+", width=3, command=self._zoom_in).pack(side="left")
ttk.Button(zoom_row, text="Auto", command=self._zoom_auto).pack(side="left", padx=(8, 0))
# Canvas con scrollbars: deja ver previews mayores que el área visible.
canvas_frame = ttk.Frame(prev)
canvas_frame.pack(fill="both", expand=True)
self.preview_canvas = tk.Canvas(
canvas_frame, background="#dddddd", highlightthickness=0,
width=PREVIEW_MAX_W, height=PREVIEW_MAX_H,
)
hbar = ttk.Scrollbar(canvas_frame, orient="horizontal", command=self.preview_canvas.xview)
vbar = ttk.Scrollbar(canvas_frame, orient="vertical", command=self.preview_canvas.yview)
self.preview_canvas.configure(xscrollcommand=hbar.set, yscrollcommand=vbar.set)
self.preview_canvas.grid(row=0, column=0, sticky="nsew")
vbar.grid(row=0, column=1, sticky="ns")
hbar.grid(row=1, column=0, sticky="ew")
canvas_frame.rowconfigure(0, weight=1)
canvas_frame.columnconfigure(0, weight=1)
self.preview_canvas.create_text(
10, 10, anchor="nw", fill="#666666",
text="Selecciona un TTF para ver el preview.",
tags=("placeholder",),
)
self.info_label = ttk.Label(prev, text="", foreground="#555555")
self.info_label.pack(anchor="w", pady=(4, 0))
row = ttk.Frame(root)
row.pack(fill="x")
self.status_var = tk.StringVar()
ttk.Label(row, textvariable=self.status_var, foreground="#006600").pack(side="left")
ttk.Button(row, text="GENERAR", command=self._generate).pack(side="right")
def _toggle_advanced(self) -> None:
if self._adv_visible:
self.adv_frame.pack_forget()
self.adv_btn.configure(text="▸ Opciones avanzadas")
else:
self.adv_frame.pack(fill="x", pady=(4, 0), after=self.adv_btn)
self.adv_btn.configure(text="▾ Opciones avanzadas")
self._adv_visible = not self._adv_visible
def _browse_ttf(self) -> None:
path = filedialog.askopenfilename(
title="Selecciona TTF",
filetypes=[("TrueType / OpenType", "*.ttf *.otf"), ("Todos los archivos", "*.*")],
)
if not path:
return
self.ttf_var.set(path)
if not self.output_var.get().strip():
self.output_var.set(os.path.splitext(os.path.basename(path))[0])
# ---------------------------------------------------------- Preview flow
def _schedule_preview(self) -> None:
# Cualquier cambio en los inputs invalida el último "guardado correcto".
self.status_var.set("")
if self._preview_job is not None:
self.after_cancel(self._preview_job)
self._preview_job = self.after(PREVIEW_DEBOUNCE_MS, self._update_preview)
def _update_preview(self) -> None:
self._preview_job = None
ttf = self.ttf_var.get().strip()
if not ttf:
self._show_preview_error("Selecciona un TTF.")
return
if not os.path.isfile(ttf):
self._show_preview_error(f"No se encuentra el archivo:\n{ttf}")
return
# IntVar.get() lanza TclError si el campo está vacío durante la edición.
try:
size = self.size_var.get()
except tk.TclError:
self._show_preview_error("Tamaño no válido.")
return
if not (4 <= size <= 256):
self._show_preview_error("Tamaño fuera de rango (4256).")
return
try:
columns = self.columns_var.get()
except tk.TclError:
columns = 15
if columns <= 0:
columns = 15
bw = _parse_optional_positive_int(self.bw_var.get())
bh = _parse_optional_positive_int(self.bh_var.get())
try:
result = font_gen.build_font_bitmap(ttf, size, columns, bw, bh)
except Exception as e:
self._show_preview_error(f"Error: {e}")
return
self._last_result = result
self._render_preview(result)
def _render_preview(self, result: font_gen.FontBitmapResult) -> None:
# Paleta legible para humanos: 0 = blanco (fondo), 1 = negro (glifo).
# No afecta al GIF que se guardará; este copy es solo para display.
preview = result.img.copy()
preview.putpalette([255, 255, 255, 0, 0, 0] + [0] * (768 - 6))
preview = preview.convert("RGB")
base_w, base_h = preview.size
scale = self._effective_scale_for(base_w, base_h)
if scale > 1:
preview = preview.resize((base_w * scale, base_h * scale), Image.NEAREST)
self._preview_img_tk = ImageTk.PhotoImage(preview)
self.preview_canvas.delete("all")
self.preview_canvas.configure(background="#ffffff")
self.preview_canvas.create_image(0, 0, anchor="nw", image=self._preview_img_tk)
self.preview_canvas.configure(scrollregion=(0, 0, preview.width, preview.height))
info = (f"{len(result.chars)} chars · "
f"caja {result.box_width}×{result.box_height} px · "
f"bitmap {base_w}×{base_h} px (×{scale})")
if result.fallback_used:
info += f" · {len(result.fallback_used)} fallback"
if result.truly_skipped:
info += f" · {len(result.truly_skipped)} omitidos"
self.info_label.configure(text=info)
def _show_preview_error(self, msg: str) -> None:
self._preview_img_tk = None
self._last_result = None
self.preview_canvas.delete("all")
self.preview_canvas.configure(background="#dddddd")
self.preview_canvas.create_text(10, 10, anchor="nw", fill="#aa0000", text=msg)
self.preview_canvas.configure(scrollregion=(0, 0, 0, 0))
self.info_label.configure(text="")
# -------------------------------------------------------------- Zoom
def _effective_scale_for(self, w: int, h: int) -> int:
if self._zoom is not None:
return self._zoom
return max(1, min(AUTO_MAX_SCALE,
PREVIEW_MAX_W // max(w, 1),
PREVIEW_MAX_H // max(h, 1)))
def _zoom_in(self) -> None:
if self._last_result is None:
return
w, h = self._last_result.img.size
self._zoom = min(MANUAL_MAX_SCALE, self._effective_scale_for(w, h) + 1)
self._update_zoom_label()
self._render_preview(self._last_result)
def _zoom_out(self) -> None:
if self._last_result is None:
return
w, h = self._last_result.img.size
self._zoom = max(1, self._effective_scale_for(w, h) - 1)
self._update_zoom_label()
self._render_preview(self._last_result)
def _zoom_auto(self) -> None:
self._zoom = None
self._update_zoom_label()
if self._last_result is not None:
self._render_preview(self._last_result)
def _update_zoom_label(self) -> None:
self.zoom_label.configure(text="Auto" if self._zoom is None else f"×{self._zoom}")
# -------------------------------------------------------------- Generar
def _generate(self) -> None:
if self._last_result is None:
messagebox.showwarning("font-gen", "No hay un preview válido todavía.")
return
name = self.output_var.get().strip()
if not name:
messagebox.showwarning("font-gen", "Indica un nombre base de salida.")
return
ttf = self.ttf_var.get().strip()
try:
gif_path, fnt_path = font_gen.save_font_files(
self._last_result, name, SCRIPT_DIR,
source_desc=f"{os.path.basename(ttf)} size {self.size_var.get()}",
)
except Exception as e:
messagebox.showerror("font-gen", f"Error al guardar:\n{e}")
return
self.status_var.set(
f"{os.path.basename(gif_path)} y {os.path.basename(fnt_path)} guardados"
)
def _parse_optional_positive_int(s: str) -> int | None:
s = s.strip()
if not s:
return None
try:
v = int(s)
return v if v > 0 else None
except ValueError:
return None
def run_gui() -> None:
app = FontGenApp()
app.mainloop()
if __name__ == "__main__":
run_gui()
+1
View File
@@ -0,0 +1 @@
Pillow>=9.2.0