diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 6af5be45d4..9317b2ec94 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -1,12 +1,29 @@ import functools +from pathlib import Path +import hashlib +import re + +import requests from esphome import core from esphome.components import display import esphome.config_validation as cv import esphome.codegen as cg -from esphome.const import CONF_FILE, CONF_GLYPHS, CONF_ID, CONF_RAW_DATA_ID, CONF_SIZE +from esphome.const import ( + CONF_FAMILY, + CONF_FILE, + CONF_GLYPHS, + CONF_ID, + CONF_RAW_DATA_ID, + CONF_TYPE, + CONF_SIZE, + CONF_PATH, + CONF_WEIGHT, +) from esphome.core import CORE, HexInt + +DOMAIN = "font" DEPENDENCIES = ["display"] MULTI_CONF = True @@ -71,6 +88,128 @@ def validate_truetype_file(value): return cv.file_(value) +def _compute_gfonts_local_path(value) -> Path: + name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1" + base_dir = Path(CORE.config_dir) / ".esphome" / DOMAIN + h = hashlib.new("sha256") + h.update(name.encode()) + return base_dir / h.hexdigest()[:8] / "font.ttf" + + +TYPE_LOCAL = "local" +TYPE_GFONTS = "gfonts" +LOCAL_SCHEMA = cv.Schema( + { + cv.Required(CONF_PATH): validate_truetype_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 download_gfonts(value): + wght = value[CONF_WEIGHT] + if value[CONF_ITALIC]: + wght = f"1,{wght}" + name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}" + url = f"https://fonts.googleapis.com/css2?family={value[CONF_FAMILY]}:wght@{wght}" + + path = _compute_gfonts_local_path(value) + if path.is_file(): + return value + try: + req = requests.get(url) + req.raise_for_status() + except requests.exceptions.RequestException as e: + raise cv.Invalid( + f"Could not download font for {name}, 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) + try: + req = requests.get(ttf_url) + req.raise_for_status() + except requests.exceptions.RequestException as e: + raise cv.Invalid(f"Could not download ttf file for {name} ({ttf_url}): {e}") + + path.parent.mkdir(exist_ok=True, parents=True) + path.write_bytes(req.content) + return value + + +GFONTS_SCHEMA = cv.All( + { + cv.Required(CONF_FAMILY): cv.string_strict, + cv.Optional(CONF_WEIGHT, default="regular"): cv.Any( + cv.int_, validate_weight_name + ), + cv.Optional(CONF_ITALIC, default=False): cv.boolean, + }, + download_gfonts, +) + + +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) + return FILE_SCHEMA( + { + CONF_TYPE: TYPE_LOCAL, + CONF_PATH: value, + } + ) + + +TYPED_FILE_SCHEMA = cv.typed_schema( + { + TYPE_LOCAL: LOCAL_SCHEMA, + TYPE_GFONTS: GFONTS_SCHEMA, + } +) + + +def _file_schema(value): + if isinstance(value, str): + return validate_file_shorthand(value) + return TYPED_FILE_SCHEMA(value) + + +FILE_SCHEMA = cv.Schema(_file_schema) + + DEFAULT_GLYPHS = ( ' !"%()+=,-.:/0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz°' ) @@ -79,7 +218,7 @@ CONF_RAW_GLYPH_ID = "raw_glyph_id" FONT_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.declare_id(Font), - cv.Required(CONF_FILE): validate_truetype_file, + 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.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), @@ -93,9 +232,13 @@ CONFIG_SCHEMA = cv.All(validate_pillow_installed, FONT_SCHEMA) async def to_code(config): from PIL import ImageFont - path = CORE.relative_config_path(config[CONF_FILE]) + conf = config[CONF_FILE] + if conf[CONF_TYPE] == TYPE_LOCAL: + path = CORE.relative_config_path(conf[CONF_PATH]) + elif conf[CONF_TYPE] == TYPE_GFONTS: + path = _compute_gfonts_local_path(conf) try: - font = ImageFont.truetype(path, config[CONF_SIZE]) + font = ImageFont.truetype(str(path), config[CONF_SIZE]) except Exception as e: raise core.EsphomeError(f"Could not load truetype file {path}: {e}")