Compare commits

3 Commits

Author SHA1 Message Date
911ee7a13e modificada la paleta d'aseprite.gif per consistencia 2026-03-22 19:22:56 +01:00
b876ccbb09 afegit fallback a la font_gen
afegida deteccio de caracters no definits a font_gen
2026-03-22 19:19:00 +01:00
94684e8758 ferramenta de text pot importar gifs
ferramenta de text accepta separació entre quadricules de lletres
2026-03-22 19:06:01 +01:00
12 changed files with 482 additions and 255 deletions

View File

@@ -101,32 +101,32 @@ columns 15
124 1 # | 124 1 # |
125 4 # } 125 4 # }
126 4 # ~ 126 4 # ~
192 4 # À 192 6 # À
193 4 # Á 193 6 # Á
200 4 # È 200 6 # È
201 4 # É 201 6 # É
205 4 # Í 205 6 # Í
207 4 # Ï 207 6 # Ï
210 4 # Ò 210 6 # Ò
211 4 # Ó 211 6 # Ó
218 4 # Ú 218 6 # Ú
220 4 # Ü 220 6 # Ü
209 4 # Ñ 209 6 # Ñ
199 4 # Ç 199 6 # Ç
224 4 # à 224 5 # à
225 4 # á 225 5 # á
232 4 # è 232 5 # è
233 4 # é 233 5 # é
237 4 # í 237 4 # í
239 4 # ï 239 4 # ï
242 4 # ò 242 5 # ò
243 4 # ó 243 5 # ó
250 4 # ú 250 5 # ú
252 4 # ü 252 5 # ü
241 4 # ñ 241 5 # ñ
231 4 # ç 231 5 # ç
161 4 # ¡ 161 2 # ¡
191 4 # ¿ 191 6 # ¿
171 4 # « 171 4 # «
187 4 # » 187 4 # »
183 4 # · 183 2 # ·

Binary file not shown.

Before

Width:  |  Height:  |  Size: 759 B

After

Width:  |  Height:  |  Size: 837 B

View File

@@ -1,135 +1,134 @@
# Font: aseprite # Font: aseprite — generado desde aseprite_font.gif
# Formato: codepoint_decimal ancho_visual # Generado con tools/font_gen/font_gen.py
# Los gliphos se listan en orden de aparición en el bitmap (izquierda→derecha, arriba→abajo)
box_width 8 box_width 10
box_height 8 box_height 7
columns 15 columns 16
cell_spacing 1
row_spacing 4
# ASCII 32-126 # codepoint_decimal ancho_visual
32 3 32 3 # U+0020
33 1 33 1 # !
34 3 34 3 # "
35 3 35 5 # #
36 4 36 4 # $
37 5 37 5 # %
38 5 38 5 # &
39 2 39 2 # '
40 2 40 2 # (
41 2 41 2 # )
42 5 42 5 # *
43 5 43 5 # +
44 3 44 2 # ,
45 3 45 3 # -
46 1 46 1 # .
47 4 47 3 # /
48 4 48 4 # 0
49 2 49 2 # 1
50 4 50 4 # 2
51 4 51 4 # 3
52 4 52 4 # 4
53 4 53 4 # 5
54 4 54 4 # 6
55 4 55 4 # 7
56 4 56 4 # 8
57 4 57 4 # 9
58 1 58 1 # :
59 1 59 2 # ;
60 3 60 3 # <
61 4 61 4 # =
62 4 62 3 # >
63 4 63 4 # ?
64 7 64 8 # @
65 4 65 4 # A
66 4 66 4 # B
67 4 67 4 # C
68 4 68 4 # D
69 4 69 4 # E
70 4 70 4 # F
71 4 71 4 # G
72 4 72 4 # H
73 2 73 1 # I
74 2 74 2 # J
75 4 75 4 # K
76 4 76 4 # L
77 5 77 5 # M
78 4 78 4 # N
79 5 79 5 # O
80 4 80 4 # P
81 5 81 5 # Q
82 4 82 4 # R
83 4 83 4 # S
84 5 84 5 # T
85 4 85 4 # U
86 5 86 5 # V
87 7 87 7 # W
88 5 88 5 # X
89 5 89 5 # Y
90 4 90 4 # Z
91 2 91 2 # [
92 3 92 3 # \
93 2 93 2 # ]
94 5 94 5 # ^
95 5 95 5 # _
96 3 96 3 # `
97 4 97 4 # a
98 4 98 4 # b
99 4 99 4 # c
100 4 100 4 # d
101 4 101 4 # e
102 2 102 2 # f
103 4 103 4 # g
104 4 104 4 # h
105 1 105 1 # i
106 2 106 2 # j
107 4 107 4 # k
108 1 108 1 # l
109 7 109 7 # m
110 4 110 4 # n
111 4 111 4 # o
112 4 112 4 # p
113 4 113 4 # q
114 3 114 3 # r
115 3 115 3 # s
116 2 116 2 # t
117 4 117 4 # u
118 4 118 4 # v
119 5 119 5 # w
120 5 120 5 # x
121 4 121 4 # y
122 4 122 4 # z
123 3 123 3 # {
124 3 124 1 # |
125 3 125 3 # }
126 5 126 4 # ~
192 5 # À
# Extensiones para ES/CA/VA (descomentar tras añadirlos al bitmap) 193 5 # Á
# 192 4 # À 200 5 # È
# 193 4 # Á 201 5 # É
# 200 4 # È 205 5 # Í
# 201 4 # É 207 5 # Ï
# 205 2 # Í 210 5 # Ò
# 207 2 # Ï 211 5 # Ó
# 210 5 # Ò 218 5 # Ú
# 211 5 # Ó 220 5 # Ü
# 218 4 # Ú 209 5 # Ñ
# 220 4 # Ü 199 5 # Ç
# 209 4 # Ñ 224 5 # à
# 199 4 # Ç 225 5 # á
# 224 4 # à 232 5 # è
# 225 4 # á 233 5 # é
# 232 4 # è 237 5 # í
# 233 4 # é 239 5 # ï
# 237 1 # í 242 5 # ò
# 239 2 # ï 243 5 # ó
# 242 4 # ò 250 5 # ú
# 243 4 # ó 252 5 # ü
# 250 4 # ú 241 5 # ñ
# 252 4 # ü 231 5 # ç
# 241 4 # ñ 161 5 # ¡
# 231 4 # ç 191 5 # ¿
# 161 1 # ¡ 171 5 # «
# 191 4 # ¿ 187 5 # »
# 171 5 # « 183 5 # ·
# 187 5 # »
# 183 1 # · (punt volat)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 640 B

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -97,3 +97,32 @@ columns 15
121 6 # y 121 6 # y
122 7 # z 122 7 # z
126 6 # ~ 126 6 # ~
192 6 # À
193 6 # Á
200 7 # È
201 7 # É
205 6 # Í
207 6 # Ï
210 7 # Ò
211 7 # Ó
218 6 # Ú
220 6 # Ü
209 7 # Ñ
199 7 # Ç
224 6 # à
225 6 # á
232 7 # è
233 7 # é
237 6 # í
239 6 # ï
242 7 # ò
243 7 # ó
250 6 # ú
252 6 # ü
241 7 # ñ
231 7 # ç
161 2 # ¡
191 6 # ¿
171 5 # «
187 5 # »
183 2 # ·

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 B

After

Width:  |  Height:  |  Size: 959 B

View File

@@ -101,32 +101,32 @@ columns 15
124 1 # | 124 1 # |
125 3 # } 125 3 # }
126 4 # ~ 126 4 # ~
192 3 # À 192 5 # À
193 3 # Á 193 5 # Á
200 3 # È 200 4 # È
201 3 # É 201 4 # É
205 3 # Í 205 1 # Í
207 3 # Ï 207 1 # Ï
210 3 # Ò 210 5 # Ò
211 3 # Ó 211 5 # Ó
218 3 # Ú 218 5 # Ú
220 3 # Ü 220 5 # Ü
209 3 # Ñ 209 5 # Ñ
199 3 # Ç 199 5 # Ç
224 3 # à 224 4 # à
225 3 # á 225 4 # á
232 3 # è 232 4 # è
233 3 # é 233 4 # é
237 3 # í 237 1 # í
239 3 # ï 239 1 # ï
242 3 # ò 242 4 # ò
243 3 # ó 243 4 # ó
250 3 # ú 250 4 # ú
252 3 # ü 252 4 # ü
241 3 # ñ 241 4 # ñ
231 3 # ç 231 3 # ç
161 3 # ¡ 161 1 # ¡
191 3 # ¿ 191 4 # ¿
171 3 # « 171 3 # «
187 3 # » 187 3 # »
183 3 # · 183 1 # ·

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 B

After

Width:  |  Height:  |  Size: 648 B

View File

@@ -59,9 +59,9 @@ ending2:
credits: credits:
instructions: "INSTRUCCIONS:" instructions: "INSTRUCCIONS:"
l0: "AJUDA EL JAILDOC A RECUPERAR" l0: "AJUDA A JAILDOC A RECUPERAR"
l1: "ELS SEUS PROJECTES I ARRIBAR A" l1: "ELS SEUS PROJECTES I ARRIBAR"
l2: "LA JAIL PER ACABAR-LOS" l2: "A LA JAIL PER ACABAR-LOS"
keys: "TECLES:" keys: "TECLES:"
keys_move: "CURSORS PER A MOURE I SALTAR" keys_move: "CURSORS PER A MOURE I SALTAR"
f8: "F8 ACTIVAR/DESACTIVAR MÚSICA" f8: "F8 ACTIVAR/DESACTIVAR MÚSICA"
@@ -119,7 +119,7 @@ ui:
scoreboard: scoreboard:
items: "TRESORS PILLATS " items: "TRESORS PILLATS "
time: " TEMPS " time: " HORA "
rooms: "SALES" rooms: "SALES"
game: game:

View File

@@ -86,6 +86,10 @@ auto Text::loadTextFile(const std::string& file_path) -> std::shared_ptr<File> {
ls >> tf->box_height; ls >> tf->box_height;
} else if (key == "columns") { } else if (key == "columns") {
ls >> tf->columns; ls >> tf->columns;
} else if (key == "cell_spacing") {
ls >> tf->cell_spacing;
} else if (key == "row_spacing") {
ls >> tf->row_spacing;
} else { } else {
// Línea de glifo: codepoint_decimal ancho_visual // Línea de glifo: codepoint_decimal ancho_visual
uint32_t codepoint = 0; uint32_t codepoint = 0;
@@ -97,8 +101,9 @@ auto Text::loadTextFile(const std::string& file_path) -> std::shared_ptr<File> {
continue; // línea mal formateada, ignorar continue; // línea mal formateada, ignorar
} }
Offset off{}; Offset off{};
off.x = (glyph_index % tf->columns) * tf->box_width; const int row_sp = tf->row_spacing > 0 ? tf->row_spacing : tf->cell_spacing;
off.y = (glyph_index / tf->columns) * tf->box_height; off.x = (glyph_index % tf->columns) * (tf->box_width + tf->cell_spacing) + tf->cell_spacing;
off.y = (glyph_index / tf->columns) * (tf->box_height + row_sp) + tf->cell_spacing;
off.w = width; off.w = width;
tf->offset[codepoint] = off; tf->offset[codepoint] = off;
++glyph_index; ++glyph_index;

View File

@@ -18,9 +18,11 @@ class Text {
}; };
struct File { struct File {
int box_width{0}; // Anchura de la caja de cada caracter en el png int box_width{0}; // Anchura de la caja de cada caracter en el png
int box_height{0}; // Altura de la caja de cada caracter en el png int box_height{0}; // Altura de la caja de cada caracter en el png
int columns{16}; // Número de columnas en el bitmap int columns{16}; // Número de columnas en el bitmap
int cell_spacing{0}; // Píxeles de separación entre columnas (y borde izquierdo/superior)
int row_spacing{0}; // Píxeles de separación entre filas (si difiere de cell_spacing)
std::unordered_map<uint32_t, Offset> offset; // Posición y ancho de cada glifo (clave: codepoint Unicode) std::unordered_map<uint32_t, Offset> offset; // Posición y ancho de cada glifo (clave: codepoint Unicode)
}; };

View File

@@ -1,24 +1,30 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Generador de fuentes bitmap para JailDoctor's Dilemma. """Generador de fuentes bitmap para JailDoctor's Dilemma.
Convierte un archivo .ttf en un GIF indexado + fichero .fnt compatibles Convierte un archivo .ttf (o un GIF existente) en un GIF indexado + fichero .fnt
con el sistema de texto del juego. compatibles con el sistema de texto del juego.
Dependencias: pip install Pillow Dependencias: pip install Pillow
Uso: Uso (desde TTF):
python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont
python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont --dir data/font --box-width 8 python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont --dir data/font --box-width 8
Uso (desde GIF existente con cuadrícula):
python3 font_gen.py --gif myfont.gif --output myfont --columns 16 --cell-spacing 1
Notas: Notas:
- Para fuentes bitmap (pixel fonts) en TTF, usa el tamaño exacto del bitmap strike. - Para fuentes bitmap (pixel fonts) en TTF, usa el tamaño exacto del bitmap strike.
- Los glifos se almacenan como índice de paleta 1 (blanco) sobre fondo transparente (índice 0). - Los glifos se almacenan como índice de paleta 1 (blanco) sobre fondo transparente (índice 0).
- Esto es compatible con SurfaceSprite::render(1, color) del motor del juego. - Esto es compatible con SurfaceSprite::render(1, color) del motor del juego.
- Los caracteres no incluidos en la fuente aparecerán como celdas vacías en el GIF. - Los caracteres no incluidos en la fuente aparecerán como celdas vacías en el GIF.
- El modo --gif acepta un GIF cuya cuadrícula siga el orden de ALL_CHARS.
Celdas vacías (todos los píxeles = índice 0) se marcan como no soportadas.
""" """
import argparse import argparse
import os import os
import shutil
import sys import sys
from math import ceil from math import ceil
@@ -34,6 +40,137 @@ _ASCII_CHARS = [chr(i) for i in range(32, 127)] # 95 chars: espacio
_EXTENDED_CHARS = list("ÀÁÈÉÍÏÒÓÚÜÑÇàáèéíïòóúüñç¡¿«»·") # 29 chars ES/CA/VA _EXTENDED_CHARS = list("ÀÁÈÉÍÏÒÓÚÜÑÇàáèéíïòóúüñç¡¿«»·") # 29 chars ES/CA/VA
ALL_CHARS = _ASCII_CHARS + _EXTENDED_CHARS # 124 total 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",
"¡": "!", "¿": "?", "«": "<", "»": ">", "·": ".",
}
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 tools/font_gen/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 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
# Comprobar límites (el GIF puede tener más o menos filas que ALL_CHARS)
if y0 + box_height > img_h or x0 + box_width > img_w:
skipped.append(ch)
continue
# Celda vacía (todos los píxeles = índice 0)
if all(pixels[x0 + px, y0 + py] == 0 for py in range(box_height) for px in range(box_width)):
if ch.isspace():
# El espacio no tiene píxeles visibles: asignar ancho por defecto
char_widths[ch] = max(1, box_width // 2)
supported_chars.append(ch)
else:
skipped.append(ch)
continue
# Medir ancho visual: última columna con algún píxel no-fondo
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")
# Copiar el GIF al directorio de salida
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( def render_font(
ttf_path: str, ttf_path: str,
@@ -66,32 +203,69 @@ def render_font(
ascent, descent = font.getmetrics() ascent, descent = font.getmetrics()
box_height = box_height_override if box_height_override is not None else (ascent + abs(descent)) box_height = box_height_override if box_height_override is not None else (ascent + abs(descent))
# Filtrar chars: solo incluir los que el TTF soporta realmente. # Calcular y_offset antes de la clasificación (necesario para detectar .notdef)
# Un char se descarta si su advance es 0 (el TTF no lo tiene) y no es un cap_tops = [font.getbbox(ch)[1] for ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if font.getbbox(ch)]
# espacio. Evita que chars sin glifo aparezcan con width=1 en el .fnt, y_offset = -round(sum(cap_tops) / len(cap_tops)) if cap_tops else 0
# lo que causaría solapamiento masivo al renderizar texto localizado.
chars_to_render = [ # Detectar el glifo .notdef: algunos TTF devuelven un glifo sustituto (con
ch for ch in ALL_CHARS # advance > 0 y bbox válido) para chars que no tienen en su cmap. Esto provoca
if ch.isspace() or font.getlength(ch) >= 1.0 # 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
skipped = [ch for ch in ALL_CHARS if ch not in chars_to_render] # referencia. Cualquier char con píxeles idénticos se considera no soportado.
if skipped: _tmp_w = box_width_override or 32
print(f"Omitidos : {len(skipped)} chars sin soporte en este TTF: {''.join(skipped)}") _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:
"""True si el char renderiza el glifo .notdef en lugar de un glifo real."""
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: soportados nativamente, con fallback, o sin soporte.
# Un char tiene soporte nativo si su advance >= 1 Y no renderiza .notdef.
# Si no, se busca en CHAR_FALLBACKS. Sin soporte y sin fallback, se omite.
chars_to_render: list[str] = []
char_render_as: dict[str, str] = {} # char → qué char dibujar realmente
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)
if truly_skipped:
print(f"Omitidos : {len(truly_skipped)} chars sin soporte ni fallback: {''.join(truly_skipped)}")
fallback_used = [ch for ch, r in char_render_as.items() if r != ch]
if fallback_used:
print(f"Fallback : {len(fallback_used)} chars con fallback: " +
" ".join(f"{ch}{char_render_as[ch]}" for ch in fallback_used))
# Medir advance width tipográfico: solo se usa para calcular box_width del canvas # Medir advance width tipográfico: solo se usa para calcular box_width del canvas
# cuando el usuario no lo especifica. El ancho real del .fnt se mide desde píxeles. # cuando el usuario no lo especifica. El ancho real del .fnt se mide desde píxeles.
char_widths: dict[str, int] = {} char_widths: dict[str, int] = {}
for ch in chars_to_render: for ch in chars_to_render:
char_widths[ch] = max(1, int(font.getlength(ch))) render_ch = char_render_as[ch]
char_widths[ch] = max(1, int(font.getlength(render_ch)))
# Calcular el offset vertical para eliminar el espacio en blanco en la parte
# superior de la celda. Muchas fuentes bitmap tienen un em-box más grande que
# los píxeles visibles (ascent incluye espacio interno). Usamos las letras
# mayúsculas como referencia de "cap height": su bbox[1] indica cuántos
# píxeles en blanco hay sobre los caracteres más altos, y restamos ese valor
# para que las mayúsculas queden alineadas al borde superior de la celda.
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
# box_width: anchura de cada celda en el bitmap. # box_width: anchura de cada celda en el bitmap.
# Si el usuario la especifica, se usa tal cual. Si no, se calcula como el # Si el usuario la especifica, se usa tal cual. Si no, se calcula como el
@@ -120,7 +294,8 @@ def render_font(
cell_x = col * box_width cell_x = col * box_width
cell_y = row * box_height cell_y = row * box_height
bbox = font.getbbox(ch) draw_ch = char_render_as[ch] # char que realmente se dibuja (puede ser fallback)
bbox = font.getbbox(draw_ch)
if not bbox: if not bbox:
# Sin glifos visibles (ej. espacio): celda vacía, correcto. # Sin glifos visibles (ej. espacio): celda vacía, correcto.
continue continue
@@ -137,7 +312,7 @@ def render_font(
# glifo al inicio de la celda, compensando el bearing izquierdo. # glifo al inicio de la celda, compensando el bearing izquierdo.
char_img = Image.new("RGBA", (box_width, box_height), (0, 0, 0, 0)) char_img = Image.new("RGBA", (box_width, box_height), (0, 0, 0, 0))
draw = ImageDraw.Draw(char_img) draw = ImageDraw.Draw(char_img)
draw.text((-bbox[0], y_offset), ch, font=font, fill=(255, 255, 255, 255)) draw.text((-bbox[0], y_offset), draw_ch, font=font, fill=(255, 255, 255, 255))
# Umbralizar alpha y volcar al buffer de índices # Umbralizar alpha y volcar al buffer de índices
char_bytes = char_img.tobytes() char_bytes = char_img.tobytes()
@@ -176,21 +351,8 @@ def render_font(
pass # si magick no está disponible se queda como GIF87a de 256 colores pass # si magick no está disponible se queda como GIF87a de 256 colores
print(f"GIF : {output_gif}") print(f"GIF : {output_gif}")
# --- Generar fichero .fnt --- _write_fnt(output_fnt, output_name, f"{os.path.basename(ttf_path)} size {size}",
ttf_name = os.path.basename(ttf_path) box_width, box_height, columns, 0, 0, chars_to_render, char_widths)
with open(output_fnt, "w", encoding="utf-8") as f:
f.write(f"# Font: {output_name} — generado desde {ttf_name} size {size}\n")
f.write(f"# Generado con tools/font_gen/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\n")
f.write("# codepoint_decimal ancho_visual\n")
for ch in chars_to_render:
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")
print(f"FNT : {output_fnt}") print(f"FNT : {output_fnt}")
@@ -201,41 +363,71 @@ def main() -> None:
default_dir = os.path.join(project_dir, "data", "font") default_dir = os.path.join(project_dir, "data", "font")
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Genera fuentes bitmap (.gif + .fnt) desde un archivo .ttf.", description="Genera fuentes bitmap (.gif + .fnt) desde un .ttf o un GIF existente.",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=""" epilog="""
Ejemplos: 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
python3 font_gen.py --ttf myfont.ttf --size 8 --output myfont --box-width 8 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 data/font python3 font_gen.py --ttf myfont.ttf --size 16 --output myfont --dir data/font
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
python3 font_gen.py --gif myfont.gif --output myfont --columns 16 --box-width 10 --box-height 7 --cell-spacing 1 --row-spacing 4
Notas: Notas:
- Para pixel fonts (.ttf bitmap), usa el tamaño exacto del bitmap strike. - Para pixel fonts (.ttf bitmap), usa el tamaño exacto del bitmap strike.
- El GIF resultante usa índice 1 = glifo, índice 0 = transparente. - El GIF resultante usa índice 1 = glifo, índice 0 = transparente.
- Se generan 124 caracteres: ASCII 32-126 + extensiones ES/CA/VA. - Se procesan 124 caracteres: ASCII 32-126 + extensiones ES/CA/VA.
- Usa --box-width para forzar una anchura de celda fija (útil para fuentes - En modo --gif, --box-width y --box-height son obligatorios.
cuadradas donde la celda coincide con box_height). - Las celdas completamente vacías (índice 0) se marcan como no soportadas.
- cell_spacing = spacing horizontal entre columnas (y borde izquierdo/superior).
- row_spacing = spacing vertical entre filas (si difiere de cell_spacing).
""", """,
) )
parser.add_argument("--ttf", required=True, help="Ruta al archivo .ttf")
parser.add_argument("--size", required=True, type=int, help="Tamaño en píxeles") # Fuente: TTF o GIF (mutuamente excluyentes)
parser.add_argument("--output", required=True, help="Nombre base de salida (sin extensión)") source_group = parser.add_mutually_exclusive_group(required=True)
parser.add_argument("--dir", default=default_dir, help="Directorio de salida (default: data/font/)") source_group.add_argument("--ttf", help="Ruta al archivo .ttf")
parser.add_argument("--columns", default=15, type=int, help="Columnas en el bitmap (default: 15)") source_group.add_argument("--gif", help="Ruta a un GIF de fuente existente con cuadrícula")
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: ascent + abs(descent))") parser.add_argument("--size", type=int, help="Tamaño en píxeles (solo con --ttf)")
parser.add_argument("--output", required=True, help="Nombre base de salida (sin extensión)")
parser.add_argument("--dir", default=default_dir, help="Directorio de salida (default: data/font/)")
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() args = parser.parse_args()
render_font( if args.gif:
ttf_path = args.ttf, if args.box_width is None or args.box_height is None:
size = args.size, parser.error("--box-width y --box-height son obligatorios con --gif")
output_name = args.output, row_sp = args.row_spacing if args.row_spacing is not None else args.cell_spacing
output_dir = args.dir, render_gif_font(
columns = args.columns, gif_path = args.gif,
box_width_override = args.box_width, output_name = args.output,
box_height_override = args.box_height, 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 __name__ == "__main__":