[font] Crop blank space from glyph bitmaps (#21235)

This commit is contained in:
Jesse Hills
2026-10-21 15:46:15 +13:00
committed by GitHub
parent d25a925a9b
commit e415c099e0
4 changed files with 210 additions and 16 deletions
+40 -16
View File
@@ -612,7 +612,14 @@ class GlyphInfo:
self.height = height
def glyph_to_glyphinfo(glyph, font, size, bpp):
def glyph_to_glyphinfo(glyph, font, size, bpp, crop: bool = True):
"""Render a glyph and pack its bitmap at the given bits per pixel.
With ``crop``, blank rows and columns around the set pixels are dropped and the
offsets adjusted to match. Bitmap fonts (BDF/PCF) render every glyph as a whole
character cell, so this keeps their empty space out of flash. A blank glyph keeps
its offsets and stores no pixels.
"""
# Convert to 32 bit unicode codepoint
glyph = ord(glyph)
scale = 256 // (1 << bpp)
@@ -632,19 +639,36 @@ def glyph_to_glyphinfo(glyph, font, size, bpp):
height = font.glyph.bitmap.rows
buffer = font.glyph.bitmap.buffer
pitch = font.glyph.bitmap.pitch
glyph_data = [0] * ((height * width * bpp + 7) // 8)
src_mode = font.glyph.bitmap.pixel_mode
if src_mode == ft_pixel_mode_mono:
rows = [
[
(1 << bpp) - 1 if buffer[y * pitch + x // 8] & (1 << (7 - x % 8)) else 0
for x in range(width)
]
for y in range(height)
]
else:
rows = [
[buffer[y * pitch + x] // scale for x in range(width)]
for y in range(height)
]
left = 0
top = 0
if crop and width * height > 1:
used_rows = [y for y, row in enumerate(rows) if any(row)]
used_cols = [x for x in range(width) if any(row[x] for row in rows)]
if used_rows:
left = used_cols[0]
top = used_rows[0]
width = used_cols[-1] + 1 - left
height = used_rows[-1] + 1 - top
else:
width = height = 0
glyph_data = [0] * ((height * width * bpp + 7) // 8)
pos = 0
for y in range(height):
for x in range(width):
if src_mode == ft_pixel_mode_mono:
pixel = (
(1 << bpp) - 1
if buffer[y * pitch + x // 8] & (1 << (7 - x % 8))
else 0
)
else:
pixel = buffer[y * pitch + x] // scale
for row in rows[top : top + height]:
for pixel in row[left : left + width]:
for bit_num in range(bpp):
if pixel & (1 << (bpp - bit_num - 1)):
glyph_data[pos // 8] |= 0x80 >> (pos % 8)
@@ -663,8 +687,8 @@ def glyph_to_glyphinfo(glyph, font, size, bpp):
glyph,
glyph_data,
pt_to_px(font.glyph.metrics.horiAdvance),
font.glyph.bitmap_left,
ascender - font.glyph.bitmap_top,
font.glyph.bitmap_left + left,
ascender - font.glyph.bitmap_top + top,
width,
height,
)
@@ -733,9 +757,9 @@ async def to_code(config):
font_height = pt_to_px(base_font.size.height)
ascender = pt_to_px(base_font.size.ascender)
descender = abs(pt_to_px(base_font.size.descender))
g = glyph_to_glyphinfo("x", base_font, size, bpp)
g = glyph_to_glyphinfo("x", base_font, size, bpp, crop=False)
xheight = g.height if len(g.bitmap_data) > 1 else 0
g = glyph_to_glyphinfo("X", base_font, size, bpp)
g = glyph_to_glyphinfo("X", base_font, size, bpp, crop=False)
capheight = g.height if len(g.bitmap_data) > 1 else 0
if font_height == 0:
if not base_font.is_scalable:
+72
View File
@@ -0,0 +1,72 @@
STARTFONT 2.1
FONT -Test-Cell-Medium-R-Normal--8-80-75-75-C-60-ISO10646-1
SIZE 8 75 75
FONTBOUNDINGBOX 6 8 0 -2
STARTPROPERTIES 4
PIXEL_SIZE 8
FONT_ASCENT 6
FONT_DESCENT 2
SPACING "C"
ENDPROPERTIES
CHARS 4
STARTCHAR space
ENCODING 32
SWIDTH 720 0
DWIDTH 6 0
BBX 6 8 0 -2
BITMAP
00
00
00
00
00
00
00
00
ENDCHAR
STARTCHAR X
ENCODING 88
SWIDTH 720 0
DWIDTH 6 0
BBX 6 8 0 -2
BITMAP
84
00
00
30
00
00
00
84
ENDCHAR
STARTCHAR x
ENCODING 120
SWIDTH 720 0
DWIDTH 6 0
BBX 6 8 0 -2
BITMAP
00
00
50
20
50
00
00
00
ENDCHAR
STARTCHAR bar
ENCODING 124
SWIDTH 720 0
DWIDTH 6 0
BBX 6 8 0 -2
BITMAP
20
20
20
20
20
20
20
20
ENDCHAR
ENDFONT
@@ -0,0 +1,14 @@
esphome:
name: test
host:
font:
- file: ../cell6x8.bdf
id: cell
size: 8
glyphs: " Xx|"
- file: ../NotoSans-Regular.ttf
id: noto
size: 12
glyphs: " AB"
+84
View File
@@ -10,6 +10,7 @@ from pathlib import Path
import re
from unittest.mock import MagicMock, patch
from freetype import Face
import pytest
from esphome.components.font import (
@@ -19,8 +20,10 @@ from esphome.components.font import (
CONF_IGNORE_MISSING_GLYPHS,
CONF_RAW_GLYPH_ID,
FONT_CACHE,
GlyphInfo,
flatten,
glyph_comparator,
glyph_to_glyphinfo,
to_code,
validate_font_config,
)
@@ -345,3 +348,84 @@ def test_glyph_table_is_progmem(
assert re.search(r"static constexpr font::Glyph \w+\[\] PROGMEM = ", main_cpp)
assert "static const font::Glyph" not in main_cpp
# ---------- glyph cropping ----------
BDF_PATH = FONT_DIR / "cell6x8.bdf"
def _glyph_table(main_cpp: str, glyph_table_id: str) -> dict[int, tuple[int, ...]]:
"""Map each codepoint in a generated glyph table to (advance, offset_x, offset_y, width, height)."""
table = re.search(
rf"font::Glyph {glyph_table_id}\[\] PROGMEM = \{{(.*)\}};", main_cpp
).group(1)
return {
int(code): tuple(int(x) for x in rest.split(", "))
for code, rest in re.findall(r"\{(\d+), \([^)]*\), ([-\d, ]+)\}", table)
}
def test_bitmap_font_glyphs_are_cropped(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Bitmap font glyphs drop blank rows and columns; outline glyphs are unchanged."""
main_cpp = generate_main(component_config_path("bitmap_crop.yaml"))
# Each 6x8 cell is reduced to its set pixels, and the offsets move to match
assert _glyph_table(main_cpp, "font_glyph_id") == {
32: (6, 0, 0, 0, 0), # blank: no pixels stored
88: (6, 0, 0, 6, 8), # touches every edge of the cell: no crop
120: (6, 1, 2, 3, 3), # blank border all round
124: (6, 2, 0, 1, 8), # full height column
}
# 6 bytes for "X", 2 for the cropped "x", 1 for "|"
assert (
"uint8_t_id[] PROGMEM = "
"{0x84, 0x00, 0x0C, 0x00, 0x00, 0x21, 0xAA, 0x80, 0xFF};" in main_cpp
)
# xheight and capheight still measure the uncropped cell
assert "font::Font(font_glyph_id, 4, 6, 8, 2, 8, 8, 1);" in main_cpp
# FreeType already crops outline glyphs, and a blank one stays 1x1
assert _glyph_table(main_cpp, "font_glyph_id_2") == {
32: (3, 0, 12, 1, 1),
65: (8, 0, 4, 8, 9),
66: (8, 1, 4, 6, 9),
}
def _render(glyph: GlyphInfo, bpp: int) -> dict[tuple[int, int], int]:
"""Decode a packed glyph bitmap into its non-zero pixels at absolute positions."""
pixels = {}
pos = 0
for y in range(glyph.height):
for x in range(glyph.width):
value = 0
for _ in range(bpp):
bit = glyph.bitmap_data[pos // 8] >> (7 - pos % 8) & 1
value = value << 1 | bit
pos += 1
if value:
pixels[(x + glyph.offset_x, y + glyph.offset_y)] = value
return pixels
@pytest.mark.parametrize("bpp", [1, 2, 4, 8])
@pytest.mark.parametrize(
("path", "size"),
[(BDF_PATH, 8), (FONT_PATH, 12)],
ids=["bitmap", "outline"],
)
def test_cropped_glyphs_render_identically(path: Path, size: int, bpp: int) -> None:
"""Cropping only drops blank pixels, so every glyph renders the same as before."""
face = Face(str(path))
for char in " Xx|AB":
if not face.get_char_index(char):
continue
full = glyph_to_glyphinfo(char, face, size, bpp, crop=False)
cropped = glyph_to_glyphinfo(char, face, size, bpp)
assert _render(cropped, bpp) == _render(full, bpp)
assert cropped.advance == full.advance
assert len(cropped.bitmap_data) <= len(full.bitmap_data)