Files
font-gen/font_gen_gui.py

393 lines
15 KiB
Python
Raw Permalink 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 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 (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,
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()