import hashlib import logging import functools from pathlib import Path import os import re from packaging import version import requests from esphome import core from esphome import external_files import esphome.config_validation as cv import esphome.codegen as cg from esphome.helpers import ( copy_file_if_changed, cpp_string_escape, ) from esphome.const import ( __version__, CONF_FAMILY, CONF_FILE, CONF_GLYPHS, CONF_ID, CONF_RAW_DATA_ID, CONF_TYPE, CONF_REFRESH, CONF_SIZE, CONF_PATH, CONF_WEIGHT, CONF_URL, ) from esphome.core import ( CORE, HexInt, ) _LOGGER = logging.getLogger(__name__) DOMAIN = "font" DEPENDENCIES = ["display"] MULTI_CONF = True CODEOWNERS = ["@esphome/core", "@clydebarrow"] font_ns = cg.esphome_ns.namespace("font") Font = font_ns.class_("Font") Glyph = font_ns.class_("Glyph") GlyphData = font_ns.struct("GlyphData") CONF_BPP = "bpp" CONF_EXTRAS = "extras" CONF_FONTS = "fonts" def glyph_comparator(x, y): x_ = x.encode("utf-8") y_ = y.encode("utf-8") for c in range(min(len(x_), len(y_))): if x_[c] < y_[c]: return -1 if x_[c] > y_[c]: return 1 if len(x_) < len(y_): return -1 if len(x_) > len(y_): return 1 raise cv.Invalid(f"Found duplicate glyph {x}") def validate_glyphs(value): if isinstance(value, list): value = cv.Schema([cv.string])(value) value = cv.Schema([cv.string])(list(value)) value.sort(key=functools.cmp_to_key(glyph_comparator)) return value font_map = {} def merge_glyphs(config): glyphs = [] glyphs.extend(config[CONF_GLYPHS]) font_list = [(EFont(config[CONF_FILE], config[CONF_SIZE], config[CONF_GLYPHS]))] if extras := config.get(CONF_EXTRAS): extra_fonts = list( map( lambda x: EFont(x[CONF_FILE], config[CONF_SIZE], x[CONF_GLYPHS]), extras ) ) font_list.extend(extra_fonts) for extra in extras: glyphs.extend(extra[CONF_GLYPHS]) validate_glyphs(glyphs) font_map[config[CONF_ID]] = font_list return config def validate_pillow_installed(value): try: import PIL except ImportError as err: raise cv.Invalid( "Please install the pillow python package to use this feature. " '(pip install "pillow==10.2.0")' ) from err if version.parse(PIL.__version__) != version.parse("10.2.0"): raise cv.Invalid( "Please update your pillow installation to 10.2.0. " '(pip install "pillow==10.2.0")' ) return value FONT_EXTENSIONS = (".ttf", ".woff", ".otf") def validate_truetype_file(value): if value.lower().endswith(".zip"): # for Google Fonts downloads raise cv.Invalid( 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.") return cv.file_(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_BITMAP_SCHEMA = cv.Schema( { cv.Required(CONF_PATH): cv.file_, } ) CONF_ITALIC = "italic" FONT_WEIGHTS = { "thin": 100, "extra-light": 200, "light": 300, "regular": 400, "medium": 500, "semi-bold": 600, "bold": 700, "extra-bold": 800, "black": 900, } def validate_weight_name(value): return FONT_WEIGHTS[cv.one_of(*FONT_WEIGHTS, lower=True, space="-")(value)] def _compute_local_font_path(value: dict) -> Path: url = value[CONF_URL] h = hashlib.new("sha256") 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) return base_dir / key def get_font_path(value, type) -> Path: if 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 type == TYPE_WEB: return _compute_local_font_path(value) / "font.ttf" return None def download_content(url: str, path: Path) -> None: if not external_files.has_remote_file_changed(url, path): _LOGGER.debug("Remote file has not changed %s", url) return _LOGGER.debug( "Remote file has changed, downloading from %s to %s", url, path, ) try: req = requests.get( url, timeout=external_files.NETWORK_TIMEOUT, headers={"User-agent": f"ESPHome/{__version__} (https://esphome.io)"}, ) req.raise_for_status() except requests.exceptions.RequestException as e: raise cv.Invalid(f"Could not download from {url}: {e}") path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(req.content) def download_gfont(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) 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) download_content(ttf_url, path) return value def download_web_font(value): url = value[CONF_URL] path = get_font_path(value, TYPE_WEB) download_content(url, path) _LOGGER.debug("download_web_font: path=%s", path) return value EXTERNAL_FONT_SCHEMA = cv.Schema( { cv.Optional(CONF_WEIGHT, default="regular"): cv.Any( cv.int_, validate_weight_name ), cv.Optional(CONF_ITALIC, default=False): cv.boolean, cv.Optional(CONF_REFRESH, default="1d"): cv.All(cv.string, cv.source_refresh), } ) GFONTS_SCHEMA = cv.All( EXTERNAL_FONT_SCHEMA.extend( { cv.Required(CONF_FAMILY): cv.string_strict, } ), download_gfont, ) WEB_FONT_SCHEMA = cv.All( EXTERNAL_FONT_SCHEMA.extend( { cv.Required(CONF_URL): cv.string_strict, } ), download_web_font, ) def validate_file_shorthand(value): value = cv.string_strict(value) if value.startswith("gfonts://"): match = re.match(r"^gfonts://([^@]+)(@.+)?$", value) if match is None: raise cv.Invalid("Could not parse gfonts shorthand syntax, please check it") family = match.group(1) weight = match.group(2) data = { CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: family, } if weight is not None: data[CONF_WEIGHT] = weight[1:] return FILE_SCHEMA(data) if value.startswith("http://") or value.startswith("https://"): return FILE_SCHEMA( { CONF_TYPE: TYPE_WEB, CONF_URL: value, } ) if value.endswith(".pcf") or value.endswith(".bdf"): return FILE_SCHEMA( { CONF_TYPE: TYPE_LOCAL_BITMAP, CONF_PATH: value, } ) return FILE_SCHEMA( { CONF_TYPE: TYPE_LOCAL, CONF_PATH: value, } ) TYPED_FILE_SCHEMA = cv.typed_schema( { TYPE_LOCAL: LOCAL_SCHEMA, TYPE_GFONTS: GFONTS_SCHEMA, TYPE_LOCAL_BITMAP: LOCAL_BITMAP_SCHEMA, TYPE_WEB: WEB_FONT_SCHEMA, } ) def _file_schema(value): if isinstance(value, str): return validate_file_shorthand(value) return TYPED_FILE_SCHEMA(value) FILE_SCHEMA = cv.All(_file_schema) DEFAULT_GLYPHS = ( ' !"%()+=,-.:/?0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz°' ) CONF_RAW_GLYPH_ID = "raw_glyph_id" FONT_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.declare_id(Font), cv.Required(CONF_FILE): FILE_SCHEMA, cv.Optional(CONF_GLYPHS, default=DEFAULT_GLYPHS): validate_glyphs, cv.Optional(CONF_SIZE, default=20): cv.int_range(min=1), cv.Optional(CONF_BPP, default=1): cv.one_of(1, 2, 4, 8), cv.Optional(CONF_EXTRAS): cv.ensure_list( cv.Schema( { cv.Required(CONF_FILE): FILE_SCHEMA, cv.Required(CONF_GLYPHS): validate_glyphs, } ) ), cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), cv.GenerateID(CONF_RAW_GLYPH_ID): cv.declare_id(GlyphData), }, ) CONFIG_SCHEMA = cv.All(validate_pillow_installed, FONT_SCHEMA, merge_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(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(glyph, **kwargs) def getmetrics(self, glyphs): max_height = 0 for glyph in glyphs: mask = self.getmask(glyph, mode="1") _, height = mask.size if height > max_height: max_height = height return (max_height, 0) class EFont: def __init__(self, file, size, glyphs): self.glyphs = glyphs ftype = file[CONF_TYPE] if ftype == TYPE_LOCAL_BITMAP: font = load_bitmap_font(CORE.relative_config_path(file[CONF_PATH])) elif ftype == TYPE_LOCAL: path = CORE.relative_config_path(file[CONF_PATH]) font = load_ttf_font(path, size) elif ftype in (TYPE_GFONTS, TYPE_WEB): path = get_font_path(file, ftype) font = load_ttf_font(path, size) else: raise cv.Invalid(f"Could not load font: unknown type: {ftype}") self.font = font self.ascent, self.descent = font.getmetrics(glyphs) def has_glyph(self, glyph): return glyph in self.glyphs def convert_bitmap_to_pillow_font(filepath): from PIL import ( PcfFontFile, BdfFontFile, ) local_bitmap_font_file = external_files.compute_local_file_dir( DOMAIN, ) / os.path.basename(filepath) copy_file_if_changed(filepath, local_bitmap_font_file) 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_bitmap_font_file) except (SyntaxError, OSError) as err: raise core.EsphomeError( f"Failed to parse as bitmap font: '{filepath}': {err}" ) local_pil_font_file = os.path.splitext(local_bitmap_font_file)[0] + ".pil" return cv.file_(local_pil_font_file) def load_bitmap_font(filepath): from PIL import ImageFont # Convert bpf and pcf files to pillow fonts, first. pil_font_path = convert_bitmap_to_pillow_font(filepath) try: font = ImageFont.load(str(pil_font_path)) except Exception as e: raise core.EsphomeError( f"Failed to load bitmap font file: {pil_font_path} : {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: def __init__(self, data_len, offset_x, offset_y, width, height): self.data_len = data_len self.offset_x = offset_x self.offset_y = offset_y self.width = width self.height = height async def to_code(config): glyph_to_font_map = {} font_list = font_map[config[CONF_ID]] glyphs = [] for font in font_list: glyphs.extend(font.glyphs) for glyph in font.glyphs: glyph_to_font_map[glyph] = font glyphs.sort(key=functools.cmp_to_key(glyph_comparator)) glyph_args = {} data = [] bpp = config[CONF_BPP] if bpp == 1: mode = "1" scale = 1 else: mode = "L" scale = 256 // (1 << bpp) for glyph in glyphs: font = glyph_to_font_map[glyph].font mask = font.getmask(glyph, mode=mode) offset_x, offset_y = font.getoffset(glyph) width, height = mask.size glyph_data = [0] * ((height * width * bpp + 7) // 8) pos = 0 for y in range(height): for x in range(width): pixel = mask.getpixel((x, y)) // 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[glyph] = GlyphInfo(len(data), offset_x, offset_y, width, height) data += glyph_data rhs = [HexInt(x) for x in data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) glyph_initializer = [] for glyph in glyphs: glyph_initializer.append( cg.StructInitializer( GlyphData, ( "a_char", cg.RawExpression(f"(const uint8_t *){cpp_string_escape(glyph)}"), ), ( "data", cg.RawExpression( f"{str(prog_arr)} + {str(glyph_args[glyph].data_len)}" ), ), ("offset_x", glyph_args[glyph].offset_x), ("offset_y", glyph_args[glyph].offset_y), ("width", glyph_args[glyph].width), ("height", glyph_args[glyph].height), ) ) glyphs = cg.static_const_array(config[CONF_RAW_GLYPH_ID], glyph_initializer) cg.new_Pvariable( config[CONF_ID], glyphs, len(glyph_initializer), font_list[0].ascent, font_list[0].ascent + font_list[0].descent, bpp, )