font: add anti-aliasing and other features (#6198)

* Pack glyph bits

* Use unsigned chars for unicode strings.

* Implement multi-bit glyphs

* clang-format

* Allow extra glyphs to be added to a font

* Allow .otf and .woff file extensions

* Add printf versions with background color;
Add tests

* Whitespace...

* Move font test to new framework

* CI fix

* CI fix

* CODEOWNERS

* File extensions tested as case-insensitive
This commit is contained in:
Clyde Stubbs
2024-03-11 20:03:39 +11:00
committed by GitHub
parent 11b31483c3
commit e4df422798
8 changed files with 282 additions and 113 deletions
+148 -60
View File
@@ -10,7 +10,10 @@ import requests
from esphome import core
import esphome.config_validation as cv
import esphome.codegen as cg
from esphome.helpers import copy_file_if_changed
from esphome.helpers import (
copy_file_if_changed,
cpp_string_escape,
)
from esphome.const import (
CONF_FAMILY,
CONF_FILE,
@@ -22,45 +25,75 @@ from esphome.const import (
CONF_PATH,
CONF_WEIGHT,
)
from esphome.core import CORE, HexInt
from esphome.core import (
CORE,
HexInt,
)
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))
def 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}")
value.sort(key=functools.cmp_to_key(comparator))
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
@@ -79,16 +112,16 @@ def validate_pillow_installed(value):
return value
FONT_EXTENSIONS = (".ttf", ".woff", ".otf")
def validate_truetype_file(value):
if value.endswith(".zip"): # for Google Fonts downloads
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 value.endswith(".ttf"):
raise cv.Invalid(
"Only truetype (.ttf) files are supported. Please make sure you're "
"using the correct format or rename the extension to .ttf"
)
if not any(map(value.lower().endswith, FONT_EXTENSIONS)):
raise cv.Invalid(f"Only {FONT_EXTENSIONS} files are supported.")
return cv.file_(value)
@@ -233,7 +266,6 @@ def _file_schema(value):
FILE_SCHEMA = cv.Schema(_file_schema)
DEFAULT_GLYPHS = (
' !"%()+=,-.:/?0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz°'
)
@@ -245,12 +277,22 @@ FONT_SCHEMA = cv.Schema(
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)
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.
@@ -292,8 +334,32 @@ class BitmapFontWrapper:
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 == TYPE_GFONTS:
path = _compute_gfonts_local_path(file)
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
from PIL import (
PcfFontFile,
BdfFontFile,
)
local_bitmap_font_file = _compute_local_font_dir(filepath) / os.path.basename(
filepath
@@ -347,60 +413,82 @@ def load_ttf_font(path, size):
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):
conf = config[CONF_FILE]
if conf[CONF_TYPE] == TYPE_LOCAL_BITMAP:
font = load_bitmap_font(CORE.relative_config_path(conf[CONF_PATH]))
elif conf[CONF_TYPE] == TYPE_LOCAL:
path = CORE.relative_config_path(conf[CONF_PATH])
font = load_ttf_font(path, config[CONF_SIZE])
elif conf[CONF_TYPE] == TYPE_GFONTS:
path = _compute_gfonts_local_path(conf)
font = load_ttf_font(path, config[CONF_SIZE])
else:
raise core.EsphomeError(f"Could not load font: unknown type: {conf[CONF_TYPE]}")
ascent, descent = font.getmetrics(config[CONF_GLYPHS])
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 = []
for glyph in config[CONF_GLYPHS]:
mask = font.getmask(glyph, mode="1")
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
width8 = ((width + 7) // 8) * 8
glyph_data = [0] * (height * width8 // 8)
glyph_data = [0] * ((height * width * bpp + 7) // 8)
pos = 0
for y in range(height):
for x in range(width):
if not mask.getpixel((x, y)):
continue
pos = x + y * width8
glyph_data[pos // 8] |= 0x80 >> (pos % 8)
glyph_args[glyph] = (len(data), offset_x, offset_y, width, height)
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 config[CONF_GLYPHS]:
for glyph in glyphs:
glyph_initializer.append(
cg.StructInitializer(
GlyphData,
("a_char", glyph),
(
"a_char",
cg.RawExpression(f"(const uint8_t *){cpp_string_escape(glyph)}"),
),
(
"data",
cg.RawExpression(f"{str(prog_arr)} + {str(glyph_args[glyph][0])}"),
cg.RawExpression(
f"{str(prog_arr)} + {str(glyph_args[glyph].data_len)}"
),
),
("offset_x", glyph_args[glyph][1]),
("offset_y", glyph_args[glyph][2]),
("width", glyph_args[glyph][3]),
("height", glyph_args[glyph][4]),
("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), ascent, ascent + descent
config[CONF_ID],
glyphs,
len(glyph_initializer),
font_list[0].ascent,
font_list[0].ascent + font_list[0].descent,
bpp,
)