1
0
mirror of https://github.com/esphome/esphome.git synced 2025-03-14 06:38:17 +00:00

[font] Use freetype instead of Pillow for font rendering (#8300)

This commit is contained in:
Clyde Stubbs 2025-02-28 06:50:51 +11:00 committed by GitHub
parent 1029202848
commit 9bc4f68d87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 191 additions and 190 deletions

View File

@ -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,
)

View File

@ -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;
}

View File

@ -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;

View File

@ -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;