Files
font-gen/font_gen_gui.py
T

307 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()