From c862f5aab8c3a65d797ffbd3af0a861583b09b0b Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sun, 26 Apr 2026 15:22:07 +0200 Subject: [PATCH] =?UTF-8?q?Versi=C3=B3=20inicial:=20CLI=20i=20GUI=20Tk=20p?= =?UTF-8?q?er=20a=20fonts=20bitmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 13 ++ CLAUDE.md | 60 ++++++ README.md | 93 +++++++++ font_gen.py | 486 +++++++++++++++++++++++++++++++++++++++++++++++ font_gen_gui.py | 306 +++++++++++++++++++++++++++++ requirements.txt | 1 + 6 files changed, 959 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 font_gen.py create mode 100644 font_gen_gui.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a72982 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..58669d8 --- /dev/null +++ b/CLAUDE.md @@ -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 ` # ` 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. diff --git a/README.md b/README.md index e69de29..149c23d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/font_gen.py b/font_gen.py new file mode 100644 index 0000000..a1ae875 --- /dev/null +++ b/font_gen.py @@ -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() diff --git a/font_gen_gui.py b/font_gen_gui.py new file mode 100644 index 0000000..c159393 --- /dev/null +++ b/font_gen_gui.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..71a2668 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Pillow>=9.2.0