mirror of
https://github.com/esphome/esphome.git
synced 2026-10-21 13:34:12 +00:00
[font] Crop blank space from glyph bitmaps (#21235)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user