Versió inicial: CLI i GUI Tk per a fonts bitmap
This commit is contained in:
+13
@@ -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
|
||||
@@ -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 32–126 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.
|
||||
@@ -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 32–126 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
@@ -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
@@ -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 (4–256).")
|
||||
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()
|
||||
@@ -0,0 +1 @@
|
||||
Pillow>=9.2.0
|
||||
Reference in New Issue
Block a user