download font from url on build (#5254)

Co-authored-by: guillempages <guillempages@users.noreply.github.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
This commit is contained in:
Landon Rohatensky 2024-03-12 16:07:40 -07:00 committed by GitHub
parent d3a028f7fa
commit 2df9c30446
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 122 additions and 43 deletions

View file

@ -1,13 +1,15 @@
import hashlib
import logging
import functools import functools
from pathlib import Path from pathlib import Path
import hashlib
import os import os
import re import re
from packaging import version from packaging import version
import requests import requests
from esphome import core from esphome import core
from esphome import external_files
import esphome.config_validation as cv import esphome.config_validation as cv
import esphome.codegen as cg import esphome.codegen as cg
from esphome.helpers import ( from esphome.helpers import (
@ -15,21 +17,26 @@ from esphome.helpers import (
cpp_string_escape, cpp_string_escape,
) )
from esphome.const import ( from esphome.const import (
__version__,
CONF_FAMILY, CONF_FAMILY,
CONF_FILE, CONF_FILE,
CONF_GLYPHS, CONF_GLYPHS,
CONF_ID, CONF_ID,
CONF_RAW_DATA_ID, CONF_RAW_DATA_ID,
CONF_TYPE, CONF_TYPE,
CONF_REFRESH,
CONF_SIZE, CONF_SIZE,
CONF_PATH, CONF_PATH,
CONF_WEIGHT, CONF_WEIGHT,
CONF_URL,
) )
from esphome.core import ( from esphome.core import (
CORE, CORE,
HexInt, HexInt,
) )
_LOGGER = logging.getLogger(__name__)
DOMAIN = "font" DOMAIN = "font"
DEPENDENCIES = ["display"] DEPENDENCIES = ["display"]
MULTI_CONF = True MULTI_CONF = True
@ -125,20 +132,10 @@ def validate_truetype_file(value):
return cv.file_(value) return cv.file_(value)
def _compute_local_font_dir(name) -> Path:
h = hashlib.new("sha256")
h.update(name.encode())
return Path(CORE.data_dir) / DOMAIN / h.hexdigest()[:8]
def _compute_gfonts_local_path(value) -> Path:
name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1"
return _compute_local_font_dir(name) / "font.ttf"
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"
LOCAL_SCHEMA = cv.Schema( LOCAL_SCHEMA = cv.Schema(
{ {
cv.Required(CONF_PATH): validate_truetype_file, cv.Required(CONF_PATH): validate_truetype_file,
@ -169,21 +166,64 @@ def validate_weight_name(value):
return FONT_WEIGHTS[cv.one_of(*FONT_WEIGHTS, lower=True, space="-")(value)] return FONT_WEIGHTS[cv.one_of(*FONT_WEIGHTS, lower=True, space="-")(value)]
def download_gfonts(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 = ( 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)
_LOGGER.debug("download_gfont: path=%s", path)
path = _compute_gfonts_local_path(value)
if path.is_file():
return value
try: try:
req = requests.get(url, timeout=30) req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT)
req.raise_for_status() req.raise_for_status()
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
raise cv.Invalid( raise cv.Invalid(
f"Could not download font for {name}, please check the fonts exists " f"Could not download font at {url}, please check the fonts exists "
f"at google fonts ({e})" f"at google fonts ({e})"
) )
match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text) match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text)
@ -194,26 +234,48 @@ def download_gfonts(value):
) )
ttf_url = match.group(1) ttf_url = match.group(1)
try: _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url)
req = requests.get(ttf_url, timeout=30)
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) download_content(ttf_url, path)
path.write_bytes(req.content)
return value return value
GFONTS_SCHEMA = cv.All( 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.Required(CONF_FAMILY): cv.string_strict,
cv.Optional(CONF_WEIGHT, default="regular"): cv.Any( cv.Optional(CONF_WEIGHT, default="regular"): cv.Any(
cv.int_, validate_weight_name cv.int_, validate_weight_name
), ),
cv.Optional(CONF_ITALIC, default=False): cv.boolean, cv.Optional(CONF_ITALIC, default=False): cv.boolean,
}, cv.Optional(CONF_REFRESH, default="1d"): cv.All(cv.string, cv.source_refresh),
download_gfonts, }
)
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,
) )
@ -233,6 +295,14 @@ def validate_file_shorthand(value):
data[CONF_WEIGHT] = weight[1:] data[CONF_WEIGHT] = weight[1:]
return FILE_SCHEMA(data) 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"): if value.endswith(".pcf") or value.endswith(".bdf"):
return FILE_SCHEMA( return FILE_SCHEMA(
{ {
@ -254,6 +324,7 @@ TYPED_FILE_SCHEMA = cv.typed_schema(
TYPE_LOCAL: LOCAL_SCHEMA, TYPE_LOCAL: LOCAL_SCHEMA,
TYPE_GFONTS: GFONTS_SCHEMA, TYPE_GFONTS: GFONTS_SCHEMA,
TYPE_LOCAL_BITMAP: LOCAL_BITMAP_SCHEMA, TYPE_LOCAL_BITMAP: LOCAL_BITMAP_SCHEMA,
TYPE_WEB: WEB_FONT_SCHEMA,
} }
) )
@ -264,7 +335,7 @@ def _file_schema(value):
return TYPED_FILE_SCHEMA(value) return TYPED_FILE_SCHEMA(value)
FILE_SCHEMA = cv.Schema(_file_schema) FILE_SCHEMA = cv.All(_file_schema)
DEFAULT_GLYPHS = ( DEFAULT_GLYPHS = (
' !"%()+=,-.:/?0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz°' ' !"%()+=,-.:/?0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz°'
@ -288,7 +359,7 @@ FONT_SCHEMA = cv.Schema(
), ),
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
cv.GenerateID(CONF_RAW_GLYPH_ID): cv.declare_id(GlyphData), cv.GenerateID(CONF_RAW_GLYPH_ID): cv.declare_id(GlyphData),
} },
) )
CONFIG_SCHEMA = cv.All(validate_pillow_installed, FONT_SCHEMA, merge_glyphs) CONFIG_SCHEMA = cv.All(validate_pillow_installed, FONT_SCHEMA, merge_glyphs)
@ -343,8 +414,8 @@ class EFont:
elif ftype == TYPE_LOCAL: elif ftype == TYPE_LOCAL:
path = CORE.relative_config_path(file[CONF_PATH]) path = CORE.relative_config_path(file[CONF_PATH])
font = load_ttf_font(path, size) font = load_ttf_font(path, size)
elif ftype == TYPE_GFONTS: elif ftype in (TYPE_GFONTS, TYPE_WEB):
path = _compute_gfonts_local_path(file) path = get_font_path(file, ftype)
font = load_ttf_font(path, size) font = load_ttf_font(path, size)
else: else:
raise cv.Invalid(f"Could not load font: unknown type: {ftype}") raise cv.Invalid(f"Could not load font: unknown type: {ftype}")
@ -361,9 +432,9 @@ def convert_bitmap_to_pillow_font(filepath):
BdfFontFile, BdfFontFile,
) )
local_bitmap_font_file = _compute_local_font_dir(filepath) / os.path.basename( local_bitmap_font_file = external_files.compute_local_file_dir(
filepath DOMAIN,
) ) / os.path.basename(filepath)
copy_file_if_changed(filepath, local_bitmap_font_file) copy_file_if_changed(filepath, local_bitmap_font_file)

View file

@ -33,7 +33,9 @@ def has_remote_file_changed(url, local_file_path):
IF_MODIFIED_SINCE: local_modification_time_str, IF_MODIFIED_SINCE: local_modification_time_str,
CACHE_CONTROL: CACHE_CONTROL_MAX_AGE + "3600", CACHE_CONTROL: CACHE_CONTROL_MAX_AGE + "3600",
} }
response = requests.head(url, headers=headers, timeout=NETWORK_TIMEOUT) response = requests.head(
url, headers=headers, timeout=NETWORK_TIMEOUT, allow_redirects=True
)
_LOGGER.debug( _LOGGER.debug(
"has_remote_file_changed: File %s, Local modified %s, response code %d", "has_remote_file_changed: File %s, Local modified %s, response code %d",

View file

@ -6,6 +6,17 @@ font:
extras: extras:
- file: "gfonts://Roboto" - file: "gfonts://Roboto"
glyphs: ["\u00C4", "\u00C5", "\U000000C7"] glyphs: ["\u00C4", "\u00C5", "\U000000C7"]
- file: "gfonts://Roboto"
id: roboto_web
size: 20
- file: "https://github.com/IdreesInc/Monocraft/releases/download/v3.0/Monocraft.ttf"
id: monocraft
size: 20
- file:
type: web
url: "https://github.com/IdreesInc/Monocraft/releases/download/v3.0/Monocraft.ttf"
id: monocraft2
size: 24
spi: spi:
clk_pin: 14 clk_pin: 14

View file

@ -812,11 +812,6 @@ image:
file: mdi:alert-outline file: mdi:alert-outline
type: BINARY type: BINARY
font:
- file: "gfonts://Roboto"
id: roboto
size: 20
graph: graph:
- id: my_graph - id: my_graph
sensor: ha_hello_world_temperature sensor: ha_hello_world_temperature