Caixa pixel-tight, accents visibles i zoom ×3 per defecte

This commit is contained in:
2026-04-26 17:37:32 +02:00
parent 805d9499ce
commit 7631f80d76
3 changed files with 104 additions and 34 deletions
+82 -29
View File
@@ -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