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

This commit is contained in:
2026-04-26 15:22:07 +02:00
parent 49b8917a46
commit c862f5aab8
6 changed files with 959 additions and 0 deletions
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""Interfaz gráfica Tk para font_gen.
Selecciona un TTF, ajusta el tamaño y propiedades de la cuadrícula, ve un
preview en vivo del GIF resultante, y genera los ficheros .gif/.fnt junto
a este script al pulsar GENERAR.
"""
from __future__ import annotations
import os
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from PIL import Image, ImageTk
import font_gen
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PREVIEW_MAX_W = 720
PREVIEW_MAX_H = 320
PREVIEW_DEBOUNCE_MS = 200
AUTO_MAX_SCALE = 8 # techo del fit automático
MANUAL_MAX_SCALE = 16 # techo cuando el usuario pulsa +
class FontGenApp(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("font-gen")
self.minsize(640, 480)
self._preview_job: str | None = None
# Mantener una referencia viva a la PhotoImage; si no, Tk la libera
# en cuanto sale del scope local y el preview aparece en blanco.
self._preview_img_tk: ImageTk.PhotoImage | None = None
self._last_result: font_gen.FontBitmapResult | None = None
self._zoom: int | None = None # None = auto-fit; int = escala explícita
self.ttf_var = tk.StringVar()
self.size_var = tk.IntVar(value=8)
self.output_var = tk.StringVar()
self.columns_var = tk.IntVar(value=15)
self.bw_var = tk.StringVar() # "" = auto
self.bh_var = tk.StringVar() # "" = auto
self._adv_visible = False
self._build_ui()
for v in (self.ttf_var, self.size_var, self.columns_var, self.bw_var, self.bh_var):
v.trace_add("write", lambda *_: self._schedule_preview())
# ------------------------------------------------------------------ UI
def _build_ui(self) -> None:
root = ttk.Frame(self, padding=8)
root.pack(fill="both", expand=True)
row = ttk.Frame(root)
row.pack(fill="x", pady=(0, 4))
ttk.Label(row, text="TTF:", width=8).pack(side="left")
ttk.Entry(row, textvariable=self.ttf_var).pack(side="left", fill="x", expand=True, padx=(0, 4))
ttk.Button(row, text="Examinar…", command=self._browse_ttf).pack(side="left")
row = ttk.Frame(root)
row.pack(fill="x", pady=4)
ttk.Label(row, text="Tamaño:", width=8).pack(side="left")
ttk.Spinbox(row, from_=4, to=128, width=5, textvariable=self.size_var).pack(side="left")
ttk.Label(row, text="Salida:").pack(side="left", padx=(16, 4))
ttk.Entry(row, textvariable=self.output_var, width=24).pack(side="left")
self.adv_btn = ttk.Button(root, text="▸ Opciones avanzadas", command=self._toggle_advanced)
self.adv_btn.pack(anchor="w", pady=(8, 0))
self.adv_frame = ttk.Frame(root)
for label, var, w in [
("Columnas:", self.columns_var, 5),
("box_width (auto):", self.bw_var, 6),
("box_height (auto):", self.bh_var, 6),
]:
ttk.Label(self.adv_frame, text=label).pack(side="left", padx=(8, 2))
ttk.Entry(self.adv_frame, textvariable=var, width=w).pack(side="left")
prev = ttk.LabelFrame(root, text="Preview", padding=8)
prev.pack(fill="both", expand=True, pady=8)
# Controles de zoom
zoom_row = ttk.Frame(prev)
zoom_row.pack(fill="x", pady=(0, 6))
ttk.Button(zoom_row, text="", width=3, command=self._zoom_out).pack(side="left")
self.zoom_label = ttk.Label(zoom_row, text="Auto", width=6, anchor="center")
self.zoom_label.pack(side="left", padx=4)
ttk.Button(zoom_row, text="+", width=3, command=self._zoom_in).pack(side="left")
ttk.Button(zoom_row, text="Auto", command=self._zoom_auto).pack(side="left", padx=(8, 0))
# Canvas con scrollbars: deja ver previews mayores que el área visible.
canvas_frame = ttk.Frame(prev)
canvas_frame.pack(fill="both", expand=True)
self.preview_canvas = tk.Canvas(
canvas_frame, background="#dddddd", highlightthickness=0,
width=PREVIEW_MAX_W, height=PREVIEW_MAX_H,
)
hbar = ttk.Scrollbar(canvas_frame, orient="horizontal", command=self.preview_canvas.xview)
vbar = ttk.Scrollbar(canvas_frame, orient="vertical", command=self.preview_canvas.yview)
self.preview_canvas.configure(xscrollcommand=hbar.set, yscrollcommand=vbar.set)
self.preview_canvas.grid(row=0, column=0, sticky="nsew")
vbar.grid(row=0, column=1, sticky="ns")
hbar.grid(row=1, column=0, sticky="ew")
canvas_frame.rowconfigure(0, weight=1)
canvas_frame.columnconfigure(0, weight=1)
self.preview_canvas.create_text(
10, 10, anchor="nw", fill="#666666",
text="Selecciona un TTF para ver el preview.",
tags=("placeholder",),
)
self.info_label = ttk.Label(prev, text="", foreground="#555555")
self.info_label.pack(anchor="w", pady=(4, 0))
row = ttk.Frame(root)
row.pack(fill="x")
self.status_var = tk.StringVar()
ttk.Label(row, textvariable=self.status_var, foreground="#006600").pack(side="left")
ttk.Button(row, text="GENERAR", command=self._generate).pack(side="right")
def _toggle_advanced(self) -> None:
if self._adv_visible:
self.adv_frame.pack_forget()
self.adv_btn.configure(text="▸ Opciones avanzadas")
else:
self.adv_frame.pack(fill="x", pady=(4, 0), after=self.adv_btn)
self.adv_btn.configure(text="▾ Opciones avanzadas")
self._adv_visible = not self._adv_visible
def _browse_ttf(self) -> None:
path = filedialog.askopenfilename(
title="Selecciona TTF",
filetypes=[("TrueType / OpenType", "*.ttf *.otf"), ("Todos los archivos", "*.*")],
)
if not path:
return
self.ttf_var.set(path)
if not self.output_var.get().strip():
self.output_var.set(os.path.splitext(os.path.basename(path))[0])
# ---------------------------------------------------------- Preview flow
def _schedule_preview(self) -> None:
# Cualquier cambio en los inputs invalida el último "guardado correcto".
self.status_var.set("")
if self._preview_job is not None:
self.after_cancel(self._preview_job)
self._preview_job = self.after(PREVIEW_DEBOUNCE_MS, self._update_preview)
def _update_preview(self) -> None:
self._preview_job = None
ttf = self.ttf_var.get().strip()
if not ttf:
self._show_preview_error("Selecciona un TTF.")
return
if not os.path.isfile(ttf):
self._show_preview_error(f"No se encuentra el archivo:\n{ttf}")
return
# IntVar.get() lanza TclError si el campo está vacío durante la edición.
try:
size = self.size_var.get()
except tk.TclError:
self._show_preview_error("Tamaño no válido.")
return
if not (4 <= size <= 256):
self._show_preview_error("Tamaño fuera de rango (4256).")
return
try:
columns = self.columns_var.get()
except tk.TclError:
columns = 15
if columns <= 0:
columns = 15
bw = _parse_optional_positive_int(self.bw_var.get())
bh = _parse_optional_positive_int(self.bh_var.get())
try:
result = font_gen.build_font_bitmap(ttf, size, columns, bw, bh)
except Exception as e:
self._show_preview_error(f"Error: {e}")
return
self._last_result = result
self._render_preview(result)
def _render_preview(self, result: font_gen.FontBitmapResult) -> None:
# Paleta legible para humanos: 0 = blanco (fondo), 1 = negro (glifo).
# No afecta al GIF que se guardará; este copy es solo para display.
preview = result.img.copy()
preview.putpalette([255, 255, 255, 0, 0, 0] + [0] * (768 - 6))
preview = preview.convert("RGB")
base_w, base_h = preview.size
scale = self._effective_scale_for(base_w, base_h)
if scale > 1:
preview = preview.resize((base_w * scale, base_h * scale), Image.NEAREST)
self._preview_img_tk = ImageTk.PhotoImage(preview)
self.preview_canvas.delete("all")
self.preview_canvas.configure(background="#ffffff")
self.preview_canvas.create_image(0, 0, anchor="nw", image=self._preview_img_tk)
self.preview_canvas.configure(scrollregion=(0, 0, preview.width, preview.height))
info = (f"{len(result.chars)} chars · "
f"caja {result.box_width}×{result.box_height} px · "
f"bitmap {base_w}×{base_h} px (×{scale})")
if result.fallback_used:
info += f" · {len(result.fallback_used)} fallback"
if result.truly_skipped:
info += f" · {len(result.truly_skipped)} omitidos"
self.info_label.configure(text=info)
def _show_preview_error(self, msg: str) -> None:
self._preview_img_tk = None
self._last_result = None
self.preview_canvas.delete("all")
self.preview_canvas.configure(background="#dddddd")
self.preview_canvas.create_text(10, 10, anchor="nw", fill="#aa0000", text=msg)
self.preview_canvas.configure(scrollregion=(0, 0, 0, 0))
self.info_label.configure(text="")
# -------------------------------------------------------------- Zoom
def _effective_scale_for(self, w: int, h: int) -> int:
if self._zoom is not None:
return self._zoom
return max(1, min(AUTO_MAX_SCALE,
PREVIEW_MAX_W // max(w, 1),
PREVIEW_MAX_H // max(h, 1)))
def _zoom_in(self) -> None:
if self._last_result is None:
return
w, h = self._last_result.img.size
self._zoom = min(MANUAL_MAX_SCALE, self._effective_scale_for(w, h) + 1)
self._update_zoom_label()
self._render_preview(self._last_result)
def _zoom_out(self) -> None:
if self._last_result is None:
return
w, h = self._last_result.img.size
self._zoom = max(1, self._effective_scale_for(w, h) - 1)
self._update_zoom_label()
self._render_preview(self._last_result)
def _zoom_auto(self) -> None:
self._zoom = None
self._update_zoom_label()
if self._last_result is not None:
self._render_preview(self._last_result)
def _update_zoom_label(self) -> None:
self.zoom_label.configure(text="Auto" if self._zoom is None else f"×{self._zoom}")
# -------------------------------------------------------------- Generar
def _generate(self) -> None:
if self._last_result is None:
messagebox.showwarning("font-gen", "No hay un preview válido todavía.")
return
name = self.output_var.get().strip()
if not name:
messagebox.showwarning("font-gen", "Indica un nombre base de salida.")
return
ttf = self.ttf_var.get().strip()
try:
gif_path, fnt_path = font_gen.save_font_files(
self._last_result, name, SCRIPT_DIR,
source_desc=f"{os.path.basename(ttf)} size {self.size_var.get()}",
)
except Exception as e:
messagebox.showerror("font-gen", f"Error al guardar:\n{e}")
return
self.status_var.set(
f"{os.path.basename(gif_path)} y {os.path.basename(fnt_path)} guardados"
)
def _parse_optional_positive_int(s: str) -> int | None:
s = s.strip()
if not s:
return None
try:
v = int(s)
return v if v > 0 else None
except ValueError:
return None
def run_gui() -> None:
app = FontGenApp()
app.mainloop()
if __name__ == "__main__":
run_gui()