#!/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 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 = 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() self._load_state() # antes de los traces: el set() inicial no debe disparar preview 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()) # 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") 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" ) # ---------------------------------------------------------- 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) 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} 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()