1
0
mirror of https://github.com/esphome/esphome.git synced 2025-04-15 23:30:28 +01:00

[font] Use freetype instead of Pillow for font rendering

This commit is contained in:
clydebarrow 2025-02-23 14:36:41 +11:00
parent c281351732
commit 102e2b1cda
3 changed files with 160 additions and 171 deletions

View File

@ -1,3 +1,4 @@
from collections.abc import MutableMapping
import functools import functools
import hashlib import hashlib
import logging import logging
@ -7,9 +8,10 @@ import re
import esphome_glyphsets as glyphsets import esphome_glyphsets as glyphsets
import freetype import freetype
from freetype import ft_pixel_mode_grays, ft_pixel_mode_mono
import requests import requests
from esphome import core, external_files from esphome import external_files
import esphome.codegen as cg import esphome.codegen as cg
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
@ -26,7 +28,7 @@ from esphome.const import (
CONF_WEIGHT, CONF_WEIGHT,
) )
from esphome.core import CORE, HexInt 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__) _LOGGER = logging.getLogger(__name__)
@ -49,13 +51,33 @@ CONF_IGNORE_MISSING_GLYPHS = "ignore_missing_glyphs"
# Cache loaded freetype fonts # Cache loaded freetype fonts
class FontCache(dict): class FontCache(MutableMapping):
def __missing__(self, key): @staticmethod
try: def _keytransform(value):
res = self[key] = freetype.Face(key) if CONF_FAMILY in value:
return res return f"gfont:{value[CONF_FAMILY]}:{int(value[CONF_ITALIC])}:{value[CONF_WEIGHT]}"
except freetype.FT_Exception as e: if CONF_URL in value:
raise cv.Invalid(f"Could not load Font file {key}: {e}") from e 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() FONT_CACHE = FontCache()
@ -154,13 +176,14 @@ def validate_glyphs(config):
points = {ord(x) for x in flatten(extra[CONF_GLYPHS])} points = {ord(x) for x in flatten(extra[CONF_GLYPHS])}
glyphspoints.difference_update(points) glyphspoints.difference_update(points)
setpoints.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 # 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. # A missing glyph from a set is a warning.
if not config[CONF_IGNORE_MISSING_GLYPHS]: 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 # 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]: if not config[CONF_GLYPHS] and not config[CONF_GLYPHSETS]:
@ -168,7 +191,7 @@ def validate_glyphs(config):
config[CONF_GLYPHS] = [DEFAULT_GLYPHS] config[CONF_GLYPHS] = [DEFAULT_GLYPHS]
else: else:
# set a default glyphset, intersected with what the font actually offers # set a default glyphset, intersected with what the font actually offers
font = FONT_CACHE[fileconf[CONF_PATH]] font = FONT_CACHE[fileconf]
config[CONF_GLYPHS] = [ config[CONF_GLYPHS] = [
chr(x) chr(x)
for x in glyphsets.unicodes_per_glyphset(DEFAULT_GLYPHSET) for x in glyphsets.unicodes_per_glyphset(DEFAULT_GLYPHSET)
@ -179,6 +202,13 @@ def validate_glyphs(config):
FONT_EXTENSIONS = (".ttf", ".woff", ".otf") 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): 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." f"Please unzip the font archive '{value}' first and then use the .ttf files inside."
) )
if not any(map(value.lower().endswith, FONT_EXTENSIONS)): 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)) 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 = "local"
TYPE_LOCAL_BITMAP = "local_bitmap" TYPE_LOCAL_BITMAP = "local_bitmap"
TYPE_GFONTS = "gfonts" TYPE_GFONTS = "gfonts"
TYPE_WEB = "web" TYPE_WEB = "web"
LOCAL_SCHEMA = cv.Schema( LOCAL_SCHEMA = cv.All(
{ cv.Schema(
cv.Required(CONF_PATH): validate_truetype_file, {
} cv.Required(CONF_PATH): validate_truetype_file,
}
),
add_local_file,
) )
LOCAL_BITMAP_SCHEMA = cv.Schema( LOCAL_BITMAP_SCHEMA = cv.All(
{ cv.Schema(
cv.Required(CONF_PATH): cv.file_, {
} cv.Required(CONF_PATH): validate_bitmap_file,
}
),
add_local_file,
) )
FULLPATH_SCHEMA = cv.maybe_simple_value( FULLPATH_SCHEMA = cv.maybe_simple_value(
@ -235,56 +281,57 @@ def _compute_local_font_path(value: dict) -> Path:
h.update(url.encode()) h.update(url.encode())
key = h.hexdigest()[:8] key = h.hexdigest()[:8]
base_dir = external_files.compute_local_file_dir(DOMAIN) 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 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): def download_gfont(value):
if value in FONT_CACHE:
return value
name = ( name = (
f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}"
) )
url = f"https://fonts.googleapis.com/css2?family={name}" 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) _LOGGER.debug("download_gfont: path=%s", path)
try: if not external_files.is_file_recent(str(path), value[CONF_REFRESH]):
req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) try:
req.raise_for_status() req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT)
except requests.exceptions.RequestException as e: req.raise_for_status()
raise cv.Invalid( except requests.exceptions.RequestException as e:
f"Could not download font at {url}, please check the fonts exists " raise cv.Invalid(
f"at google fonts ({e})" 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: match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text)
raise cv.Invalid( if match is None:
f"Could not extract ttf file from gfonts response for {name}, " raise cv.Invalid(
f"please report this." f"Could not extract ttf file from gfonts response for {name}, "
) f"please report this."
)
ttf_url = match.group(1) ttf_url = match.group(1)
_LOGGER.debug("download_gfont: ttf_url=%s", ttf_url) _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url)
external_files.download_content(ttf_url, path) external_files.download_content(ttf_url, path)
return FULLPATH_SCHEMA(path) FONT_CACHE[value] = path
return value
def download_web_font(value): def download_web_font(value):
if value in FONT_CACHE:
return value
url = value[CONF_URL] 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) external_files.download_content(url, path)
_LOGGER.debug("download_web_font: path=%s", path) _LOGGER.debug("download_web_font: path=%s", path)
return FULLPATH_SCHEMA(path) FONT_CACHE[value] = path
return value
EXTERNAL_FONT_SCHEMA = cv.Schema( EXTERNAL_FONT_SCHEMA = cv.Schema(
@ -341,13 +388,12 @@ def validate_file_shorthand(value):
) )
if value.endswith(".pcf") or value.endswith(".bdf"): if value.endswith(".pcf") or value.endswith(".bdf"):
value = convert_bitmap_to_pillow_font( return font_file_schema(
CORE.relative_config_path(cv.file_(value)) {
CONF_TYPE: TYPE_LOCAL_BITMAP,
CONF_PATH: value,
}
) )
return {
CONF_TYPE: TYPE_LOCAL_BITMAP,
CONF_PATH: value,
}
return font_file_schema( 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. # 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: class EFont:
def __init__(self, file, size, codepoints): def __init__(self, file, codepoints):
self.codepoints = codepoints self.codepoints = codepoints
path = file[CONF_PATH] self.font: freetype.Face = FONT_CACHE[file]
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)
class GlyphInfo: 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.data_len = data_len
self.advance = advance
self.offset_x = offset_x self.offset_x = offset_x
self.offset_y = offset_y self.offset_y = offset_y
self.width = width self.width = width
@ -539,13 +494,13 @@ async def to_code(config):
point_set.update(flatten(config[CONF_GLYPHS])) point_set.update(flatten(config[CONF_GLYPHS]))
size = config[CONF_SIZE] size = config[CONF_SIZE]
# Create the codepoint to font file map # 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} point_font_map: dict[str, EFont] = {c: base_font for c in point_set}
# process extras, updating the map and extending the codepoint list # process extras, updating the map and extending the codepoint list
for extra in config[CONF_EXTRAS]: for extra in config[CONF_EXTRAS]:
extra_points = flatten(extra[CONF_GLYPHS]) extra_points = flatten(extra[CONF_GLYPHS])
point_set.update(extra_points) 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}) point_font_map.update({c: extra_font for c in extra_points})
codepoints = list(point_set) codepoints = list(point_set)
@ -553,28 +508,52 @@ async def to_code(config):
glyph_args = {} glyph_args = {}
data = [] data = []
bpp = config[CONF_BPP] bpp = config[CONF_BPP]
if bpp == 1: mode = ft_pixel_mode_grays
mode = "1" scale = 256 // (1 << bpp)
scale = 1
else:
mode = "L"
scale = 256 // (1 << bpp)
# create the data array for all glyphs # create the data array for all glyphs
for codepoint in codepoints: for codepoint in codepoints:
font = point_font_map[codepoint] font = point_font_map[codepoint].font
mask = font.font.getmask(codepoint, mode=mode) if not font.has_fixed_sizes:
offset_x, offset_y = font.font.getoffset(codepoint) font.set_pixel_sizes(size, 0)
width, height = mask.size 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) glyph_data = [0] * ((height * width * bpp + 7) // 8)
src_mode = font.glyph.bitmap.pixel_mode
pos = 0 pos = 0
for y in range(height): for y in range(height):
for x in range(width): 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): for bit_num in range(bpp):
if pixel & (1 << (bpp - bit_num - 1)): if pixel & (1 << (bpp - bit_num - 1)):
glyph_data[pos // 8] |= 0x80 >> (pos % 8) glyph_data[pos // 8] |= 0x80 >> (pos % 8)
pos += 1 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 data += glyph_data
rhs = [HexInt(x) for x in 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)}" f"{str(prog_arr)} + {str(glyph_args[codepoint].data_len)}"
), ),
), ),
("advance", glyph_args[codepoint].advance),
("offset_x", glyph_args[codepoint].offset_x), ("offset_x", glyph_args[codepoint].offset_x),
("offset_y", glyph_args[codepoint].offset_y), ("offset_y", glyph_args[codepoint].offset_y),
("width", glyph_args[codepoint].width), ("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) 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( cg.new_Pvariable(
config[CONF_ID], config[CONF_ID],
glyphs, glyphs,
len(glyph_initializer), len(glyph_initializer),
base_font.ascent, ascender,
base_font.ascent + base_font.descent, font_height,
bpp, bpp,
) )

View File

@ -15,6 +15,7 @@ class Font;
struct GlyphData { struct GlyphData {
const uint8_t *a_char; const uint8_t *a_char;
const uint8_t *data; const uint8_t *data;
int advance;
int offset_x; int offset_x;
int offset_y; int offset_y;
int width; 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); const auto *gd = fe->get_glyph_data(unicode_letter);
if (gd == nullptr) if (gd == nullptr)
return false; return false;
dsc->adv_w = gd->offset_x + gd->width; dsc->adv_w = gd->advance;
dsc->ofs_x = gd->offset_x; dsc->ofs_x = gd->offset_x;
dsc->ofs_y = fe->height - gd->height - gd->offset_y - fe->baseline; dsc->ofs_y = fe->height - gd->height - gd->offset_y - fe->baseline;
dsc->box_w = gd->width; dsc->box_w = gd->width;