Files
Flipdot-display/tools/make_builtin_fonts.py
T
mangavalkandClaude Opus 5.5 0411804a2f Add dynamic pages with clock, date, Home Assistant values and fonts
The hardcoded clock is now the built-in dynamic page "Clock". A dynamic page
has a background bitmap per time of day and up to 8 elements (clock, date,
Home Assistant value, text), each with position, alignment, font and overlay.
Pages are drawn on the ESP32, also for the web previews.

- Pages tab in the web UI: page editor with drag and drop, font upload
  (TTF/OTF converted in the browser to the FDF1 dot font format)
- Built-in fonts Small, Normal, Large and Full height (generated by
  tools/make_builtin_fonts.py), built-in bitmaps Pikachu and Pikachu yawning
- Home Assistant values over the REST API (URL and token in the HA tab)
- Settings version 4: evening settings removed (now time slots of the Clock
  page), old records are converted
- HA Mode dropdown lists the dynamic pages and bitmaps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:43:59 +02:00

205 lines
7.1 KiB
Python

"""Generates src/esp32/logic/builtin_fonts.cpp: the built-in fonts of the dynamic pages.
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
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))
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])
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
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()