From 102e2b1cda5800de502b8832a5b4a08bdeddadcb Mon Sep 17 00:00:00 2001 From: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 23 Feb 2025 14:36:41 +1100 Subject: [PATCH] [font] Use freetype instead of Pillow for font rendering --- esphome/components/font/__init__.py | 328 ++++++++++++++-------------- esphome/components/font/font.h | 1 + esphome/components/lvgl/font.cpp | 2 +- 3 files changed, 160 insertions(+), 171 deletions(-) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 4f569379be..30221ccb5c 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import MutableMapping import functools import hashlib import logging @@ -7,9 +8,10 @@ import re import esphome_glyphsets as glyphsets import freetype +from freetype import ft_pixel_mode_grays, ft_pixel_mode_mono import requests -from esphome import core, external_files +from esphome import external_files import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -26,7 +28,7 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt -from esphome.helpers import copy_file_if_changed, cpp_string_escape +from esphome.helpers import cpp_string_escape _LOGGER = logging.getLogger(__name__) @@ -49,13 +51,33 @@ CONF_IGNORE_MISSING_GLYPHS = "ignore_missing_glyphs" # Cache loaded freetype fonts -class FontCache(dict): - def __missing__(self, key): - try: - res = self[key] = freetype.Face(key) - return res - except freetype.FT_Exception as e: - raise cv.Invalid(f"Could not load Font file {key}: {e}") from e +class FontCache(MutableMapping): + @staticmethod + def _keytransform(value): + if CONF_FAMILY in value: + return f"gfont:{value[CONF_FAMILY]}:{int(value[CONF_ITALIC])}:{value[CONF_WEIGHT]}" + if CONF_URL in value: + return f"url:{value[CONF_URL]}" + return f"file:{value[CONF_PATH]}" + + def __init__(self, *args, **kwargs): + self.store = dict() + self.update(dict(*args, **kwargs)) + + def __delitem__(self, key): + del self.store[self._keytransform(key)] + + def __iter__(self): + return iter(self.store) + + def __len__(self): + return len(self.store) + + def __getitem__(self, item): + return self.store[self._keytransform(item)] + + def __setitem__(self, key, value): + self.store[self._keytransform(key)] = freetype.Face(str(value)) FONT_CACHE = FontCache() @@ -154,13 +176,14 @@ def validate_glyphs(config): points = {ord(x) for x in flatten(extra[CONF_GLYPHS])} glyphspoints.difference_update(points) setpoints.difference_update(points) - check_missing_glyphs(extra[CONF_FILE][CONF_PATH], points) + check_missing_glyphs(extra[CONF_FILE], points) # A named glyph that can't be provided is an error - check_missing_glyphs(fileconf[CONF_PATH], glyphspoints) + + check_missing_glyphs(fileconf, glyphspoints) # A missing glyph from a set is a warning. if not config[CONF_IGNORE_MISSING_GLYPHS]: - check_missing_glyphs(fileconf[CONF_PATH], setpoints, warning=True) + check_missing_glyphs(fileconf, setpoints, warning=True) # Populate the default after the above checks so that use of the default doesn't trigger errors if not config[CONF_GLYPHS] and not config[CONF_GLYPHSETS]: @@ -168,7 +191,7 @@ def validate_glyphs(config): config[CONF_GLYPHS] = [DEFAULT_GLYPHS] else: # set a default glyphset, intersected with what the font actually offers - font = FONT_CACHE[fileconf[CONF_PATH]] + font = FONT_CACHE[fileconf] config[CONF_GLYPHS] = [ chr(x) for x in glyphsets.unicodes_per_glyphset(DEFAULT_GLYPHSET) @@ -179,6 +202,13 @@ def validate_glyphs(config): FONT_EXTENSIONS = (".ttf", ".woff", ".otf") +BITMAP_EXTENSIONS = (".bdf", ".pcf") + + +def validate_bitmap_file(value): + if not any(map(value.lower().endswith, BITMAP_EXTENSIONS)): + raise cv.Invalid(f"Only {', '.join(BITMAP_EXTENSIONS)} files are supported.") + return CORE.relative_config_path(cv.file_(value)) def validate_truetype_file(value): @@ -187,24 +217,40 @@ def validate_truetype_file(value): f"Please unzip the font archive '{value}' first and then use the .ttf files inside." ) if not any(map(value.lower().endswith, FONT_EXTENSIONS)): - raise cv.Invalid(f"Only {FONT_EXTENSIONS} files are supported.") + raise cv.Invalid(f"Only {', '.join(FONT_EXTENSIONS)} files are supported.") return CORE.relative_config_path(cv.file_(value)) +def add_local_file(value): + if value in FONT_CACHE: + return value + path = value[CONF_PATH] + if not os.path.isfile(path): + raise cv.Invalid(f"File '{path}' not found.") + FONT_CACHE[value] = path + return value + + TYPE_LOCAL = "local" TYPE_LOCAL_BITMAP = "local_bitmap" TYPE_GFONTS = "gfonts" TYPE_WEB = "web" -LOCAL_SCHEMA = cv.Schema( - { - cv.Required(CONF_PATH): validate_truetype_file, - } +LOCAL_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_PATH): validate_truetype_file, + } + ), + add_local_file, ) -LOCAL_BITMAP_SCHEMA = cv.Schema( - { - cv.Required(CONF_PATH): cv.file_, - } +LOCAL_BITMAP_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_PATH): validate_bitmap_file, + } + ), + add_local_file, ) FULLPATH_SCHEMA = cv.maybe_simple_value( @@ -235,56 +281,57 @@ def _compute_local_font_path(value: dict) -> Path: h.update(url.encode()) key = h.hexdigest()[:8] base_dir = external_files.compute_local_file_dir(DOMAIN) - _LOGGER.debug("_compute_local_font_path: base_dir=%s", base_dir / key) + _LOGGER.debug("_compute_local_font_path: %s", base_dir / key) return base_dir / key -def get_font_path(value, font_type) -> Path: - if font_type == TYPE_GFONTS: - name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1" - return external_files.compute_local_file_dir(DOMAIN) / f"{name}.ttf" - if font_type == TYPE_WEB: - return _compute_local_font_path(value) / "font.ttf" - assert False - - def download_gfont(value): + if value in FONT_CACHE: + return value name = ( f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" ) url = f"https://fonts.googleapis.com/css2?family={name}" - path = get_font_path(value, TYPE_GFONTS) + path = ( + external_files.compute_local_file_dir(DOMAIN) + / f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf" + ) _LOGGER.debug("download_gfont: path=%s", path) - try: - req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) - req.raise_for_status() - except requests.exceptions.RequestException as e: - raise cv.Invalid( - f"Could not download font at {url}, please check the fonts exists " - f"at google fonts ({e})" - ) - match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text) - if match is None: - raise cv.Invalid( - f"Could not extract ttf file from gfonts response for {name}, " - f"please report this." - ) + if not external_files.is_file_recent(str(path), value[CONF_REFRESH]): + try: + req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) + req.raise_for_status() + except requests.exceptions.RequestException as e: + raise cv.Invalid( + f"Could not download font at {url}, please check the fonts exists " + f"at google fonts ({e})" + ) + match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text) + if match is None: + raise cv.Invalid( + f"Could not extract ttf file from gfonts response for {name}, " + f"please report this." + ) - ttf_url = match.group(1) - _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url) + ttf_url = match.group(1) + _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url) - external_files.download_content(ttf_url, path) - return FULLPATH_SCHEMA(path) + external_files.download_content(ttf_url, path) + FONT_CACHE[value] = path + return value def download_web_font(value): + if value in FONT_CACHE: + return value url = value[CONF_URL] - path = get_font_path(value, TYPE_WEB) + path = _compute_local_font_path(value) / "font.ttf" external_files.download_content(url, path) _LOGGER.debug("download_web_font: path=%s", path) - return FULLPATH_SCHEMA(path) + FONT_CACHE[value] = path + return value EXTERNAL_FONT_SCHEMA = cv.Schema( @@ -341,13 +388,12 @@ def validate_file_shorthand(value): ) if value.endswith(".pcf") or value.endswith(".bdf"): - value = convert_bitmap_to_pillow_font( - CORE.relative_config_path(cv.file_(value)) + return font_file_schema( + { + CONF_TYPE: TYPE_LOCAL_BITMAP, + CONF_PATH: value, + } ) - return { - CONF_TYPE: TYPE_LOCAL_BITMAP, - CONF_PATH: value, - } return font_file_schema( { @@ -413,107 +459,16 @@ CONFIG_SCHEMA = cv.All(FONT_SCHEMA, validate_glyphs) # fonts. So, we use our own wrappers to give us the consistency that we need. -class TrueTypeFontWrapper: - def __init__(self, font): - self.font = font - - def getoffset(self, glyph): - _, (offset_x, offset_y) = self.font.font.getsize(glyph) - return offset_x, offset_y - - def getmask(self, glyph, **kwargs): - return self.font.getmask(str(glyph), **kwargs) - - def getmetrics(self, glyphs): - return self.font.getmetrics() - - -class BitmapFontWrapper: - def __init__(self, font): - self.font = font - self.max_height = 0 - - def getoffset(self, glyph): - return 0, 0 - - def getmask(self, glyph, **kwargs): - return self.font.getmask(str(glyph), **kwargs) - - def getmetrics(self, glyphs): - max_height = 0 - for glyph in glyphs: - mask = self.getmask(glyph, mode="1") - _, height = mask.size - max_height = max(max_height, height) - return max_height, 0 - - class EFont: - def __init__(self, file, size, codepoints): + def __init__(self, file, codepoints): self.codepoints = codepoints - path = file[CONF_PATH] - self.name = Path(path).name - ftype = file[CONF_TYPE] - if ftype == TYPE_LOCAL_BITMAP: - self.font = load_bitmap_font(path) - else: - self.font = load_ttf_font(path, size) - self.ascent, self.descent = self.font.getmetrics(codepoints) - - -def convert_bitmap_to_pillow_font(filepath): - from PIL import BdfFontFile, PcfFontFile - - local_bitmap_font_file = external_files.compute_local_file_dir( - DOMAIN, - ) / os.path.basename(filepath) - - copy_file_if_changed(filepath, local_bitmap_font_file) - - local_pil_font_file = local_bitmap_font_file.with_suffix(".pil") - with open(local_bitmap_font_file, "rb") as fp: - try: - try: - p = PcfFontFile.PcfFontFile(fp) - except SyntaxError: - fp.seek(0) - p = BdfFontFile.BdfFontFile(fp) - - # Convert to pillow-formatted fonts, which have a .pil and .pbm extension. - p.save(local_pil_font_file) - except (SyntaxError, OSError) as err: - raise core.EsphomeError( - f"Failed to parse as bitmap font: '{filepath}': {err}" - ) - - return str(local_pil_font_file) - - -def load_bitmap_font(filepath): - from PIL import ImageFont - - try: - font = ImageFont.load(str(filepath)) - except Exception as e: - raise core.EsphomeError(f"Failed to load bitmap font file: {filepath}: {e}") - - return BitmapFontWrapper(font) - - -def load_ttf_font(path, size): - from PIL import ImageFont - - try: - font = ImageFont.truetype(str(path), size) - except Exception as e: - raise core.EsphomeError(f"Could not load TrueType file {path}: {e}") - - return TrueTypeFontWrapper(font) + self.font: freetype.Face = FONT_CACHE[file] class GlyphInfo: - def __init__(self, data_len, offset_x, offset_y, width, height): + def __init__(self, data_len, advance, offset_x, offset_y, width, height): self.data_len = data_len + self.advance = advance self.offset_x = offset_x self.offset_y = offset_y self.width = width @@ -539,13 +494,13 @@ async def to_code(config): point_set.update(flatten(config[CONF_GLYPHS])) size = config[CONF_SIZE] # Create the codepoint to font file map - base_font = EFont(config[CONF_FILE], size, point_set) + base_font = EFont(config[CONF_FILE], point_set) point_font_map: dict[str, EFont] = {c: base_font for c in point_set} # process extras, updating the map and extending the codepoint list for extra in config[CONF_EXTRAS]: extra_points = flatten(extra[CONF_GLYPHS]) point_set.update(extra_points) - extra_font = EFont(extra[CONF_FILE], size, extra_points) + extra_font = EFont(extra[CONF_FILE], extra_points) point_font_map.update({c: extra_font for c in extra_points}) codepoints = list(point_set) @@ -553,28 +508,52 @@ async def to_code(config): glyph_args = {} data = [] bpp = config[CONF_BPP] - if bpp == 1: - mode = "1" - scale = 1 - else: - mode = "L" - scale = 256 // (1 << bpp) + mode = ft_pixel_mode_grays + scale = 256 // (1 << bpp) # create the data array for all glyphs for codepoint in codepoints: - font = point_font_map[codepoint] - mask = font.font.getmask(codepoint, mode=mode) - offset_x, offset_y = font.font.getoffset(codepoint) - width, height = mask.size + font = point_font_map[codepoint].font + if not font.has_fixed_sizes: + font.set_pixel_sizes(size, 0) + font.load_char(codepoint) + font.glyph.render(mode) + width = font.glyph.bitmap.width + 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 pos = 0 for y in range(height): for x in range(width): - pixel = mask.getpixel((x, y)) // scale + 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 bit_num in range(bpp): if pixel & (1 << (bpp - bit_num - 1)): glyph_data[pos // 8] |= 0x80 >> (pos % 8) pos += 1 - glyph_args[codepoint] = GlyphInfo(len(data), offset_x, offset_y, width, height) + ascender = font.size.ascender // 64 + if ascender == 0: + if font.has_fixed_sizes: + ascender = font.available_sizes[0].height + else: + _LOGGER.error( + "Unable to determine ascender of font %s", config[CONF_FILE] + ) + glyph_args[codepoint] = GlyphInfo( + len(data), + font.glyph.metrics.horiAdvance // 64, + font.glyph.bitmap_left, + ascender - font.glyph.bitmap_top, + width, + height, + ) data += glyph_data rhs = [HexInt(x) for x in data] @@ -598,6 +577,7 @@ async def to_code(config): f"{str(prog_arr)} + {str(glyph_args[codepoint].data_len)}" ), ), + ("advance", glyph_args[codepoint].advance), ("offset_x", glyph_args[codepoint].offset_x), ("offset_y", glyph_args[codepoint].offset_y), ("width", glyph_args[codepoint].width), @@ -607,11 +587,19 @@ async def to_code(config): glyphs = cg.static_const_array(config[CONF_RAW_GLYPH_ID], glyph_initializer) + font_height = base_font.font.size.height // 64 + ascender = base_font.font.size.ascender // 64 + if font_height == 0: + if base_font.font.has_fixed_sizes: + font_height = base_font.font.available_sizes[0].height + ascender = font_height + else: + _LOGGER.error("Unable to determine height of font %s", config[CONF_FILE]) cg.new_Pvariable( config[CONF_ID], glyphs, len(glyph_initializer), - base_font.ascent, - base_font.ascent + base_font.descent, + ascender, + font_height, bpp, ) diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index 5cde694d91..9ee23b3ec5 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -15,6 +15,7 @@ class Font; struct GlyphData { const uint8_t *a_char; const uint8_t *data; + int advance; int offset_x; int offset_y; int width; diff --git a/esphome/components/lvgl/font.cpp b/esphome/components/lvgl/font.cpp index 9c172f07f5..a0d5127570 100644 --- a/esphome/components/lvgl/font.cpp +++ b/esphome/components/lvgl/font.cpp @@ -19,7 +19,7 @@ static bool get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, ui const auto *gd = fe->get_glyph_data(unicode_letter); if (gd == nullptr) return false; - dsc->adv_w = gd->offset_x + gd->width; + dsc->adv_w = gd->advance; dsc->ofs_x = gd->offset_x; dsc->ofs_y = fe->height - gd->height - gd->offset_y - fe->baseline; dsc->box_w = gd->width;