From 9bc4f68d87828d5c3cb75ca0d6e6776458a01eef Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 28 Feb 2025 06:50:51 +1100 Subject: [PATCH] [font] Use freetype instead of Pillow for font rendering (#8300) --- esphome/components/font/__init__.py | 370 ++++++++++++++-------------- esphome/components/font/font.cpp | 8 +- esphome/components/font/font.h | 1 + esphome/components/lvgl/font.cpp | 2 +- 4 files changed, 191 insertions(+), 190 deletions(-) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 4f569379be..426680604a 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 @@ -6,10 +7,10 @@ from pathlib import Path import re import esphome_glyphsets as glyphsets -import freetype +from freetype import Face, 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 +27,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 +50,42 @@ 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 get_name(value): + if CONF_FAMILY in value: + return ( + f"{value[CONF_FAMILY]}:{int(value[CONF_ITALIC])}:{value[CONF_WEIGHT]}" + ) + if CONF_URL in value: + return value[CONF_URL] + return value[CONF_PATH] + + @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): + self.store = {} + + 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)] = Face(str(value)) FONT_CACHE = FontCache() @@ -109,14 +139,14 @@ def check_missing_glyphs(file, codepoints, warning: bool = False): ) if count > 10: missing_str += f"\n and {count - 10} more." - message = f"Font {Path(file).name} is missing {count} glyph{'s' if count != 1 else ''}:\n {missing_str}" + message = f"Font {FontCache.get_name(file)} is missing {count} glyph{'s' if count != 1 else ''}:\n {missing_str}" if warning: _LOGGER.warning(message) else: raise cv.Invalid(message) -def validate_glyphs(config): +def validate_font_config(config): """ Check for duplicate codepoints, then check that all requested codepoints actually have glyphs defined in the appropriate font file. @@ -143,8 +173,6 @@ def validate_glyphs(config): # Make setpoints and glyphspoints disjoint setpoints.difference_update(glyphspoints) if fileconf[CONF_TYPE] == TYPE_LOCAL_BITMAP: - # Pillow only allows 256 glyphs per bitmap font. Not sure if that is a Pillow limitation - # or a file format limitation if any(x >= 256 for x in setpoints.copy().union(glyphspoints)): raise cv.Invalid("Codepoints in bitmap fonts must be in the range 0-255") else: @@ -154,13 +182,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,17 +197,32 @@ 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) if font.get_char_index(x) != 0 ] + if config[CONF_FILE][CONF_TYPE] == TYPE_LOCAL_BITMAP: + if CONF_SIZE in config: + raise cv.Invalid( + "Size is not a valid option for bitmap fonts, which are inherently fixed size" + ) + elif CONF_SIZE not in config: + config[CONF_SIZE] = 20 + return 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 +231,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 +295,59 @@ 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) - _LOGGER.debug("download_gfont: path=%s", path) + path = ( + external_files.compute_local_file_dir(DOMAIN) + / f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf" + ) + if not external_files.is_file_recent(str(path), value[CONF_REFRESH]): + _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." + ) - 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) + # In case the remote file is not modified, the download_content function will return the existing file, + # so update the modification time to now. + path.touch() + 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( @@ -340,14 +403,14 @@ 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)) + extension = Path(value).suffix + if extension in BITMAP_EXTENSIONS: + 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( { @@ -391,7 +454,7 @@ FONT_SCHEMA = cv.Schema( cv.one_of(*glyphsets.defined_glyphsets()) ), cv.Optional(CONF_IGNORE_MISSING_GLYPHS, default=False): cv.boolean, - cv.Optional(CONF_SIZE, default=20): cv.int_range(min=1), + cv.Optional(CONF_SIZE): cv.int_range(min=1), cv.Optional(CONF_BPP, default=1): cv.one_of(1, 2, 4, 8), cv.Optional(CONF_EXTRAS, default=[]): cv.ensure_list( cv.Schema( @@ -406,114 +469,19 @@ FONT_SCHEMA = cv.Schema( }, ) -CONFIG_SCHEMA = cv.All(FONT_SCHEMA, validate_glyphs) - - -# PIL doesn't provide a consistent interface for both TrueType and bitmap -# 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 +CONFIG_SCHEMA = cv.All(FONT_SCHEMA, validate_font_config) 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: 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 @@ -537,15 +505,14 @@ async def to_code(config): } # get the codepoints from the glyphs key, flatten to a list of chrs and combine with the points from glyphsets 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) - point_font_map: dict[str, EFont] = {c: base_font for c in point_set} + base_font = FONT_CACHE[config[CONF_FILE]] + point_font_map: dict[str, Face] = {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 = FONT_CACHE[extra[CONF_FILE]] point_font_map.update({c: extra_font for c in extra_points}) codepoints = list(point_set) @@ -553,28 +520,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 + if not font.has_fixed_sizes: + font.set_pixel_sizes(config[CONF_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 +589,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 +599,19 @@ async def to_code(config): glyphs = cg.static_const_array(config[CONF_RAW_GLYPH_ID], glyph_initializer) + font_height = base_font.size.height // 64 + ascender = base_font.size.ascender // 64 + if font_height == 0: + if base_font.has_fixed_sizes: + font_height = base_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.cpp b/esphome/components/font/font.cpp index 8c4cba34b3..32464d87ee 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -81,7 +81,7 @@ void Font::measure(const char *str, int *width, int *x_offset, int *baseline, in if (glyph_n < 0) { // Unknown char, skip if (!this->get_glyphs().empty()) - x += this->get_glyphs()[0].glyph_data_->width; + x += this->get_glyphs()[0].glyph_data_->advance; i++; continue; } @@ -92,7 +92,7 @@ void Font::measure(const char *str, int *width, int *x_offset, int *baseline, in } else { min_x = std::min(min_x, x + glyph.glyph_data_->offset_x); } - x += glyph.glyph_data_->width + glyph.glyph_data_->offset_x; + x += glyph.glyph_data_->advance; i += match_length; has_char = true; @@ -111,7 +111,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo // Unknown char, skip ESP_LOGW(TAG, "Encountered character without representation in font: '%c'", text[i]); if (!this->get_glyphs().empty()) { - uint8_t glyph_width = this->get_glyphs()[0].glyph_data_->width; + uint8_t glyph_width = this->get_glyphs()[0].glyph_data_->advance; display->filled_rectangle(x_at, y_start, glyph_width, this->height_, color); x_at += glyph_width; } @@ -161,7 +161,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo } } } - x_at += glyph.glyph_data_->width + glyph.glyph_data_->offset_x; + x_at += glyph.glyph_data_->advance; i += match_length; } 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;