diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 732933ba480..f2220f14a6a 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -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: diff --git a/tests/component_tests/font/cell6x8.bdf b/tests/component_tests/font/cell6x8.bdf new file mode 100644 index 00000000000..11201404ee9 --- /dev/null +++ b/tests/component_tests/font/cell6x8.bdf @@ -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 diff --git a/tests/component_tests/font/config/bitmap_crop.yaml b/tests/component_tests/font/config/bitmap_crop.yaml new file mode 100644 index 00000000000..563fbd8fd35 --- /dev/null +++ b/tests/component_tests/font/config/bitmap_crop.yaml @@ -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" diff --git a/tests/component_tests/font/test_font.py b/tests/component_tests/font/test_font.py index 8de0c0a22c0..5bf765595a4 100644 --- a/tests/component_tests/font/test_font.py +++ b/tests/component_tests/font/test_font.py @@ -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)