Caixa pixel-tight, accents visibles i zoom ×3 per defecte
This commit is contained in:
@@ -38,8 +38,9 @@ These properties are consumed by the engine and must hold for any change to the
|
|||||||
|
|
||||||
- **`.notdef` detection**: some TTFs return a substitute glyph (with positive advance and a valid bbox) for missing codepoints, which would falsely pass the `getlength(ch) >= 1` check. The tool renders `U+FFFE` as a reference and rejects any char whose pixels match it. See `_is_notdef` inside `build_font_bitmap`.
|
- **`.notdef` detection**: some TTFs return a substitute glyph (with positive advance and a valid bbox) for missing codepoints, which would falsely pass the `getlength(ch) >= 1` check. The tool renders `U+FFFE` as a reference and rejects any char whose pixels match it. See `_is_notdef` inside `build_font_bitmap`.
|
||||||
- **Fallback table** (`CHAR_FALLBACKS`) maps accented ES/CA/VA chars to ASCII equivalents (`À→A`, `ñ→n`, …). When a TTF lacks the accented glyph, the fallback is drawn into the cell **but the original codepoint is recorded in the `.fnt`**, so localised text still resolves at runtime.
|
- **Fallback table** (`CHAR_FALLBACKS`) maps accented ES/CA/VA chars to ASCII equivalents (`À→A`, `ñ→n`, …). When a TTF lacks the accented glyph, the fallback is drawn into the cell **but the original codepoint is recorded in the `.fnt`**, so localised text still resolves at runtime.
|
||||||
- **Vertical placement**: `draw.text((-bbox[0], y_offset), …)` where `y_offset` is computed from the average top of capital letters. Don't "fix" this by removing the offset — it keeps descenders inside the cell and aligns punctuation to the baseline.
|
- **Vertical placement**: `draw.text((-bbox[0], y_offset), …)` where `y_offset = -min(getbbox.top)` over all chars to draw, so the topmost pixel of any glyph (including the diacritic on À/É/Ç) lands at y=0 in the scratch. Don't revert this to `cap_tops` average — that variant clips accents above the cap line. The `.notdef` detection uses a separate tentative `notdef_scratch_h` / `notdef_y_offset` (consistency between reference and per-char render is all that matters there); the real `scratch_h` and `y_offset` are recomputed from `getbbox` spans after classification.
|
||||||
- **`box_width` auto-mode** uses `max(getlength)` across rendered chars; override with `--box-width` for pixel-exact bitmap strikes.
|
- **`box_width` auto-mode** uses `max(getlength)` across rendered chars; override with `--box-width` for pixel-exact bitmap strikes.
|
||||||
|
- **`box_height` auto-mode is pixel-tight**, *not* `ascent + descent`. Glyphs are rendered to a scratch canvas sized to the bbox span, then `box_height = max_opaque_y - min_opaque_y + 1` across all rendered glyphs (and `y_crop = min_opaque_y` is applied during the second pass). This trims unused leading/descent space — for many bitmap fonts the typographic line height reserves rows that no glyph actually paints. Override with `--box-height` to disable.
|
||||||
|
|
||||||
## GIF mode quirks (CLI-only)
|
## GIF mode quirks (CLI-only)
|
||||||
|
|
||||||
|
|||||||
+82
-29
@@ -117,15 +117,14 @@ def build_font_bitmap(
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise RuntimeError(f"No se pudo cargar la fuente: {e}") from e
|
raise RuntimeError(f"No se pudo cargar la fuente: {e}") from e
|
||||||
|
|
||||||
# --- Calcular dimensiones de la caja ---
|
# Scratch tentativo para la detección de .notdef. Sus dimensiones solo
|
||||||
# box_height = línea completa: ascent (sobre la línea base) + descent (bajo ella)
|
# afectan a la comparación pixel-perfect entre el char candidato y el
|
||||||
|
# glifo .notdef; lo único que importa es la consistencia interna entre
|
||||||
|
# ambas renders. El scratch definitivo del bitmap se recalcula después.
|
||||||
ascent, descent = font.getmetrics()
|
ascent, descent = font.getmetrics()
|
||||||
box_height = box_height_override if box_height_override is not None else (ascent + abs(descent))
|
notdef_scratch_h = ascent + abs(descent)
|
||||||
|
|
||||||
# y_offset: media de los tops de las mayúsculas, para alinear la cima del glifo
|
|
||||||
# con el borde superior de la celda y dejar puntuación/descenders dentro.
|
|
||||||
cap_tops = [font.getbbox(ch)[1] for ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if font.getbbox(ch)]
|
cap_tops = [font.getbbox(ch)[1] for ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if font.getbbox(ch)]
|
||||||
y_offset = -round(sum(cap_tops) / len(cap_tops)) if cap_tops else 0
|
notdef_y_offset = -round(sum(cap_tops) / len(cap_tops)) if cap_tops else 0
|
||||||
|
|
||||||
# Detectar el glifo .notdef: algunos TTF devuelven un glifo sustituto (con
|
# Detectar el glifo .notdef: algunos TTF devuelven un glifo sustituto (con
|
||||||
# advance > 0 y bbox válido) para chars que no tienen en su cmap. Esto provoca
|
# advance > 0 y bbox válido) para chars que no tienen en su cmap. Esto provoca
|
||||||
@@ -135,8 +134,8 @@ def build_font_bitmap(
|
|||||||
_tmp_w = box_width_override or 32
|
_tmp_w = box_width_override or 32
|
||||||
_nd_bbox = font.getbbox(chr(0xFFFE))
|
_nd_bbox = font.getbbox(chr(0xFFFE))
|
||||||
if _nd_bbox:
|
if _nd_bbox:
|
||||||
_nd_img = Image.new("RGBA", (_tmp_w, box_height), (0, 0, 0, 0))
|
_nd_img = Image.new("RGBA", (_tmp_w, notdef_scratch_h), (0, 0, 0, 0))
|
||||||
ImageDraw.Draw(_nd_img).text((-_nd_bbox[0], y_offset), chr(0xFFFE),
|
ImageDraw.Draw(_nd_img).text((-_nd_bbox[0], notdef_y_offset), chr(0xFFFE),
|
||||||
font=font, fill=(255, 255, 255, 255))
|
font=font, fill=(255, 255, 255, 255))
|
||||||
_notdef_bytes = _nd_img.tobytes()
|
_notdef_bytes = _nd_img.tobytes()
|
||||||
else:
|
else:
|
||||||
@@ -148,8 +147,8 @@ def build_font_bitmap(
|
|||||||
bbox = font.getbbox(ch)
|
bbox = font.getbbox(ch)
|
||||||
if not bbox:
|
if not bbox:
|
||||||
return True
|
return True
|
||||||
img = Image.new("RGBA", (_tmp_w, box_height), (0, 0, 0, 0))
|
img = Image.new("RGBA", (_tmp_w, notdef_scratch_h), (0, 0, 0, 0))
|
||||||
ImageDraw.Draw(img).text((-bbox[0], y_offset), ch, font=font, fill=(255, 255, 255, 255))
|
ImageDraw.Draw(img).text((-bbox[0], notdef_y_offset), ch, font=font, fill=(255, 255, 255, 255))
|
||||||
return img.tobytes() == _notdef_bytes
|
return img.tobytes() == _notdef_bytes
|
||||||
|
|
||||||
# Clasificar chars: soporte nativo, fallback ASCII, o sin soporte.
|
# Clasificar chars: soporte nativo, fallback ASCII, o sin soporte.
|
||||||
@@ -179,6 +178,64 @@ def build_font_bitmap(
|
|||||||
|
|
||||||
box_width = box_width_override if box_width_override is not None else max(char_widths.values())
|
box_width = box_width_override if box_width_override is not None else max(char_widths.values())
|
||||||
|
|
||||||
|
# Scratch definitivo: lo dimensionamos al span vertical real (bbox)
|
||||||
|
# de los caracteres que vamos a dibujar, INCLUIDOS los acentos. Si solo
|
||||||
|
# nos basásemos en el top de las mayúsculas (cap_tops), À/É/Ç quedarían
|
||||||
|
# con el acento por encima de y=0 y se recortarían. y_offset se ajusta
|
||||||
|
# para que el píxel más alto de cualquier glifo caiga en y=0.
|
||||||
|
draw_chars = {char_render_as[ch] for ch in chars_to_render if not ch.isspace()}
|
||||||
|
glyph_bboxes = [b for b in (font.getbbox(c) for c in draw_chars) if b]
|
||||||
|
if glyph_bboxes:
|
||||||
|
top_y = min(b[1] for b in glyph_bboxes)
|
||||||
|
bot_y = max(b[3] for b in glyph_bboxes)
|
||||||
|
scratch_h = max(1, bot_y - top_y)
|
||||||
|
y_offset = -top_y
|
||||||
|
else:
|
||||||
|
scratch_h = notdef_scratch_h
|
||||||
|
y_offset = 0
|
||||||
|
|
||||||
|
# --- Pase 1: rasterizar cada glifo a un scratch RGBA ---
|
||||||
|
# POSICIONAMIENTO VERTICAL: y_offset alinea el píxel más alto de
|
||||||
|
# cualquier glifo (incluido el de un acento) con el borde superior del
|
||||||
|
# scratch. El recorte pixel-tight posterior compensa que las mayúsculas
|
||||||
|
# sin acento queden ligeramente más bajas en su celda.
|
||||||
|
# POSICIONAMIENTO HORIZONTAL: -bbox[0] compensa el bearing izquierdo.
|
||||||
|
glyph_bytes: dict[str, bytes] = {}
|
||||||
|
for ch in chars_to_render:
|
||||||
|
draw_ch = char_render_as[ch]
|
||||||
|
bbox = font.getbbox(draw_ch)
|
||||||
|
if not bbox:
|
||||||
|
continue # sin glifo (ej. espacio): celda vacía, correcto.
|
||||||
|
char_img = Image.new("RGBA", (box_width, scratch_h), (0, 0, 0, 0))
|
||||||
|
ImageDraw.Draw(char_img).text((-bbox[0], y_offset), draw_ch,
|
||||||
|
font=font, fill=(255, 255, 255, 255))
|
||||||
|
glyph_bytes[ch] = char_img.tobytes()
|
||||||
|
|
||||||
|
# --- Alto pixel-tight ---
|
||||||
|
# Como con el ancho visual: tomamos solo las filas con algún píxel
|
||||||
|
# opaco en cualquier caracter. Recorta leading sobrante por arriba y
|
||||||
|
# descenders no usados por abajo.
|
||||||
|
if box_height_override is not None:
|
||||||
|
box_height = box_height_override
|
||||||
|
y_crop = 0
|
||||||
|
elif not glyph_bytes:
|
||||||
|
box_height = scratch_h
|
||||||
|
y_crop = 0
|
||||||
|
else:
|
||||||
|
min_y, max_y = scratch_h, -1
|
||||||
|
for b in glyph_bytes.values():
|
||||||
|
for py in range(scratch_h):
|
||||||
|
row = py * box_width * 4
|
||||||
|
if any(b[row + px * 4 + 3] > 128 for px in range(box_width)):
|
||||||
|
if py < min_y: min_y = py
|
||||||
|
if py > max_y: max_y = py
|
||||||
|
if max_y < 0:
|
||||||
|
box_height = scratch_h
|
||||||
|
y_crop = 0
|
||||||
|
else:
|
||||||
|
box_height = max_y - min_y + 1
|
||||||
|
y_crop = min_y
|
||||||
|
|
||||||
rows = ceil(len(chars_to_render) / columns)
|
rows = ceil(len(chars_to_render) / columns)
|
||||||
img_width = columns * box_width
|
img_width = columns * box_width
|
||||||
img_height = rows * box_height
|
img_height = rows * box_height
|
||||||
@@ -186,36 +243,32 @@ def build_font_bitmap(
|
|||||||
# Buffer de píxeles: 0 = fondo, 1 = glifo
|
# Buffer de píxeles: 0 = fondo, 1 = glifo
|
||||||
pixels = bytearray(img_width * img_height)
|
pixels = bytearray(img_width * img_height)
|
||||||
|
|
||||||
|
# --- Pase 2: componer cada glifo en el bitmap final, recortado ---
|
||||||
for i, ch in enumerate(chars_to_render):
|
for i, ch in enumerate(chars_to_render):
|
||||||
col = i % columns
|
col = i % columns
|
||||||
row = i // columns
|
row = i // columns
|
||||||
cell_x = col * box_width
|
cell_x = col * box_width
|
||||||
cell_y = row * box_height
|
cell_y = row * box_height
|
||||||
|
|
||||||
draw_ch = char_render_as[ch]
|
b = glyph_bytes.get(ch)
|
||||||
bbox = font.getbbox(draw_ch)
|
if b is None:
|
||||||
if not bbox:
|
continue
|
||||||
continue # sin glifo (ej. espacio): celda vacía, correcto.
|
|
||||||
|
|
||||||
# POSICIONAMIENTO VERTICAL: y_offset alinea la cima de las mayúsculas
|
|
||||||
# con el borde superior de la celda; descenders y puntuación quedan
|
|
||||||
# dentro de la caja sin recortes.
|
|
||||||
# POSICIONAMIENTO HORIZONTAL: -bbox[0] compensa el bearing izquierdo.
|
|
||||||
char_img = Image.new("RGBA", (box_width, box_height), (0, 0, 0, 0))
|
|
||||||
draw = ImageDraw.Draw(char_img)
|
|
||||||
draw.text((-bbox[0], y_offset), draw_ch, font=font, fill=(255, 255, 255, 255))
|
|
||||||
|
|
||||||
char_bytes = char_img.tobytes()
|
|
||||||
for py in range(box_height):
|
for py in range(box_height):
|
||||||
|
src_y = py + y_crop
|
||||||
|
if src_y >= scratch_h:
|
||||||
|
break
|
||||||
for px in range(box_width):
|
for px in range(box_width):
|
||||||
src = (py * box_width + px) * 4
|
src = (src_y * box_width + px) * 4
|
||||||
if char_bytes[src + 3] > 128:
|
if b[src + 3] > 128:
|
||||||
pixels[(cell_y + py) * img_width + (cell_x + px)] = 1
|
pixels[(cell_y + py) * img_width + (cell_x + px)] = 1
|
||||||
|
|
||||||
# Ancho visual real: última columna con algún píxel opaco. Reemplaza el
|
# Ancho visual real: última columna con algún píxel opaco en la
|
||||||
# advance tipográfico de getlength() que incluye side-bearings.
|
# zona visible. Reemplaza el advance tipográfico de getlength().
|
||||||
for px in range(box_width - 1, -1, -1):
|
for px in range(box_width - 1, -1, -1):
|
||||||
if any(char_bytes[(py * box_width + px) * 4 + 3] > 128 for py in range(box_height)):
|
if any(b[((y_crop + py) * box_width + px) * 4 + 3] > 128
|
||||||
|
for py in range(box_height)
|
||||||
|
if y_crop + py < scratch_h):
|
||||||
char_widths[ch] = px + 1
|
char_widths[ch] = px + 1
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
+20
-4
@@ -20,9 +20,10 @@ import font_gen
|
|||||||
|
|
||||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
STATE_FILE = os.path.join(SCRIPT_DIR, ".font_gen_gui_state.json")
|
STATE_FILE = os.path.join(SCRIPT_DIR, ".font_gen_gui_state.json")
|
||||||
PREVIEW_MAX_W = 720
|
PREVIEW_MAX_W = 800
|
||||||
PREVIEW_MAX_H = 320
|
PREVIEW_MAX_H = 480
|
||||||
PREVIEW_DEBOUNCE_MS = 200
|
PREVIEW_DEBOUNCE_MS = 200
|
||||||
|
DEFAULT_ZOOM = 3 # zoom inicial cuando no hay estado guardado
|
||||||
AUTO_MAX_SCALE = 8 # techo del fit automático
|
AUTO_MAX_SCALE = 8 # techo del fit automático
|
||||||
MANUAL_MAX_SCALE = 16 # techo cuando el usuario pulsa +
|
MANUAL_MAX_SCALE = 16 # techo cuando el usuario pulsa +
|
||||||
|
|
||||||
@@ -31,6 +32,9 @@ class FontGenApp(tk.Tk):
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.title("font-gen")
|
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.minsize(640, 480)
|
||||||
|
|
||||||
self._preview_job: str | None = None
|
self._preview_job: str | None = None
|
||||||
@@ -38,7 +42,7 @@ class FontGenApp(tk.Tk):
|
|||||||
# en cuanto sale del scope local y el preview aparece en blanco.
|
# en cuanto sale del scope local y el preview aparece en blanco.
|
||||||
self._preview_img_tk: ImageTk.PhotoImage | None = None
|
self._preview_img_tk: ImageTk.PhotoImage | None = None
|
||||||
self._last_result: font_gen.FontBitmapResult | None = None
|
self._last_result: font_gen.FontBitmapResult | None = None
|
||||||
self._zoom: int | None = None # None = auto-fit; int = escala explícita
|
self._zoom: int | None = DEFAULT_ZOOM # None = auto-fit; int = escala explícita
|
||||||
|
|
||||||
self.ttf_var = tk.StringVar()
|
self.ttf_var = tk.StringVar()
|
||||||
self.size_var = tk.IntVar(value=8)
|
self.size_var = tk.IntVar(value=8)
|
||||||
@@ -51,6 +55,7 @@ class FontGenApp(tk.Tk):
|
|||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._load_state() # antes de los traces: el set() inicial no debe disparar preview
|
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):
|
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())
|
v.trace_add("write", lambda *_: self._schedule_preview())
|
||||||
@@ -309,13 +314,24 @@ class FontGenApp(tk.Tk):
|
|||||||
size = state.get("size")
|
size = state.get("size")
|
||||||
if isinstance(size, int) and 4 <= size <= 256:
|
if isinstance(size, int) and 4 <= size <= 256:
|
||||||
self.size_var.set(size)
|
self.size_var.set(size)
|
||||||
|
# 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:
|
def _save_state(self) -> None:
|
||||||
try:
|
try:
|
||||||
size = self.size_var.get()
|
size = self.size_var.get()
|
||||||
except tk.TclError:
|
except tk.TclError:
|
||||||
size = 8
|
size = 8
|
||||||
state = {"ttf": self.ttf_var.get().strip(), "size": size}
|
state = {
|
||||||
|
"ttf": self.ttf_var.get().strip(),
|
||||||
|
"size": size,
|
||||||
|
"zoom": self._zoom,
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||||
json.dump(state, f, indent=2)
|
json.dump(state, f, indent=2)
|
||||||
|
|||||||
Reference in New Issue
Block a user