diff --git a/CLAUDE.md b/CLAUDE.md index 58669d8..e07ee77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. - **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_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) diff --git a/font_gen.py b/font_gen.py index a1ae875..dcb2568 100644 --- a/font_gen.py +++ b/font_gen.py @@ -117,15 +117,14 @@ def build_font_bitmap( except OSError as e: raise RuntimeError(f"No se pudo cargar la fuente: {e}") from e - # --- Calcular dimensiones de la caja --- - # box_height = línea completa: ascent (sobre la línea base) + descent (bajo ella) + # Scratch tentativo para la detección de .notdef. Sus dimensiones solo + # 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() - box_height = box_height_override if box_height_override is not None else (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. + notdef_scratch_h = ascent + abs(descent) 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 # 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 _nd_bbox = font.getbbox(chr(0xFFFE)) if _nd_bbox: - _nd_img = Image.new("RGBA", (_tmp_w, box_height), (0, 0, 0, 0)) - ImageDraw.Draw(_nd_img).text((-_nd_bbox[0], y_offset), chr(0xFFFE), + _nd_img = Image.new("RGBA", (_tmp_w, notdef_scratch_h), (0, 0, 0, 0)) + ImageDraw.Draw(_nd_img).text((-_nd_bbox[0], notdef_y_offset), chr(0xFFFE), font=font, fill=(255, 255, 255, 255)) _notdef_bytes = _nd_img.tobytes() else: @@ -148,8 +147,8 @@ def build_font_bitmap( bbox = font.getbbox(ch) if not bbox: return True - img = Image.new("RGBA", (_tmp_w, box_height), (0, 0, 0, 0)) - ImageDraw.Draw(img).text((-bbox[0], y_offset), ch, font=font, fill=(255, 255, 255, 255)) + img = Image.new("RGBA", (_tmp_w, notdef_scratch_h), (0, 0, 0, 0)) + ImageDraw.Draw(img).text((-bbox[0], notdef_y_offset), ch, font=font, fill=(255, 255, 255, 255)) return img.tobytes() == _notdef_bytes # 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()) + # 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) img_width = columns * box_width img_height = rows * box_height @@ -186,36 +243,32 @@ def build_font_bitmap( # Buffer de píxeles: 0 = fondo, 1 = glifo pixels = bytearray(img_width * img_height) + # --- Pase 2: componer cada glifo en el bitmap final, recortado --- for i, ch in enumerate(chars_to_render): col = i % columns row = i // columns cell_x = col * box_width cell_y = row * box_height - draw_ch = char_render_as[ch] - bbox = font.getbbox(draw_ch) - if not bbox: - continue # sin glifo (ej. espacio): celda vacía, correcto. + b = glyph_bytes.get(ch) + if b is None: + continue - # 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): + src_y = py + y_crop + if src_y >= scratch_h: + break for px in range(box_width): - src = (py * box_width + px) * 4 - if char_bytes[src + 3] > 128: + src = (src_y * box_width + px) * 4 + if b[src + 3] > 128: pixels[(cell_y + py) * img_width + (cell_x + px)] = 1 - # Ancho visual real: última columna con algún píxel opaco. Reemplaza el - # advance tipográfico de getlength() que incluye side-bearings. + # Ancho visual real: última columna con algún píxel opaco en la + # zona visible. Reemplaza el advance tipográfico de getlength(). 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 break diff --git a/font_gen_gui.py b/font_gen_gui.py index e991a5c..c78d412 100644 --- a/font_gen_gui.py +++ b/font_gen_gui.py @@ -20,9 +20,10 @@ 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_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 + @@ -31,6 +32,9 @@ 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 @@ -38,7 +42,7 @@ class FontGenApp(tk.Tk): # 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._zoom: int | None = DEFAULT_ZOOM # None = auto-fit; int = escala explícita self.ttf_var = tk.StringVar() self.size_var = tk.IntVar(value=8) @@ -51,6 +55,7 @@ class FontGenApp(tk.Tk): 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): v.trace_add("write", lambda *_: self._schedule_preview()) @@ -309,13 +314,24 @@ class FontGenApp(tk.Tk): size = state.get("size") if isinstance(size, int) and 4 <= size <= 256: 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: try: size = self.size_var.get() except tk.TclError: size = 8 - state = {"ttf": self.ttf_var.get().strip(), "size": size} + state = { + "ttf": self.ttf_var.get().strip(), + "size": size, + "zoom": self._zoom, + } try: with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(state, f, indent=2)