mirror of
https://github.com/esphome/esphome.git
synced 2024-11-23 23:48:11 +01:00
[font] Add support for "glyphsets" (#7429)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
This commit is contained in:
parent
8b7e061f3a
commit
a043022444
10 changed files with 7771 additions and 129 deletions
|
@ -40,25 +40,6 @@ RUN \
|
||||||
libcairo2=1.16.0-7 \
|
libcairo2=1.16.0-7 \
|
||||||
libmagic1=1:5.44-3 \
|
libmagic1=1:5.44-3 \
|
||||||
patch=2.7.6-7 \
|
patch=2.7.6-7 \
|
||||||
&& ( \
|
|
||||||
( \
|
|
||||||
[ "$TARGETARCH$TARGETVARIANT" = "armv7" ] && \
|
|
||||||
apt-get install -y --no-install-recommends \
|
|
||||||
build-essential=12.9 \
|
|
||||||
python3-dev=3.11.2-1+b1 \
|
|
||||||
zlib1g-dev=1:1.2.13.dfsg-1 \
|
|
||||||
libjpeg-dev=1:2.1.5-2 \
|
|
||||||
libfreetype-dev=2.12.1+dfsg-5+deb12u3 \
|
|
||||||
libssl-dev=3.0.14-1~deb12u2 \
|
|
||||||
libffi-dev=3.4.4-1 \
|
|
||||||
libopenjp2-7=2.5.0-2 \
|
|
||||||
libtiff6=4.5.0-6+deb12u1 \
|
|
||||||
cargo=0.66.0+ds1-1 \
|
|
||||||
pkg-config=1.8.1-1 \
|
|
||||||
gcc-arm-linux-gnueabihf=4:12.2.0-3 \
|
|
||||||
) \
|
|
||||||
|| [ "$TARGETARCH$TARGETVARIANT" != "armv7" ] \
|
|
||||||
) \
|
|
||||||
&& rm -rf \
|
&& rm -rf \
|
||||||
/tmp/* \
|
/tmp/* \
|
||||||
/var/{cache,log}/* \
|
/var/{cache,log}/* \
|
||||||
|
@ -97,15 +78,48 @@ RUN \
|
||||||
# tmpfs is for https://github.com/rust-lang/cargo/issues/8719
|
# tmpfs is for https://github.com/rust-lang/cargo/issues/8719
|
||||||
|
|
||||||
COPY requirements.txt requirements_optional.txt /
|
COPY requirements.txt requirements_optional.txt /
|
||||||
RUN --mount=type=tmpfs,target=/root/.cargo if [ "$TARGETARCH$TARGETVARIANT" = "armv7" ]; then \
|
RUN --mount=type=tmpfs,target=/root/.cargo <<END-OF-RUN
|
||||||
curl -L https://www.piwheels.org/cp311/cryptography-43.0.0-cp37-abi3-linux_armv7l.whl -o /tmp/cryptography-43.0.0-cp37-abi3-linux_armv7l.whl \
|
# Fail on any non-zero status
|
||||||
&& pip3 install --break-system-packages --no-cache-dir /tmp/cryptography-43.0.0-cp37-abi3-linux_armv7l.whl \
|
set -e
|
||||||
&& rm /tmp/cryptography-43.0.0-cp37-abi3-linux_armv7l.whl \
|
|
||||||
&& export PIP_EXTRA_INDEX_URL="https://www.piwheels.org/simple"; \
|
if [ "$TARGETARCH$TARGETVARIANT" = "armv7" ]
|
||||||
fi; \
|
then
|
||||||
CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse CARGO_HOME=/root/.cargo \
|
curl -L https://www.piwheels.org/cp311/cryptography-43.0.0-cp37-abi3-linux_armv7l.whl -o /tmp/cryptography-43.0.0-cp37-abi3-linux_armv7l.whl
|
||||||
pip3 install \
|
pip3 install --break-system-packages --no-cache-dir /tmp/cryptography-43.0.0-cp37-abi3-linux_armv7l.whl
|
||||||
--break-system-packages --no-cache-dir -r /requirements.txt -r /requirements_optional.txt
|
rm /tmp/cryptography-43.0.0-cp37-abi3-linux_armv7l.whl
|
||||||
|
export PIP_EXTRA_INDEX_URL="https://www.piwheels.org/simple";
|
||||||
|
fi
|
||||||
|
|
||||||
|
# install build tools in case wheels are not available
|
||||||
|
BUILD_DEPS="
|
||||||
|
build-essential=12.9
|
||||||
|
python3-dev=3.11.2-1+b1
|
||||||
|
zlib1g-dev=1:1.2.13.dfsg-1
|
||||||
|
libjpeg-dev=1:2.1.5-2
|
||||||
|
libfreetype-dev=2.12.1+dfsg-5+deb12u3
|
||||||
|
libssl-dev=3.0.14-1~deb12u2
|
||||||
|
libffi-dev=3.4.4-1
|
||||||
|
libopenjp2-7=2.5.0-2
|
||||||
|
libtiff6=4.5.0-6+deb12u1
|
||||||
|
cargo=0.66.0+ds1-1
|
||||||
|
pkg-config=1.8.1-1
|
||||||
|
"
|
||||||
|
if [ "$TARGETARCH$TARGETVARIANT" = "arm64" ] || [ "$TARGETARCH$TARGETVARIANT" = "armv7" ]
|
||||||
|
then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends $BUILD_DEPS
|
||||||
|
fi
|
||||||
|
|
||||||
|
CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse CARGO_HOME=/root/.cargo
|
||||||
|
pip3 install --break-system-packages --no-cache-dir -r /requirements.txt -r /requirements_optional.txt
|
||||||
|
|
||||||
|
if [ "$TARGETARCH$TARGETVARIANT" = "arm64" ] || [ "$TARGETARCH$TARGETVARIANT" = "armv7" ]
|
||||||
|
then
|
||||||
|
apt-get remove -y --purge --auto-remove $BUILD_DEPS
|
||||||
|
rm -rf /tmp/* /var/{cache,log}/* /var/lib/apt/lists/*
|
||||||
|
fi
|
||||||
|
END-OF-RUN
|
||||||
|
|
||||||
|
|
||||||
COPY script/platformio_install_deps.py platformio.ini /
|
COPY script/platformio_install_deps.py platformio.ini /
|
||||||
RUN /platformio_install_deps.py /platformio.ini --libraries
|
RUN /platformio_install_deps.py /platformio.ini --libraries
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
from collections.abc import Iterable
|
||||||
import functools
|
import functools
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
@ -5,6 +6,8 @@ import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
import freetype
|
||||||
|
import glyphsets
|
||||||
from packaging import version
|
from packaging import version
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
@ -43,6 +46,18 @@ GlyphData = font_ns.struct("GlyphData")
|
||||||
CONF_BPP = "bpp"
|
CONF_BPP = "bpp"
|
||||||
CONF_EXTRAS = "extras"
|
CONF_EXTRAS = "extras"
|
||||||
CONF_FONTS = "fonts"
|
CONF_FONTS = "fonts"
|
||||||
|
CONF_GLYPHSETS = "glyphsets"
|
||||||
|
CONF_IGNORE_MISSING_GLYPHS = "ignore_missing_glyphs"
|
||||||
|
|
||||||
|
|
||||||
|
# Cache loaded freetype fonts
|
||||||
|
class FontCache(dict):
|
||||||
|
def __missing__(self, key):
|
||||||
|
res = self[key] = freetype.Face(key)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
FONT_CACHE = FontCache()
|
||||||
|
|
||||||
|
|
||||||
def glyph_comparator(x, y):
|
def glyph_comparator(x, y):
|
||||||
|
@ -59,36 +74,106 @@ def glyph_comparator(x, y):
|
||||||
return -1
|
return -1
|
||||||
if len(x_) > len(y_):
|
if len(x_) > len(y_):
|
||||||
return 1
|
return 1
|
||||||
raise cv.Invalid(f"Found duplicate glyph {x}")
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def validate_glyphs(value):
|
def flatten(lists) -> list:
|
||||||
if isinstance(value, list):
|
"""
|
||||||
value = cv.Schema([cv.string])(value)
|
Given a list of lists, flatten it to a single list of all elements of all lists.
|
||||||
value = cv.Schema([cv.string])(list(value))
|
This wraps itertools.chain.from_iterable to make it more readable, and return a list
|
||||||
|
rather than a single use iterable.
|
||||||
|
"""
|
||||||
|
from itertools import chain
|
||||||
|
|
||||||
value.sort(key=functools.cmp_to_key(glyph_comparator))
|
return list(chain.from_iterable(lists))
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
font_map = {}
|
def check_missing_glyphs(file, codepoints: Iterable, warning: bool = False):
|
||||||
|
"""
|
||||||
|
Check that the given font file actually contains the requested glyphs
|
||||||
|
:param file: A Truetype font file
|
||||||
|
:param codepoints: A list of codepoints to check
|
||||||
|
:param warning: If true, log a warning instead of raising an exception
|
||||||
|
"""
|
||||||
|
|
||||||
|
font = FONT_CACHE[file]
|
||||||
def merge_glyphs(config):
|
missing = [chr(x) for x in codepoints if font.get_char_index(x) == 0]
|
||||||
glyphs = []
|
if missing:
|
||||||
glyphs.extend(config[CONF_GLYPHS])
|
# Only list up to 10 missing glyphs
|
||||||
font_list = [(EFont(config[CONF_FILE], config[CONF_SIZE], config[CONF_GLYPHS]))]
|
missing.sort(key=functools.cmp_to_key(glyph_comparator))
|
||||||
if extras := config.get(CONF_EXTRAS):
|
count = len(missing)
|
||||||
extra_fonts = list(
|
missing = missing[:10]
|
||||||
map(
|
missing_str = "\n ".join(
|
||||||
lambda x: EFont(x[CONF_FILE], config[CONF_SIZE], x[CONF_GLYPHS]), extras
|
f"{x} ({x.encode('unicode_escape')})" for x in missing
|
||||||
)
|
)
|
||||||
|
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}"
|
||||||
|
if warning:
|
||||||
|
_LOGGER.warning(message)
|
||||||
|
else:
|
||||||
|
raise cv.Invalid(message)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_glyphs(config):
|
||||||
|
"""
|
||||||
|
Check for duplicate codepoints, then check that all requested codepoints actually
|
||||||
|
have glyphs defined in the appropriate font file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Collect all glyph codepoints and flatten to a list of chars
|
||||||
|
glyphspoints = flatten(
|
||||||
|
[x[CONF_GLYPHS] for x in config[CONF_EXTRAS]] + config[CONF_GLYPHS]
|
||||||
)
|
)
|
||||||
font_list.extend(extra_fonts)
|
# Convert a list of strings to a list of chars (one char strings)
|
||||||
for extra in extras:
|
glyphspoints = flatten([list(x) for x in glyphspoints])
|
||||||
glyphs.extend(extra[CONF_GLYPHS])
|
if len(set(glyphspoints)) != len(glyphspoints):
|
||||||
validate_glyphs(glyphs)
|
duplicates = {x for x in glyphspoints if glyphspoints.count(x) > 1}
|
||||||
font_map[config[CONF_ID]] = font_list
|
dup_str = ", ".join(f"{x} ({x.encode('unicode_escape')})" for x in duplicates)
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"Found duplicate glyph{'s' if len(duplicates) != 1 else ''}: {dup_str}"
|
||||||
|
)
|
||||||
|
# convert to codepoints
|
||||||
|
glyphspoints = {ord(x) for x in glyphspoints}
|
||||||
|
fileconf = config[CONF_FILE]
|
||||||
|
setpoints = set(
|
||||||
|
flatten([glyphsets.unicodes_per_glyphset(x) for x in config[CONF_GLYPHSETS]])
|
||||||
|
)
|
||||||
|
# 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:
|
||||||
|
# for TT fonts, check that glyphs are actually present
|
||||||
|
# Check extras against their own font, exclude from parent font codepoints
|
||||||
|
for extra in config[CONF_EXTRAS]:
|
||||||
|
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)
|
||||||
|
|
||||||
|
# A named glyph that can't be provided is an error
|
||||||
|
check_missing_glyphs(fileconf[CONF_PATH], 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)
|
||||||
|
|
||||||
|
# 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 fileconf[CONF_TYPE] == TYPE_LOCAL_BITMAP:
|
||||||
|
config[CONF_GLYPHS] = [DEFAULT_GLYPHS]
|
||||||
|
else:
|
||||||
|
# set a default glyphset, intersected with what the font actually offers
|
||||||
|
font = FONT_CACHE[fileconf[CONF_PATH]]
|
||||||
|
config[CONF_GLYPHS] = [
|
||||||
|
chr(x)
|
||||||
|
for x in glyphsets.unicodes_per_glyphset(DEFAULT_GLYPHSET)
|
||||||
|
if font.get_char_index(x) != 0
|
||||||
|
]
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@ -120,7 +205,7 @@ def validate_truetype_file(value):
|
||||||
)
|
)
|
||||||
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 {FONT_EXTENSIONS} files are supported.")
|
||||||
return cv.file_(value)
|
return CORE.relative_config_path(cv.file_(value))
|
||||||
|
|
||||||
|
|
||||||
TYPE_LOCAL = "local"
|
TYPE_LOCAL = "local"
|
||||||
|
@ -139,6 +224,10 @@ LOCAL_BITMAP_SCHEMA = cv.Schema(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
FULLPATH_SCHEMA = cv.maybe_simple_value(
|
||||||
|
{cv.Required(CONF_PATH): cv.string}, key=CONF_PATH
|
||||||
|
)
|
||||||
|
|
||||||
CONF_ITALIC = "italic"
|
CONF_ITALIC = "italic"
|
||||||
FONT_WEIGHTS = {
|
FONT_WEIGHTS = {
|
||||||
"thin": 100,
|
"thin": 100,
|
||||||
|
@ -167,13 +256,13 @@ def _compute_local_font_path(value: dict) -> Path:
|
||||||
return base_dir / key
|
return base_dir / key
|
||||||
|
|
||||||
|
|
||||||
def get_font_path(value, type) -> Path:
|
def get_font_path(value, font_type) -> Path:
|
||||||
if type == TYPE_GFONTS:
|
if font_type == TYPE_GFONTS:
|
||||||
name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1"
|
name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1"
|
||||||
return external_files.compute_local_file_dir(DOMAIN) / f"{name}.ttf"
|
return external_files.compute_local_file_dir(DOMAIN) / f"{name}.ttf"
|
||||||
if type == TYPE_WEB:
|
if font_type == TYPE_WEB:
|
||||||
return _compute_local_font_path(value) / "font.ttf"
|
return _compute_local_font_path(value) / "font.ttf"
|
||||||
return None
|
assert False
|
||||||
|
|
||||||
|
|
||||||
def download_gfont(value):
|
def download_gfont(value):
|
||||||
|
@ -203,7 +292,7 @@ def download_gfont(value):
|
||||||
_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 value
|
return FULLPATH_SCHEMA(path)
|
||||||
|
|
||||||
|
|
||||||
def download_web_font(value):
|
def download_web_font(value):
|
||||||
|
@ -212,7 +301,7 @@ def download_web_font(value):
|
||||||
|
|
||||||
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 value
|
return FULLPATH_SCHEMA(path)
|
||||||
|
|
||||||
|
|
||||||
EXTERNAL_FONT_SCHEMA = cv.Schema(
|
EXTERNAL_FONT_SCHEMA = cv.Schema(
|
||||||
|
@ -225,7 +314,6 @@ EXTERNAL_FONT_SCHEMA = cv.Schema(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
GFONTS_SCHEMA = cv.All(
|
GFONTS_SCHEMA = cv.All(
|
||||||
EXTERNAL_FONT_SCHEMA.extend(
|
EXTERNAL_FONT_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
|
@ -259,10 +347,10 @@ def validate_file_shorthand(value):
|
||||||
}
|
}
|
||||||
if weight is not None:
|
if weight is not None:
|
||||||
data[CONF_WEIGHT] = weight[1:]
|
data[CONF_WEIGHT] = weight[1:]
|
||||||
return FILE_SCHEMA(data)
|
return font_file_schema(data)
|
||||||
|
|
||||||
if value.startswith("http://") or value.startswith("https://"):
|
if value.startswith("http://") or value.startswith("https://"):
|
||||||
return FILE_SCHEMA(
|
return font_file_schema(
|
||||||
{
|
{
|
||||||
CONF_TYPE: TYPE_WEB,
|
CONF_TYPE: TYPE_WEB,
|
||||||
CONF_URL: value,
|
CONF_URL: value,
|
||||||
|
@ -270,14 +358,15 @@ def validate_file_shorthand(value):
|
||||||
)
|
)
|
||||||
|
|
||||||
if value.endswith(".pcf") or value.endswith(".bdf"):
|
if value.endswith(".pcf") or value.endswith(".bdf"):
|
||||||
return FILE_SCHEMA(
|
value = convert_bitmap_to_pillow_font(
|
||||||
{
|
CORE.relative_config_path(cv.file_(value))
|
||||||
|
)
|
||||||
|
return {
|
||||||
CONF_TYPE: TYPE_LOCAL_BITMAP,
|
CONF_TYPE: TYPE_LOCAL_BITMAP,
|
||||||
CONF_PATH: value,
|
CONF_PATH: value,
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
return FILE_SCHEMA(
|
return font_file_schema(
|
||||||
{
|
{
|
||||||
CONF_TYPE: TYPE_LOCAL,
|
CONF_TYPE: TYPE_LOCAL,
|
||||||
CONF_PATH: value,
|
CONF_PATH: value,
|
||||||
|
@ -295,31 +384,35 @@ TYPED_FILE_SCHEMA = cv.typed_schema(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _file_schema(value):
|
def font_file_schema(value):
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return validate_file_shorthand(value)
|
return validate_file_shorthand(value)
|
||||||
return TYPED_FILE_SCHEMA(value)
|
return TYPED_FILE_SCHEMA(value)
|
||||||
|
|
||||||
|
|
||||||
FILE_SCHEMA = cv.All(_file_schema)
|
# Default if no glyphs or glyphsets are provided
|
||||||
|
DEFAULT_GLYPHSET = "GF_Latin_Kernel"
|
||||||
|
# default for bitmap fonts
|
||||||
|
DEFAULT_GLYPHS = ' !"%()+=,-.:/?0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz<C2><B0>'
|
||||||
|
|
||||||
DEFAULT_GLYPHS = (
|
|
||||||
' !"%()+=,-.:/?0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz°'
|
|
||||||
)
|
|
||||||
CONF_RAW_GLYPH_ID = "raw_glyph_id"
|
CONF_RAW_GLYPH_ID = "raw_glyph_id"
|
||||||
|
|
||||||
FONT_SCHEMA = cv.Schema(
|
FONT_SCHEMA = cv.Schema(
|
||||||
{
|
{
|
||||||
cv.Required(CONF_ID): cv.declare_id(Font),
|
cv.Required(CONF_ID): cv.declare_id(Font),
|
||||||
cv.Required(CONF_FILE): FILE_SCHEMA,
|
cv.Required(CONF_FILE): font_file_schema,
|
||||||
cv.Optional(CONF_GLYPHS, default=DEFAULT_GLYPHS): validate_glyphs,
|
cv.Optional(CONF_GLYPHS, default=[]): cv.ensure_list(cv.string_strict),
|
||||||
|
cv.Optional(CONF_GLYPHSETS, default=[]): cv.ensure_list(
|
||||||
|
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, default=20): cv.int_range(min=1),
|
||||||
cv.Optional(CONF_BPP, default=1): cv.one_of(1, 2, 4, 8),
|
cv.Optional(CONF_BPP, default=1): cv.one_of(1, 2, 4, 8),
|
||||||
cv.Optional(CONF_EXTRAS): cv.ensure_list(
|
cv.Optional(CONF_EXTRAS, default=[]): cv.ensure_list(
|
||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
cv.Required(CONF_FILE): FILE_SCHEMA,
|
cv.Required(CONF_FILE): font_file_schema,
|
||||||
cv.Required(CONF_GLYPHS): validate_glyphs,
|
cv.Required(CONF_GLYPHS): cv.ensure_list(cv.string_strict),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
@ -328,7 +421,7 @@ FONT_SCHEMA = cv.Schema(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.All(validate_pillow_installed, FONT_SCHEMA, merge_glyphs)
|
CONFIG_SCHEMA = cv.All(validate_pillow_installed, FONT_SCHEMA, validate_glyphs)
|
||||||
|
|
||||||
|
|
||||||
# PIL doesn't provide a consistent interface for both TrueType and bitmap
|
# PIL doesn't provide a consistent interface for both TrueType and bitmap
|
||||||
|
@ -367,28 +460,20 @@ class BitmapFontWrapper:
|
||||||
mask = self.getmask(glyph, mode="1")
|
mask = self.getmask(glyph, mode="1")
|
||||||
_, height = mask.size
|
_, height = mask.size
|
||||||
max_height = max(max_height, height)
|
max_height = max(max_height, height)
|
||||||
return (max_height, 0)
|
return max_height, 0
|
||||||
|
|
||||||
|
|
||||||
class EFont:
|
class EFont:
|
||||||
def __init__(self, file, size, glyphs):
|
def __init__(self, file, size, codepoints):
|
||||||
self.glyphs = glyphs
|
self.codepoints = codepoints
|
||||||
|
path = file[CONF_PATH]
|
||||||
|
self.name = Path(path).name
|
||||||
ftype = file[CONF_TYPE]
|
ftype = file[CONF_TYPE]
|
||||||
if ftype == TYPE_LOCAL_BITMAP:
|
if ftype == TYPE_LOCAL_BITMAP:
|
||||||
font = load_bitmap_font(CORE.relative_config_path(file[CONF_PATH]))
|
self.font = load_bitmap_font(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:
|
else:
|
||||||
raise cv.Invalid(f"Could not load font: unknown type: {ftype}")
|
self.font = load_ttf_font(path, size)
|
||||||
self.font = font
|
self.ascent, self.descent = self.font.getmetrics(codepoints)
|
||||||
self.ascent, self.descent = font.getmetrics(glyphs)
|
|
||||||
|
|
||||||
def has_glyph(self, glyph):
|
|
||||||
return glyph in self.glyphs
|
|
||||||
|
|
||||||
|
|
||||||
def convert_bitmap_to_pillow_font(filepath):
|
def convert_bitmap_to_pillow_font(filepath):
|
||||||
|
@ -400,6 +485,7 @@ def convert_bitmap_to_pillow_font(filepath):
|
||||||
|
|
||||||
copy_file_if_changed(filepath, local_bitmap_font_file)
|
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:
|
with open(local_bitmap_font_file, "rb") as fp:
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
|
@ -409,28 +495,22 @@ def convert_bitmap_to_pillow_font(filepath):
|
||||||
p = BdfFontFile.BdfFontFile(fp)
|
p = BdfFontFile.BdfFontFile(fp)
|
||||||
|
|
||||||
# Convert to pillow-formatted fonts, which have a .pil and .pbm extension.
|
# Convert to pillow-formatted fonts, which have a .pil and .pbm extension.
|
||||||
p.save(local_bitmap_font_file)
|
p.save(local_pil_font_file)
|
||||||
except (SyntaxError, OSError) as err:
|
except (SyntaxError, OSError) as err:
|
||||||
raise core.EsphomeError(
|
raise core.EsphomeError(
|
||||||
f"Failed to parse as bitmap font: '{filepath}': {err}"
|
f"Failed to parse as bitmap font: '{filepath}': {err}"
|
||||||
)
|
)
|
||||||
|
|
||||||
local_pil_font_file = os.path.splitext(local_bitmap_font_file)[0] + ".pil"
|
return str(local_pil_font_file)
|
||||||
return cv.file_(local_pil_font_file)
|
|
||||||
|
|
||||||
|
|
||||||
def load_bitmap_font(filepath):
|
def load_bitmap_font(filepath):
|
||||||
from PIL import ImageFont
|
from PIL import ImageFont
|
||||||
|
|
||||||
# Convert bpf and pcf files to pillow fonts, first.
|
|
||||||
pil_font_path = convert_bitmap_to_pillow_font(filepath)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
font = ImageFont.load(str(pil_font_path))
|
font = ImageFont.load(str(filepath))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise core.EsphomeError(
|
raise core.EsphomeError(f"Failed to load bitmap font file: {filepath}: {e}")
|
||||||
f"Failed to load bitmap font file: {pil_font_path} : {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return BitmapFontWrapper(font)
|
return BitmapFontWrapper(font)
|
||||||
|
|
||||||
|
@ -441,7 +521,7 @@ def load_ttf_font(path, size):
|
||||||
try:
|
try:
|
||||||
font = ImageFont.truetype(str(path), size)
|
font = ImageFont.truetype(str(path), size)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise core.EsphomeError(f"Could not load truetype file {path}: {e}")
|
raise core.EsphomeError(f"Could not load TrueType file {path}: {e}")
|
||||||
|
|
||||||
return TrueTypeFontWrapper(font)
|
return TrueTypeFontWrapper(font)
|
||||||
|
|
||||||
|
@ -456,14 +536,35 @@ class GlyphInfo:
|
||||||
|
|
||||||
|
|
||||||
async def to_code(config):
|
async def to_code(config):
|
||||||
glyph_to_font_map = {}
|
"""
|
||||||
font_list = font_map[config[CONF_ID]]
|
Collect all glyph codepoints, construct a map from a codepoint to a font file.
|
||||||
glyphs = []
|
Codepoints are either explicit (glyphs key in top level or extras) or part of a glyphset.
|
||||||
for font in font_list:
|
Codepoints listed in extras use the extra font and override codepoints from glyphsets.
|
||||||
glyphs.extend(font.glyphs)
|
Achieve this by processing the base codepoints first, then the extras
|
||||||
for glyph in font.glyphs:
|
"""
|
||||||
glyph_to_font_map[glyph] = font
|
|
||||||
glyphs.sort(key=functools.cmp_to_key(glyph_comparator))
|
# get the codepoints from glyphsets and flatten to a set of chrs.
|
||||||
|
point_set: set[str] = {
|
||||||
|
chr(x)
|
||||||
|
for x in flatten(
|
||||||
|
[glyphsets.unicodes_per_glyphset(x) for x in config[CONF_GLYPHSETS]]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
# 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}
|
||||||
|
# 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)
|
||||||
|
point_font_map.update({c: extra_font for c in extra_points})
|
||||||
|
|
||||||
|
codepoints = list(point_set)
|
||||||
|
codepoints.sort(key=functools.cmp_to_key(glyph_comparator))
|
||||||
glyph_args = {}
|
glyph_args = {}
|
||||||
data = []
|
data = []
|
||||||
bpp = config[CONF_BPP]
|
bpp = config[CONF_BPP]
|
||||||
|
@ -473,10 +574,11 @@ async def to_code(config):
|
||||||
else:
|
else:
|
||||||
mode = "L"
|
mode = "L"
|
||||||
scale = 256 // (1 << bpp)
|
scale = 256 // (1 << bpp)
|
||||||
for glyph in glyphs:
|
# create the data array for all glyphs
|
||||||
font = glyph_to_font_map[glyph].font
|
for codepoint in codepoints:
|
||||||
mask = font.getmask(glyph, mode=mode)
|
font = point_font_map[codepoint]
|
||||||
offset_x, offset_y = font.getoffset(glyph)
|
mask = font.font.getmask(codepoint, mode=mode)
|
||||||
|
offset_x, offset_y = font.font.getoffset(codepoint)
|
||||||
width, height = mask.size
|
width, height = mask.size
|
||||||
glyph_data = [0] * ((height * width * bpp + 7) // 8)
|
glyph_data = [0] * ((height * width * bpp + 7) // 8)
|
||||||
pos = 0
|
pos = 0
|
||||||
|
@ -487,31 +589,34 @@ async def to_code(config):
|
||||||
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[glyph] = GlyphInfo(len(data), offset_x, offset_y, width, height)
|
glyph_args[codepoint] = GlyphInfo(len(data), offset_x, offset_y, width, height)
|
||||||
data += glyph_data
|
data += glyph_data
|
||||||
|
|
||||||
rhs = [HexInt(x) for x in data]
|
rhs = [HexInt(x) for x in data]
|
||||||
prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
|
prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
|
||||||
|
|
||||||
|
# Create the glyph table that points to data in the above array.
|
||||||
glyph_initializer = []
|
glyph_initializer = []
|
||||||
for glyph in glyphs:
|
for codepoint in codepoints:
|
||||||
glyph_initializer.append(
|
glyph_initializer.append(
|
||||||
cg.StructInitializer(
|
cg.StructInitializer(
|
||||||
GlyphData,
|
GlyphData,
|
||||||
(
|
(
|
||||||
"a_char",
|
"a_char",
|
||||||
cg.RawExpression(f"(const uint8_t *){cpp_string_escape(glyph)}"),
|
cg.RawExpression(
|
||||||
|
f"(const uint8_t *){cpp_string_escape(codepoint)}"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"data",
|
"data",
|
||||||
cg.RawExpression(
|
cg.RawExpression(
|
||||||
f"{str(prog_arr)} + {str(glyph_args[glyph].data_len)}"
|
f"{str(prog_arr)} + {str(glyph_args[codepoint].data_len)}"
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("offset_x", glyph_args[glyph].offset_x),
|
("offset_x", glyph_args[codepoint].offset_x),
|
||||||
("offset_y", glyph_args[glyph].offset_y),
|
("offset_y", glyph_args[codepoint].offset_y),
|
||||||
("width", glyph_args[glyph].width),
|
("width", glyph_args[codepoint].width),
|
||||||
("height", glyph_args[glyph].height),
|
("height", glyph_args[codepoint].height),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -521,7 +626,7 @@ async def to_code(config):
|
||||||
config[CONF_ID],
|
config[CONF_ID],
|
||||||
glyphs,
|
glyphs,
|
||||||
len(glyph_initializer),
|
len(glyph_initializer),
|
||||||
font_list[0].ascent,
|
base_font.ascent,
|
||||||
font_list[0].ascent + font_list[0].descent,
|
base_font.ascent + base_font.descent,
|
||||||
bpp,
|
bpp,
|
||||||
)
|
)
|
||||||
|
|
|
@ -17,6 +17,9 @@ aioesphomeapi==24.6.2
|
||||||
zeroconf==0.132.2
|
zeroconf==0.132.2
|
||||||
puremagic==1.27
|
puremagic==1.27
|
||||||
ruamel.yaml==0.18.6 # dashboard_import
|
ruamel.yaml==0.18.6 # dashboard_import
|
||||||
|
glyphsets==1.0.0
|
||||||
|
pillow==10.4.0
|
||||||
|
freetype-py==2.5.1
|
||||||
|
|
||||||
# esp-idf requires this, but doesn't bundle it by default
|
# esp-idf requires this, but doesn't bundle it by default
|
||||||
# https://github.com/espressif/esp-idf/blob/220590d599e134d7a5e7f1e683cc4550349ffbf8/requirements.txt#L24
|
# https://github.com/espressif/esp-idf/blob/220590d599e134d7a5e7f1e683cc4550349ffbf8/requirements.txt#L24
|
||||||
|
|
|
@ -1,2 +1 @@
|
||||||
pillow==10.4.0
|
|
||||||
cairosvg==2.7.1
|
cairosvg==2.7.1
|
||||||
|
|
|
@ -58,7 +58,7 @@ file_types = (
|
||||||
)
|
)
|
||||||
cpp_include = ("*.h", "*.c", "*.cpp", "*.tcc")
|
cpp_include = ("*.h", "*.c", "*.cpp", "*.tcc")
|
||||||
py_include = ("*.py",)
|
py_include = ("*.py",)
|
||||||
ignore_types = (".ico", ".png", ".woff", ".woff2", "", ".ttf", ".otf")
|
ignore_types = (".ico", ".png", ".woff", ".woff2", "", ".ttf", ".otf", ".pcf")
|
||||||
|
|
||||||
LINT_FILE_CHECKS = []
|
LINT_FILE_CHECKS = []
|
||||||
LINT_CONTENT_CHECKS = []
|
LINT_CONTENT_CHECKS = []
|
||||||
|
|
2
tests/components/font/.gitattributes
vendored
Normal file
2
tests/components/font/.gitattributes
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*.pcf -text
|
||||||
|
|
7461
tests/components/font/MatrixChunky8X.bdf
Normal file
7461
tests/components/font/MatrixChunky8X.bdf
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,4 +1,12 @@
|
||||||
font:
|
font:
|
||||||
|
- file:
|
||||||
|
type: gfonts
|
||||||
|
family: "Roboto"
|
||||||
|
weight: bold
|
||||||
|
italic: true
|
||||||
|
size: 32
|
||||||
|
id: roboto32
|
||||||
|
|
||||||
- file: "gfonts://Roboto"
|
- file: "gfonts://Roboto"
|
||||||
id: roboto
|
id: roboto
|
||||||
size: 20
|
size: 20
|
||||||
|
@ -9,6 +17,10 @@ font:
|
||||||
- file: "gfonts://Roboto"
|
- file: "gfonts://Roboto"
|
||||||
id: roboto_web
|
id: roboto_web
|
||||||
size: 20
|
size: 20
|
||||||
|
- file: "gfonts://Roboto"
|
||||||
|
id: roboto_greek
|
||||||
|
size: 20
|
||||||
|
glyphs: ["\u0300", "\u00C5", "\U000000C7"]
|
||||||
- file: "https://github.com/IdreesInc/Monocraft/releases/download/v3.0/Monocraft.ttf"
|
- file: "https://github.com/IdreesInc/Monocraft/releases/download/v3.0/Monocraft.ttf"
|
||||||
id: monocraft
|
id: monocraft
|
||||||
size: 20
|
size: 20
|
||||||
|
@ -20,6 +32,17 @@ font:
|
||||||
- file: $component_dir/Monocraft.ttf
|
- file: $component_dir/Monocraft.ttf
|
||||||
id: monocraft3
|
id: monocraft3
|
||||||
size: 28
|
size: 28
|
||||||
|
- file: $component_dir/MatrixChunky8X.bdf
|
||||||
|
id: special_font
|
||||||
|
glyphs:
|
||||||
|
- '"'
|
||||||
|
- "'"
|
||||||
|
- '#$%&()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz°'
|
||||||
|
|
||||||
|
- file: $component_dir/MatrixChunky8X.bdf
|
||||||
|
id: default_font
|
||||||
|
- file: $component_dir/x11.pcf
|
||||||
|
id: pcf_font
|
||||||
|
|
||||||
i2c:
|
i2c:
|
||||||
scl: ${i2c_scl}
|
scl: ${i2c_scl}
|
||||||
|
@ -36,3 +59,4 @@ display:
|
||||||
it.print(0, 40, id(monocraft), "Hello, World!");
|
it.print(0, 40, id(monocraft), "Hello, World!");
|
||||||
it.print(0, 60, id(monocraft2), "Hello, World!");
|
it.print(0, 60, id(monocraft2), "Hello, World!");
|
||||||
it.print(0, 80, id(monocraft3), "Hello, World!");
|
it.print(0, 80, id(monocraft3), "Hello, World!");
|
||||||
|
it.print(0, 100, id(roboto_greek), "Hello κόσμε!");
|
||||||
|
|
|
@ -1,4 +1,12 @@
|
||||||
font:
|
font:
|
||||||
|
- file:
|
||||||
|
type: gfonts
|
||||||
|
family: "Roboto"
|
||||||
|
weight: bold
|
||||||
|
italic: true
|
||||||
|
size: 32
|
||||||
|
id: roboto32
|
||||||
|
|
||||||
- file: "gfonts://Roboto"
|
- file: "gfonts://Roboto"
|
||||||
id: roboto
|
id: roboto
|
||||||
size: 20
|
size: 20
|
||||||
|
@ -9,6 +17,10 @@ font:
|
||||||
- file: "gfonts://Roboto"
|
- file: "gfonts://Roboto"
|
||||||
id: roboto_web
|
id: roboto_web
|
||||||
size: 20
|
size: 20
|
||||||
|
- file: "gfonts://Roboto"
|
||||||
|
id: roboto_greek
|
||||||
|
size: 20
|
||||||
|
glyphs: ["\u0300", "\u00C5", "\U000000C7"]
|
||||||
- file: "https://github.com/IdreesInc/Monocraft/releases/download/v3.0/Monocraft.ttf"
|
- file: "https://github.com/IdreesInc/Monocraft/releases/download/v3.0/Monocraft.ttf"
|
||||||
id: monocraft
|
id: monocraft
|
||||||
size: 20
|
size: 20
|
||||||
|
@ -20,4 +32,26 @@ font:
|
||||||
- file: $component_dir/Monocraft.ttf
|
- file: $component_dir/Monocraft.ttf
|
||||||
id: monocraft3
|
id: monocraft3
|
||||||
size: 28
|
size: 28
|
||||||
|
- file: $component_dir/MatrixChunky8X.bdf
|
||||||
|
id: special_font
|
||||||
|
glyphs:
|
||||||
|
- '"'
|
||||||
|
- "'"
|
||||||
|
- '#$%&()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz°'
|
||||||
|
|
||||||
|
- file: $component_dir/MatrixChunky8X.bdf
|
||||||
|
id: default_font
|
||||||
|
|
||||||
|
display:
|
||||||
|
- platform: sdl
|
||||||
|
id: sdl_display
|
||||||
|
dimensions:
|
||||||
|
width: 800
|
||||||
|
height: 600
|
||||||
|
lambda: |-
|
||||||
|
it.print(0, 0, id(roboto), "Hello, World!");
|
||||||
|
it.print(0, 20, id(roboto_web), "Hello, World!");
|
||||||
|
it.print(0, 40, id(roboto_greek), "Hello κόσμε!");
|
||||||
|
it.print(0, 60, id(monocraft), "Hello, World!");
|
||||||
|
it.print(0, 80, id(monocraft2), "Hello, World!");
|
||||||
|
it.print(0, 100, id(monocraft3), "Hello, World!");
|
||||||
|
|
BIN
tests/components/font/x11.pcf
Normal file
BIN
tests/components/font/x11.pcf
Normal file
Binary file not shown.
Loading…
Reference in a new issue