Public Access
- Text elements can scroll in a window at a set speed; clock, countdown and stopwatch can show seconds. Moving pages are sent as instant frames (new Nano command D1) and paced by the estimated flip time. - Countdown to a date and time (smart, days, days + time, with seconds), stopwatch (web UI, HA switch and reset button), image element (fixed or by Home Assistant state), multiplier for HA values, degree sign (char 127) in all fonts and the font converter. - Page format version 2; version 1 pages are converted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
7.6 KiB
Python
216 lines
7.6 KiB
Python
"""Generates src/esp32/logic/builtin_fonts.cpp: the built-in fonts of the dynamic pages.
|
|
Characters 32-126, and 127 as the degree sign.
|
|
|
|
python tools/make_builtin_fonts.py
|
|
|
|
Needs the Adafruit GFX library in .pio/libdeps/esp32 (run `pio run -e esp32` once):
|
|
Small TomThumb 3x5 (Fonts/TomThumb.h, BSD license, see that file)
|
|
Normal the classic 5x7 font (glcdfont.c), the font the clock always used
|
|
Full height digits drawn below (seven segments with rounded corners, 19 dots high)
|
|
"Large" is Normal drawn twice as big, so it has no data of its own.
|
|
|
|
The font format is described in src/esp32/logic/font.h. Only the Python standard
|
|
library is used.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
GFX = os.path.join(ROOT, ".pio", "libdeps", "esp32", "Adafruit GFX Library")
|
|
OUT = os.path.join(ROOT, "src", "esp32", "logic", "builtin_fonts.cpp")
|
|
|
|
FIRST = 32
|
|
LAST = 126 # plus character 127: the degree sign (DEGREE in each font)
|
|
|
|
# The degree sign per font height, drawn at the top of the character
|
|
DEGREE = {
|
|
6: [".#.", "#.#", ".#.", "...", "...", "..."],
|
|
8: [".##.", "#..#", "#..#", ".##.", "....", "....", "....", "...."],
|
|
19: [".####.", "##..##", "#....#", "#....#", "##..##", ".####."] + ["......"] * 13,
|
|
}
|
|
|
|
|
|
def numbers(text):
|
|
return [int(v, 0) for v in re.findall(r"0x[0-9A-Fa-f]+|-?\d+", text)]
|
|
|
|
|
|
def array_body(source, name):
|
|
match = re.search(name + r"\[\]\s*PROGMEM\s*=\s*\{(.*?)\};", source, re.S)
|
|
if not match:
|
|
sys.exit("Not found: " + name)
|
|
# drop comments, they contain numbers too
|
|
return re.sub(r"/\*.*?\*/|//[^\n]*", "", match.group(1), flags=re.S)
|
|
|
|
|
|
def encode(name, height, spacing, glyphs):
|
|
"""glyphs: list of (width, rows) per character FIRST..LAST, rows = list of strings"""
|
|
data = bytearray(b"FDF1")
|
|
data += bytes([height, spacing, FIRST, len(glyphs)])
|
|
data += bytes(width for width, _ in glyphs)
|
|
for width, rows in glyphs:
|
|
assert len(rows) == height, name
|
|
for row in rows:
|
|
for start in range(0, width, 8):
|
|
byte = 0
|
|
for bit in range(8):
|
|
x = start + bit
|
|
if x < width and row[x] == "#":
|
|
byte |= 0x80 >> bit
|
|
data.append(byte)
|
|
return bytes(data)
|
|
|
|
|
|
def classic():
|
|
source = open(os.path.join(GFX, "glcdfont.c")).read()
|
|
font = numbers(array_body(source, "font"))
|
|
glyphs = []
|
|
for c in range(FIRST, LAST + 1):
|
|
columns = font[c * 5 : c * 5 + 5]
|
|
rows = ["".join("#" if columns[x] >> y & 1 else "." for x in range(5)) for y in range(8)]
|
|
glyphs.append((5, rows))
|
|
glyphs.append((4, DEGREE[8]))
|
|
return encode("Normal", 8, 1, glyphs)
|
|
|
|
|
|
def tom_thumb():
|
|
source = open(os.path.join(GFX, "Fonts", "TomThumb.h")).read()
|
|
bitmaps = numbers(array_body(source, "TomThumbBitmaps"))
|
|
table = numbers(array_body(source, "TomThumbGlyphs"))
|
|
glyphs = []
|
|
for i, c in enumerate(range(FIRST, LAST + 1)):
|
|
offset, w, h, advance, x_offset, y_offset = table[i * 6 : i * 6 + 6]
|
|
width = max(advance - 1, 1)
|
|
rows = [["."] * width for _ in range(6)]
|
|
bit = 0
|
|
for y in range(h):
|
|
for x in range(w):
|
|
byte = bitmaps[offset + bit // 8]
|
|
if byte & (0x80 >> (bit % 8)):
|
|
rows[5 + y_offset + y][x_offset + x] = "#"
|
|
bit += 1
|
|
if c == ord(" "):
|
|
rows = [["."] * 2 for _ in range(6)]
|
|
width = 2
|
|
glyphs.append((width, ["".join(r) for r in rows]))
|
|
# Digits all as wide as the widest one (right aligned), so a clock doesn't move
|
|
digits = range(ord("0") - FIRST, ord("9") - FIRST + 1)
|
|
widest = max(glyphs[i][0] for i in digits)
|
|
for i in digits:
|
|
width, rows = glyphs[i]
|
|
glyphs[i] = (widest, ["." * (widest - width) + r for r in rows])
|
|
glyphs.append((3, DEGREE[6]))
|
|
return encode("Small", 6, 1, glyphs)
|
|
|
|
|
|
# Full height digits: seven segments, 10 x 19 dots
|
|
SEGMENTS = {
|
|
"a": (0, 0, 10, 2), # x, y, width, height
|
|
"b": (7, 0, 3, 11),
|
|
"c": (7, 8, 3, 11),
|
|
"d": (0, 17, 10, 2),
|
|
"e": (0, 8, 3, 11),
|
|
"f": (0, 0, 3, 11),
|
|
"g": (0, 8, 10, 3),
|
|
}
|
|
DIGITS = ["abcdef", "bc", "abdeg", "abcdg", "bcfg", "acdfg", "acdefg", "abc", "abcdefg", "abcdfg"]
|
|
|
|
|
|
def rounded(rows):
|
|
"""Rounds the outer corners: clears a corner dot of the glyph box or of the middle bar
|
|
when it has no neighbour above or below and none left or right"""
|
|
height, width = len(rows), len(rows[0])
|
|
on = lambda x, y: 0 <= x < width and 0 <= y < height and rows[y][x] == "#"
|
|
out = [list(r) for r in rows]
|
|
for y in (0, 8, 10, height - 1):
|
|
for x in (0, width - 1):
|
|
if on(x, y) and not (on(x - 1, y) and on(x + 1, y)) and not (on(x, y - 1) and on(x, y + 1)) and (not on(x - 1, y) or not on(x + 1, y)) and (not on(x, y - 1) or not on(x, y + 1)):
|
|
out[y][x] = "."
|
|
return ["".join(r) for r in out]
|
|
|
|
|
|
def box(width, height, dots):
|
|
rows = [["."] * width for _ in range(height)]
|
|
for x, y, w, h in dots:
|
|
for yy in range(y, y + h):
|
|
for xx in range(x, x + w):
|
|
rows[yy][xx] = "#"
|
|
return ["".join(r) for r in rows]
|
|
|
|
|
|
def full_height():
|
|
special = {
|
|
" ": box(4, 19, []),
|
|
":": box(3, 19, [(0, 4, 3, 3), (0, 12, 3, 3)]),
|
|
".": box(3, 19, [(0, 16, 3, 3)]),
|
|
"-": box(6, 19, [(0, 8, 6, 3)]),
|
|
}
|
|
glyphs = []
|
|
for c in range(FIRST, LAST + 1):
|
|
ch = chr(c)
|
|
if ch.isdigit():
|
|
rows = rounded(box(10, 19, [SEGMENTS[s] for s in DIGITS[int(ch)]]))
|
|
glyphs.append((10, rows))
|
|
elif ch in special:
|
|
glyphs.append((len(special[ch][0]), special[ch]))
|
|
else:
|
|
glyphs.append((0, [""] * 19)) # not in this font: skipped
|
|
glyphs.append((6, DEGREE[19]))
|
|
return encode("Full height", 19, 2, glyphs)
|
|
|
|
|
|
def c_array(name, data):
|
|
lines = []
|
|
for i in range(0, len(data), 16):
|
|
lines.append("\t" + ", ".join("0x%02x" % b for b in data[i : i + 16]) + ",")
|
|
return "static const uint8_t %s[] = {\n%s\n};\n" % (name, "\n".join(lines))
|
|
|
|
|
|
LOOKUP = """
|
|
const BuiltinFont *findBuiltinFont(const char *name)
|
|
{
|
|
for (int i = 0; i < BUILTIN_FONT_COUNT; i++)
|
|
if (sameName(BUILTIN_FONTS[i].name, name))
|
|
return &BUILTIN_FONTS[i];
|
|
return nullptr;
|
|
}
|
|
|
|
Font builtinFont(const BuiltinFont &font)
|
|
{
|
|
Font f;
|
|
f.data = font.data;
|
|
f.size = font.size;
|
|
f.scale = font.scale;
|
|
return f;
|
|
}
|
|
"""
|
|
|
|
|
|
def main():
|
|
fonts = [("FONT_SMALL", tom_thumb()), ("FONT_NORMAL", classic()), ("FONT_FULL", full_height())]
|
|
out = [
|
|
"// Generated by tools/make_builtin_fonts.py, do not edit.\n",
|
|
"// Small: TomThumb (BSD license, Brian J. Swetland, Vassilii Khachaturov, Dan Marks,\n",
|
|
"// Robey Pointer); Normal: Adafruit GFX glcdfont (BSD license, Adafruit Industries).\n\n",
|
|
'#include "logic/builtin_fonts.h"\n\n',
|
|
'#include "logic/bitmap_format.h"\n\n',
|
|
]
|
|
for name, data in fonts:
|
|
out.append(c_array(name, data) + "\n")
|
|
out.append("const BuiltinFont BUILTIN_FONTS[] = {\n")
|
|
out.append('\t{"Small", FONT_SMALL, sizeof(FONT_SMALL), 1},\n')
|
|
out.append('\t{"Normal", FONT_NORMAL, sizeof(FONT_NORMAL), 1},\n')
|
|
out.append('\t{"Large", FONT_NORMAL, sizeof(FONT_NORMAL), 2},\n')
|
|
out.append('\t{"Full height", FONT_FULL, sizeof(FONT_FULL), 1},\n')
|
|
out.append("};\n")
|
|
out.append("const int BUILTIN_FONT_COUNT = sizeof(BUILTIN_FONTS) / sizeof(BUILTIN_FONTS[0]);\n")
|
|
out.append(LOOKUP)
|
|
with open(OUT, "w", newline="\n") as f:
|
|
f.write("".join(out))
|
|
print("Wrote", OUT)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|