393 lines
15 KiB
Python
393 lines
15 KiB
Python
#!/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 json
|
||
import os
|
||
import tkinter as tk
|
||
from math import ceil
|
||
from tkinter import filedialog, messagebox, ttk
|
||
|
||
from PIL import Image, ImageTk
|
||
|
||
import font_gen
|
||
|
||
|
||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
STATE_FILE = os.path.join(SCRIPT_DIR, ".font_gen_gui_state.json")
|
||
PREVIEW_MAX_W = 800
|
||
PREVIEW_MAX_H = 480
|
||
PREVIEW_DEBOUNCE_MS = 200
|
||
DEFAULT_ZOOM = 3 # zoom inicial cuando no hay estado guardado
|
||
AUTO_MAX_SCALE = 8 # techo del fit automático
|
||
MANUAL_MAX_SCALE = 16 # techo cuando el usuario pulsa +
|
||
GRID_MIN_SCALE = 2 # zoom mínimo para mostrar la cuadrícula
|
||
GRID_COLOR = "#dd2222"
|
||
|
||
|
||
class FontGenApp(tk.Tk):
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.title("font-gen")
|
||
# Tamaño suficiente para mostrar el preview ×3 de fuentes pequeñas y
|
||
# medianas sin tener que estirar la ventana.
|
||
self.geometry("900x760")
|
||
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 = DEFAULT_ZOOM # 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.strip_accents_var = tk.BooleanVar(value=False)
|
||
|
||
self._adv_visible = False
|
||
|
||
self._build_ui()
|
||
self._load_state() # antes de los traces: el set() inicial no debe disparar preview
|
||
self._update_zoom_label()
|
||
|
||
for v in (self.ttf_var, self.size_var, self.columns_var,
|
||
self.bw_var, self.bh_var, self.strip_accents_var):
|
||
v.trace_add("write", lambda *_: self._schedule_preview())
|
||
|
||
# Disparar un preview inicial si _load_state nos dejó un TTF válido.
|
||
if self.ttf_var.get().strip():
|
||
self._schedule_preview()
|
||
|
||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
|
||
# ------------------------------------------------------------------ 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")
|
||
ttk.Checkbutton(row, text="Sense accents",
|
||
variable=self.strip_accents_var).pack(side="left", padx=(16, 0))
|
||
|
||
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,
|
||
strip_accents=self.strip_accents_var.get(),
|
||
)
|
||
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))
|
||
|
||
# Cuadrícula a partir de un cierto zoom: a 1x los píxeles del glifo
|
||
# y los de la línea son del mismo grosor y se confunden.
|
||
if scale >= GRID_MIN_SCALE:
|
||
cols = result.columns
|
||
rows_n = ceil(len(result.chars) / cols)
|
||
cell_w = result.box_width * scale
|
||
cell_h = result.box_height * scale
|
||
grid_w = cols * cell_w
|
||
grid_h = rows_n * cell_h
|
||
for c in range(cols + 1):
|
||
x = c * cell_w
|
||
self.preview_canvas.create_line(x, 0, x, grid_h, fill=GRID_COLOR, width=1)
|
||
for r in range(rows_n + 1):
|
||
y = r * cell_h
|
||
self.preview_canvas.create_line(0, y, grid_w, y, fill=GRID_COLOR, width=1)
|
||
|
||
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"
|
||
)
|
||
|
||
# ---------------------------------------------------------- Persistencia
|
||
def _load_state(self) -> None:
|
||
try:
|
||
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
||
state = json.load(f)
|
||
except (FileNotFoundError, ValueError, OSError):
|
||
return
|
||
ttf = state.get("ttf", "")
|
||
if isinstance(ttf, str) and ttf and os.path.isfile(ttf):
|
||
self.ttf_var.set(ttf)
|
||
if not self.output_var.get().strip():
|
||
self.output_var.set(os.path.splitext(os.path.basename(ttf))[0])
|
||
size = state.get("size")
|
||
if isinstance(size, int) and 4 <= size <= 256:
|
||
self.size_var.set(size)
|
||
if isinstance(state.get("strip_accents"), bool):
|
||
self.strip_accents_var.set(state["strip_accents"])
|
||
# Zoom: None (auto), o int en [1, MANUAL_MAX_SCALE]
|
||
if "zoom" in state:
|
||
zoom = state["zoom"]
|
||
if zoom is None:
|
||
self._zoom = None
|
||
elif isinstance(zoom, int) and 1 <= zoom <= MANUAL_MAX_SCALE:
|
||
self._zoom = zoom
|
||
|
||
def _save_state(self) -> None:
|
||
try:
|
||
size = self.size_var.get()
|
||
except tk.TclError:
|
||
size = 8
|
||
state = {
|
||
"ttf": self.ttf_var.get().strip(),
|
||
"size": size,
|
||
"zoom": self._zoom,
|
||
"strip_accents": self.strip_accents_var.get(),
|
||
}
|
||
try:
|
||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(state, f, indent=2)
|
||
except OSError:
|
||
pass # nice-to-have, no crítico si no se puede escribir.
|
||
|
||
def _on_close(self) -> None:
|
||
self._save_state()
|
||
self.destroy()
|
||
|
||
|
||
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()
|