173 lines
6.6 KiB
Python
173 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generate the icon font the UI uses: main/ui/icon_font.c + main/ui/ui_icons.h.
|
|
|
|
The icons are a ~30-glyph subset of Material Design Icons (the same icon set
|
|
Home Assistant uses, so the panel and the HA dashboard agree on what "partly
|
|
cloudy" looks like). The subset TTF is embedded as a C array and rasterised at
|
|
runtime by LVGL's tiny_ttf, which means:
|
|
|
|
- icons are just label text in a special font -- colourable, sizeable,
|
|
no per-icon bitmaps to regenerate when a size changes;
|
|
- the whole set costs a few KB of flash, not a 1.3MB font.
|
|
|
|
Inputs (cached copies are fine; only regeneration needs them):
|
|
|
|
curl -sfL -o /tmp/mdi.css https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css
|
|
curl -sfL -o /tmp/mdi.ttf https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/materialdesignicons-webfont.ttf
|
|
python3 tools/make_icon_font.py /tmp/mdi.css /tmp/mdi.ttf
|
|
|
|
Codepoints are resolved from the CSS at generation time, never hardcoded --
|
|
MDI has re-assigned codepoints between major versions, and a stale codepoint
|
|
renders as the wrong icon with no error anywhere.
|
|
|
|
The generated files are committed, so a normal build needs neither network
|
|
nor fonttools.
|
|
"""
|
|
|
|
import io
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from fontTools import subset
|
|
except ImportError:
|
|
sys.exit("fonttools is required: python3 -m venv /tmp/fontenv && "
|
|
"/tmp/fontenv/bin/pip install fonttools (then run with that python)")
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
OUT_C = REPO / "main" / "ui" / "icon_font.c"
|
|
OUT_H = REPO / "main" / "ui" / "ui_icons.h"
|
|
|
|
# macro name -> candidate MDI icon names, first match wins. Candidates exist
|
|
# because MDI occasionally renames icons between versions.
|
|
ICONS = {
|
|
# met.no condition slugs (see ha_condition_* in ha_data.c)
|
|
"ICON_W_SUNNY": ["weather-sunny"],
|
|
"ICON_W_NIGHT": ["weather-night"],
|
|
"ICON_W_PARTLY": ["weather-partly-cloudy"],
|
|
"ICON_W_CLOUDY": ["weather-cloudy"],
|
|
"ICON_W_RAINY": ["weather-rainy"],
|
|
"ICON_W_POURING": ["weather-pouring"],
|
|
"ICON_W_LIGHTNING": ["weather-lightning"],
|
|
"ICON_W_LIGHTNING_RAIN":["weather-lightning-rainy"],
|
|
"ICON_W_SNOWY": ["weather-snowy"],
|
|
"ICON_W_SNOWY_RAIN": ["weather-snowy-rainy"],
|
|
"ICON_W_FOG": ["weather-fog"],
|
|
"ICON_W_WINDY": ["weather-windy"],
|
|
"ICON_W_HAIL": ["weather-hail"],
|
|
"ICON_W_ALERT": ["alert-circle-outline"],
|
|
|
|
# grid cells
|
|
"ICON_THERMOMETER": ["thermometer"],
|
|
"ICON_HOME_THERMO": ["home-thermometer-outline", "home-thermometer"],
|
|
"ICON_WATER": ["water-outline", "water"],
|
|
"ICON_SUNRISE": ["weather-sunset-up"],
|
|
"ICON_SUNSET": ["weather-sunset-down"],
|
|
"ICON_GAUGE": ["gauge"],
|
|
|
|
# trend arrows for temperature / pressure readings
|
|
"ICON_TREND_UP": ["trending-up"],
|
|
"ICON_TREND_DOWN": ["trending-down"],
|
|
|
|
# light tiles (order-independent; ha_config.h references these by name)
|
|
"ICON_TV": ["television"],
|
|
"ICON_SOFA": ["sofa-outline", "sofa"],
|
|
"ICON_DINING": ["silverware-fork-knife", "silverware"],
|
|
"ICON_FLOOR_LAMP": ["floor-lamp"],
|
|
"ICON_DESK_LAMP": ["desk-lamp"],
|
|
"ICON_CHANDELIER": ["chandelier", "ceiling-light"],
|
|
"ICON_STRING_LIGHTS": ["string-lights", "outdoor-lamp"],
|
|
}
|
|
|
|
|
|
def resolve_codepoints(css: str) -> dict[str, int]:
|
|
"""macro -> unicode codepoint, via the .mdi-<name>::before rules."""
|
|
cp_by_name: dict[str, int] = {}
|
|
for m in re.finditer(r'\.mdi-([a-z0-9-]+)::?before\{content:"\\([0-9A-Fa-f]+)"\}', css):
|
|
cp_by_name[m.group(1)] = int(m.group(2), 16)
|
|
|
|
if not cp_by_name:
|
|
sys.exit("No codepoint rules found in the CSS -- wrong file?")
|
|
|
|
resolved: dict[str, int] = {}
|
|
for macro, candidates in ICONS.items():
|
|
for name in candidates:
|
|
if name in cp_by_name:
|
|
resolved[macro] = cp_by_name[name]
|
|
print(f" {macro:<24} mdi-{name:<24} U+{cp_by_name[name]:05X}")
|
|
break
|
|
else:
|
|
sys.exit(f"None of {candidates} exist in this MDI release "
|
|
f"(wanted for {macro}) -- pick another candidate")
|
|
return resolved
|
|
|
|
|
|
def subset_font(ttf_path: Path, codepoints: list[int]) -> bytes:
|
|
opts = subset.Options()
|
|
opts.flavor = None # plain TTF out
|
|
opts.desubroutinize = True
|
|
font = subset.load_font(str(ttf_path), opts)
|
|
ss = subset.Subsetter(opts)
|
|
ss.populate(unicodes=codepoints)
|
|
ss.subset(font)
|
|
buf = io.BytesIO()
|
|
font.save(buf)
|
|
return buf.getvalue()
|
|
|
|
|
|
def utf8_escape(cp: int) -> str:
|
|
return "".join(f"\\x{b:02x}" for b in chr(cp).encode("utf-8"))
|
|
|
|
|
|
def main() -> int:
|
|
if len(sys.argv) != 3:
|
|
sys.exit(__doc__)
|
|
css_path, ttf_path = Path(sys.argv[1]), Path(sys.argv[2])
|
|
|
|
print("resolving codepoints from CSS:")
|
|
resolved = resolve_codepoints(css_path.read_text())
|
|
|
|
data = subset_font(ttf_path, sorted(resolved.values()))
|
|
print(f"\nsubset TTF: {len(data)} bytes ({len(resolved)} glyphs)")
|
|
|
|
guard_note = (
|
|
"/*\n"
|
|
" * GENERATED by tools/make_icon_font.py -- do not edit by hand.\n"
|
|
" * Subset of Material Design Icons (Apache-2.0), rasterised at runtime\n"
|
|
" * by LVGL tiny_ttf. Regeneration instructions are in the script.\n"
|
|
" */\n"
|
|
)
|
|
|
|
lines = [guard_note, "#include <stddef.h>\n"]
|
|
lines.append(f"const size_t icon_font_data_size = {len(data)};\n")
|
|
lines.append("const unsigned char icon_font_data[] = {")
|
|
for i in range(0, len(data), 16):
|
|
chunk = ", ".join(f"0x{b:02x}" for b in data[i:i + 16])
|
|
lines.append(f" {chunk},")
|
|
lines.append("};\n")
|
|
OUT_C.write_text("\n".join(lines))
|
|
|
|
h = [guard_note]
|
|
h.append("#ifndef UI_ICONS_H")
|
|
h.append("#define UI_ICONS_H\n")
|
|
h.append("#include <stddef.h>\n")
|
|
h.append("/* The subset TTF; feed to lv_tiny_ttf_create_data(). */")
|
|
h.append("extern const unsigned char icon_font_data[];")
|
|
h.append("extern const size_t icon_font_data_size;\n")
|
|
h.append("/* Each icon is a single glyph as a UTF-8 string -- usable as label text")
|
|
h.append(" * in any font created from icon_font_data. */")
|
|
for macro, cp in resolved.items():
|
|
h.append(f'#define {macro:<24} "{utf8_escape(cp)}" /* U+{cp:05X} */')
|
|
h.append("\n#endif")
|
|
OUT_H.write_text("\n".join(h) + "\n")
|
|
|
|
print(f"wrote {OUT_C.relative_to(REPO)}")
|
|
print(f"wrote {OUT_H.relative_to(REPO)}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|