mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 15:12:06 +00:00 
			
		
		
		
	[font] Add support for "glyphsets" (#7429)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
This commit is contained in:
		| @@ -40,25 +40,6 @@ RUN \ | ||||
|         libcairo2=1.16.0-7 \ | ||||
|         libmagic1=1:5.44-3 \ | ||||
|         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 \ | ||||
|         /tmp/* \ | ||||
|         /var/{cache,log}/* \ | ||||
| @@ -97,15 +78,48 @@ RUN \ | ||||
| # tmpfs is for https://github.com/rust-lang/cargo/issues/8719 | ||||
|  | ||||
| COPY requirements.txt requirements_optional.txt / | ||||
| RUN --mount=type=tmpfs,target=/root/.cargo if [ "$TARGETARCH$TARGETVARIANT" = "armv7" ]; then \ | ||||
|         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 --break-system-packages --no-cache-dir /tmp/cryptography-43.0.0-cp37-abi3-linux_armv7l.whl \ | ||||
|         && rm /tmp/cryptography-43.0.0-cp37-abi3-linux_armv7l.whl \ | ||||
|         && export PIP_EXTRA_INDEX_URL="https://www.piwheels.org/simple"; \ | ||||
|     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 | ||||
| RUN --mount=type=tmpfs,target=/root/.cargo <<END-OF-RUN | ||||
| # Fail on any non-zero status | ||||
| set -e | ||||
|  | ||||
| if [ "$TARGETARCH$TARGETVARIANT" = "armv7" ] | ||||
| then | ||||
|     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 --break-system-packages --no-cache-dir /tmp/cryptography-43.0.0-cp37-abi3-linux_armv7l.whl | ||||
|     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 / | ||||
| RUN /platformio_install_deps.py /platformio.ini --libraries | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| from collections.abc import Iterable | ||||
| import functools | ||||
| import hashlib | ||||
| import logging | ||||
| @@ -5,6 +6,8 @@ import os | ||||
| from pathlib import Path | ||||
| import re | ||||
|  | ||||
| import freetype | ||||
| import glyphsets | ||||
| from packaging import version | ||||
| import requests | ||||
|  | ||||
| @@ -43,6 +46,18 @@ GlyphData = font_ns.struct("GlyphData") | ||||
| CONF_BPP = "bpp" | ||||
| CONF_EXTRAS = "extras" | ||||
| 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): | ||||
| @@ -59,36 +74,106 @@ def glyph_comparator(x, y): | ||||
|         return -1 | ||||
|     if len(x_) > len(y_): | ||||
|         return 1 | ||||
|     raise cv.Invalid(f"Found duplicate glyph {x}") | ||||
|     return 0 | ||||
|  | ||||
|  | ||||
| def validate_glyphs(value): | ||||
|     if isinstance(value, list): | ||||
|         value = cv.Schema([cv.string])(value) | ||||
|     value = cv.Schema([cv.string])(list(value)) | ||||
| def flatten(lists) -> list: | ||||
|     """ | ||||
|     Given a list of lists, flatten it to a single list of all elements of all lists. | ||||
|     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 value | ||||
|     return list(chain.from_iterable(lists)) | ||||
|  | ||||
|  | ||||
| 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 | ||||
|     """ | ||||
|  | ||||
|  | ||||
| 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 = FONT_CACHE[file] | ||||
|     missing = [chr(x) for x in codepoints if font.get_char_index(x) == 0] | ||||
|     if missing: | ||||
|         # Only list up to 10 missing glyphs | ||||
|         missing.sort(key=functools.cmp_to_key(glyph_comparator)) | ||||
|         count = len(missing) | ||||
|         missing = missing[:10] | ||||
|         missing_str = "\n    ".join( | ||||
|             f"{x} ({x.encode('unicode_escape')})" for x in missing | ||||
|         ) | ||||
|         font_list.extend(extra_fonts) | ||||
|         for extra in extras: | ||||
|             glyphs.extend(extra[CONF_GLYPHS]) | ||||
|         validate_glyphs(glyphs) | ||||
|     font_map[config[CONF_ID]] = font_list | ||||
|         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] | ||||
|     ) | ||||
|     # Convert a list of strings to a list of chars (one char strings) | ||||
|     glyphspoints = flatten([list(x) for x in glyphspoints]) | ||||
|     if len(set(glyphspoints)) != len(glyphspoints): | ||||
|         duplicates = {x for x in glyphspoints if glyphspoints.count(x) > 1} | ||||
|         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 | ||||
|  | ||||
|  | ||||
| @@ -120,7 +205,7 @@ def validate_truetype_file(value): | ||||
|         ) | ||||
|     if not any(map(value.lower().endswith, FONT_EXTENSIONS)): | ||||
|         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" | ||||
| @@ -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" | ||||
| FONT_WEIGHTS = { | ||||
|     "thin": 100, | ||||
| @@ -167,13 +256,13 @@ def _compute_local_font_path(value: dict) -> Path: | ||||
|     return base_dir / key | ||||
|  | ||||
|  | ||||
| def get_font_path(value, type) -> Path: | ||||
|     if type == TYPE_GFONTS: | ||||
| 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 type == TYPE_WEB: | ||||
|     if font_type == TYPE_WEB: | ||||
|         return _compute_local_font_path(value) / "font.ttf" | ||||
|     return None | ||||
|     assert False | ||||
|  | ||||
|  | ||||
| def download_gfont(value): | ||||
| @@ -203,7 +292,7 @@ def download_gfont(value): | ||||
|     _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url) | ||||
|  | ||||
|     external_files.download_content(ttf_url, path) | ||||
|     return value | ||||
|     return FULLPATH_SCHEMA(path) | ||||
|  | ||||
|  | ||||
| def download_web_font(value): | ||||
| @@ -212,7 +301,7 @@ def download_web_font(value): | ||||
|  | ||||
|     external_files.download_content(url, path) | ||||
|     _LOGGER.debug("download_web_font: path=%s", path) | ||||
|     return value | ||||
|     return FULLPATH_SCHEMA(path) | ||||
|  | ||||
|  | ||||
| EXTERNAL_FONT_SCHEMA = cv.Schema( | ||||
| @@ -225,7 +314,6 @@ EXTERNAL_FONT_SCHEMA = cv.Schema( | ||||
|     } | ||||
| ) | ||||
|  | ||||
|  | ||||
| GFONTS_SCHEMA = cv.All( | ||||
|     EXTERNAL_FONT_SCHEMA.extend( | ||||
|         { | ||||
| @@ -259,10 +347,10 @@ def validate_file_shorthand(value): | ||||
|         } | ||||
|         if weight is not None: | ||||
|             data[CONF_WEIGHT] = weight[1:] | ||||
|         return FILE_SCHEMA(data) | ||||
|         return font_file_schema(data) | ||||
|  | ||||
|     if value.startswith("http://") or value.startswith("https://"): | ||||
|         return FILE_SCHEMA( | ||||
|         return font_file_schema( | ||||
|             { | ||||
|                 CONF_TYPE: TYPE_WEB, | ||||
|                 CONF_URL: value, | ||||
| @@ -270,14 +358,15 @@ def validate_file_shorthand(value): | ||||
|         ) | ||||
|  | ||||
|     if value.endswith(".pcf") or value.endswith(".bdf"): | ||||
|         return FILE_SCHEMA( | ||||
|             { | ||||
|                 CONF_TYPE: TYPE_LOCAL_BITMAP, | ||||
|                 CONF_PATH: value, | ||||
|             } | ||||
|         value = convert_bitmap_to_pillow_font( | ||||
|             CORE.relative_config_path(cv.file_(value)) | ||||
|         ) | ||||
|         return { | ||||
|             CONF_TYPE: TYPE_LOCAL_BITMAP, | ||||
|             CONF_PATH: value, | ||||
|         } | ||||
|  | ||||
|     return FILE_SCHEMA( | ||||
|     return font_file_schema( | ||||
|         { | ||||
|             CONF_TYPE: TYPE_LOCAL, | ||||
|             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): | ||||
|         return validate_file_shorthand(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" | ||||
|  | ||||
| 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.Required(CONF_FILE): font_file_schema, | ||||
|         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_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.Required(CONF_FILE): FILE_SCHEMA, | ||||
|                     cv.Required(CONF_GLYPHS): validate_glyphs, | ||||
|                     cv.Required(CONF_FILE): font_file_schema, | ||||
|                     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 | ||||
| @@ -367,28 +460,20 @@ class BitmapFontWrapper: | ||||
|             mask = self.getmask(glyph, mode="1") | ||||
|             _, height = mask.size | ||||
|             max_height = max(max_height, height) | ||||
|         return (max_height, 0) | ||||
|         return max_height, 0 | ||||
|  | ||||
|  | ||||
| class EFont: | ||||
|     def __init__(self, file, size, glyphs): | ||||
|         self.glyphs = glyphs | ||||
|     def __init__(self, file, size, codepoints): | ||||
|         self.codepoints = codepoints | ||||
|         path = file[CONF_PATH] | ||||
|         self.name = Path(path).name | ||||
|         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) | ||||
|             self.font = load_bitmap_font(path) | ||||
|         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 | ||||
|             self.font = load_ttf_font(path, size) | ||||
|         self.ascent, self.descent = self.font.getmetrics(codepoints) | ||||
|  | ||||
|  | ||||
| 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) | ||||
|  | ||||
|     local_pil_font_file = local_bitmap_font_file.with_suffix(".pil") | ||||
|     with open(local_bitmap_font_file, "rb") as fp: | ||||
|         try: | ||||
|             try: | ||||
| @@ -409,28 +495,22 @@ def convert_bitmap_to_pillow_font(filepath): | ||||
|                 p = BdfFontFile.BdfFontFile(fp) | ||||
|  | ||||
|             # 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: | ||||
|             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) | ||||
|     return str(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)) | ||||
|         font = ImageFont.load(str(filepath)) | ||||
|     except Exception as e: | ||||
|         raise core.EsphomeError( | ||||
|             f"Failed to load bitmap font file: {pil_font_path} : {e}" | ||||
|         ) | ||||
|         raise core.EsphomeError(f"Failed to load bitmap font file: {filepath}: {e}") | ||||
|  | ||||
|     return BitmapFontWrapper(font) | ||||
|  | ||||
| @@ -441,7 +521,7 @@ def load_ttf_font(path, size): | ||||
|     try: | ||||
|         font = ImageFont.truetype(str(path), size) | ||||
|     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) | ||||
|  | ||||
| @@ -456,14 +536,35 @@ class GlyphInfo: | ||||
|  | ||||
|  | ||||
| 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)) | ||||
|     """ | ||||
|     Collect all glyph codepoints, construct a map from a codepoint to a font file. | ||||
|     Codepoints are either explicit (glyphs key in top level or extras) or part of a glyphset. | ||||
|     Codepoints listed in extras use the extra font and override codepoints from glyphsets. | ||||
|     Achieve this by processing the base codepoints first, then the extras | ||||
|     """ | ||||
|  | ||||
|     # 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 = {} | ||||
|     data = [] | ||||
|     bpp = config[CONF_BPP] | ||||
| @@ -473,10 +574,11 @@ async def to_code(config): | ||||
|     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) | ||||
|     # create the data array for all glyphs | ||||
|     for codepoint in codepoints: | ||||
|         font = point_font_map[codepoint] | ||||
|         mask = font.font.getmask(codepoint, mode=mode) | ||||
|         offset_x, offset_y = font.font.getoffset(codepoint) | ||||
|         width, height = mask.size | ||||
|         glyph_data = [0] * ((height * width * bpp + 7) // 8) | ||||
|         pos = 0 | ||||
| @@ -487,31 +589,34 @@ async def to_code(config): | ||||
|                     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) | ||||
|         glyph_args[codepoint] = 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) | ||||
|  | ||||
|     # Create the glyph table that points to data in the above array. | ||||
|     glyph_initializer = [] | ||||
|     for glyph in glyphs: | ||||
|     for codepoint in codepoints: | ||||
|         glyph_initializer.append( | ||||
|             cg.StructInitializer( | ||||
|                 GlyphData, | ||||
|                 ( | ||||
|                     "a_char", | ||||
|                     cg.RawExpression(f"(const uint8_t *){cpp_string_escape(glyph)}"), | ||||
|                     cg.RawExpression( | ||||
|                         f"(const uint8_t *){cpp_string_escape(codepoint)}" | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "data", | ||||
|                     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_y", glyph_args[glyph].offset_y), | ||||
|                 ("width", glyph_args[glyph].width), | ||||
|                 ("height", glyph_args[glyph].height), | ||||
|                 ("offset_x", glyph_args[codepoint].offset_x), | ||||
|                 ("offset_y", glyph_args[codepoint].offset_y), | ||||
|                 ("width", glyph_args[codepoint].width), | ||||
|                 ("height", glyph_args[codepoint].height), | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
| @@ -521,7 +626,7 @@ async def to_code(config): | ||||
|         config[CONF_ID], | ||||
|         glyphs, | ||||
|         len(glyph_initializer), | ||||
|         font_list[0].ascent, | ||||
|         font_list[0].ascent + font_list[0].descent, | ||||
|         base_font.ascent, | ||||
|         base_font.ascent + base_font.descent, | ||||
|         bpp, | ||||
|     ) | ||||
|   | ||||
| @@ -17,6 +17,9 @@ aioesphomeapi==24.6.2 | ||||
| zeroconf==0.132.2 | ||||
| puremagic==1.27 | ||||
| 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 | ||||
| # https://github.com/espressif/esp-idf/blob/220590d599e134d7a5e7f1e683cc4550349ffbf8/requirements.txt#L24 | ||||
|   | ||||
| @@ -1,2 +1 @@ | ||||
| pillow==10.4.0 | ||||
| cairosvg==2.7.1 | ||||
|   | ||||
| @@ -58,7 +58,7 @@ file_types = ( | ||||
| ) | ||||
| cpp_include = ("*.h", "*.c", "*.cpp", "*.tcc") | ||||
| py_include = ("*.py",) | ||||
| ignore_types = (".ico", ".png", ".woff", ".woff2", "", ".ttf", ".otf") | ||||
| ignore_types = (".ico", ".png", ".woff", ".woff2", "", ".ttf", ".otf", ".pcf") | ||||
|  | ||||
| LINT_FILE_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: | ||||
|   - file: | ||||
|       type: gfonts | ||||
|       family: "Roboto" | ||||
|       weight: bold | ||||
|       italic: true | ||||
|     size: 32 | ||||
|     id: roboto32 | ||||
|  | ||||
|   - file: "gfonts://Roboto" | ||||
|     id: roboto | ||||
|     size: 20 | ||||
| @@ -9,6 +17,10 @@ font: | ||||
|   - file: "gfonts://Roboto" | ||||
|     id: roboto_web | ||||
|     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" | ||||
|     id: monocraft | ||||
|     size: 20 | ||||
| @@ -20,6 +32,17 @@ font: | ||||
|   - file: $component_dir/Monocraft.ttf | ||||
|     id: monocraft3 | ||||
|     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: | ||||
|   scl: ${i2c_scl} | ||||
| @@ -36,3 +59,4 @@ display: | ||||
|       it.print(0, 40, id(monocraft), "Hello, World!"); | ||||
|       it.print(0, 60, id(monocraft2), "Hello, World!"); | ||||
|       it.print(0, 80, id(monocraft3), "Hello, World!"); | ||||
|       it.print(0, 100, id(roboto_greek), "Hello κόσμε!"); | ||||
|   | ||||
| @@ -1,4 +1,12 @@ | ||||
| font: | ||||
|   - file: | ||||
|       type: gfonts | ||||
|       family: "Roboto" | ||||
|       weight: bold | ||||
|       italic: true | ||||
|     size: 32 | ||||
|     id: roboto32 | ||||
|  | ||||
|   - file: "gfonts://Roboto" | ||||
|     id: roboto | ||||
|     size: 20 | ||||
| @@ -9,6 +17,10 @@ font: | ||||
|   - file: "gfonts://Roboto" | ||||
|     id: roboto_web | ||||
|     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" | ||||
|     id: monocraft | ||||
|     size: 20 | ||||
| @@ -20,4 +32,26 @@ font: | ||||
|   - file: $component_dir/Monocraft.ttf | ||||
|     id: monocraft3 | ||||
|     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.
										
									
								
							
		Reference in New Issue
	
	Block a user