diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 918fde5dbd..a2fad4bc25 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -726,7 +726,8 @@ async def to_code(config): ) ] - glyphs = cg.static_const_array(config[CONF_RAW_GLYPH_ID], glyph_initializer) + # constexpr Glyph table in flash; on ESP8266 static_const_array would place it in RAM. + glyphs = cg.progmem_array(config[CONF_RAW_GLYPH_ID], glyph_initializer) font_height = pt_to_px(base_font.size.height) ascender = pt_to_px(base_font.size.ascender) diff --git a/esphome/components/font/font.cpp b/esphome/components/font/font.cpp index fda9c269e5..1ed946dadb 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -309,7 +309,8 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo // Unknown char, skip ESP_LOGW(TAG, "Codepoint 0x%08" PRIx32 " not found in font", code_point); if (!this->glyphs_.empty()) { - uint8_t glyph_width = this->glyphs_[0].advance; + // Full-width read: a narrowing byte load would fault on a PROGMEM table on ESP8266. + int glyph_width = this->glyphs_[0].advance; display->rectangle(x_at, y_start, glyph_width, this->height_, color); x_at += glyph_width; } diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index fa24181bd0..bab7b449ba 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esphome/core/color.h" #include "esphome/core/datatypes.h" #include "esphome/core/defines.h" @@ -36,6 +38,18 @@ class Glyph final { int width; int height; }; +// The glyph table lives in flash, read with plain loads, which ESP8266 only allows for whole words. +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +#ifdef USE_ESP8266 +static_assert(alignof(Glyph) == sizeof(uint32_t), "Glyph fields must stay word sized"); +static_assert(sizeof(Glyph) == 7 * sizeof(uint32_t), "Glyph is read from flash with word loads"); +#endif class Font final #ifdef USE_DISPLAY diff --git a/tests/component_tests/font/config/glyph_table.yaml b/tests/component_tests/font/config/glyph_table.yaml new file mode 100644 index 0000000000..54776e6b6e --- /dev/null +++ b/tests/component_tests/font/config/glyph_table.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +font: + - file: ../NotoSans-Regular.ttf + id: noto + size: 12 + glyphs: "AB" diff --git a/tests/component_tests/font/test_font.py b/tests/component_tests/font/test_font.py index 55e27ae84c..8de0c0a22c 100644 --- a/tests/component_tests/font/test_font.py +++ b/tests/component_tests/font/test_font.py @@ -4,8 +4,10 @@ Focuses on verifying that long multi-byte (Chinese/CJK) glyph strings are correctly processed through the font configuration pipeline. """ +from collections.abc import Callable import functools from pathlib import Path +import re from unittest.mock import MagicMock, patch import pytest @@ -260,15 +262,12 @@ def mock_cg(): with ( patch("esphome.components.font.cg.add_define") as mock_define, patch("esphome.components.font.cg.progmem_array") as mock_progmem, - patch("esphome.components.font.cg.static_const_array") as mock_static, patch("esphome.components.font.cg.new_Pvariable") as mock_new_pvar, ): mock_progmem.return_value = MagicMock() - mock_static.return_value = MagicMock() yield { "add_define": mock_define, "progmem_array": mock_progmem, - "static_const_array": mock_static, "new_Pvariable": mock_new_pvar, } @@ -288,13 +287,13 @@ async def test_to_code_long_latin_generates_all_glyphs(mock_cg): mock_cg["add_define"].assert_any_call("USE_FONT") # progmem_array receives the combined bitmap data (non-empty) - mock_cg["progmem_array"].assert_called_once() - bitmap_data = mock_cg["progmem_array"].call_args.args[1] + # bitmap data first, then the glyph table + assert mock_cg["progmem_array"].call_count == 2 + bitmap_data = mock_cg["progmem_array"].call_args_list[0].args[1] assert len(bitmap_data) > 0 - # static_const_array receives one entry per unique glyph - mock_cg["static_const_array"].assert_called_once() - glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + # the glyph table receives one entry per unique glyph + glyph_initializer = mock_cg["progmem_array"].call_args_list[1].args[1] assert len(glyph_initializer) == glyph_count # new_Pvariable is called with the correct glyph count @@ -314,7 +313,7 @@ async def test_to_code_glyph_entries_contain_expected_fields(mock_cg): await to_code(config) - glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + glyph_initializer = mock_cg["progmem_array"].call_args_list[1].args[1] for entry in glyph_initializer: assert len(entry) == 7, f"Glyph entry should have 7 fields, got {len(entry)}" codepoint = entry[0] @@ -332,6 +331,17 @@ async def test_to_code_glyphs_sorted_by_utf8(mock_cg): await to_code(config) - glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + glyph_initializer = mock_cg["progmem_array"].call_args_list[1].args[1] codepoints = [entry[0] for entry in glyph_initializer] assert codepoints == sorted(codepoints) + + +def test_glyph_table_is_progmem( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The constexpr glyph table goes to flash, not a RAM static const array.""" + main_cpp = generate_main(component_config_path("glyph_table.yaml")) + + assert re.search(r"static constexpr font::Glyph \w+\[\] PROGMEM = ", main_cpp) + assert "static const font::Glyph" not in main_cpp