diff --git a/scripts/scroll_speeds.py b/scripts/scroll_speeds.py index a142b433..57824eb6 100644 --- a/scripts/scroll_speeds.py +++ b/scripts/scroll_speeds.py @@ -123,6 +123,8 @@ def demo(hardware, target, seconds): """Scroll text at the crisp speed nearest `target`.""" from PIL import Image, ImageDraw, ImageFont + from src.common.font_layout import load_truetype + hz = float(hardware.get("limit_refresh_rate_hz") or scroll_config.DEFAULT_REFRESH_HZ) choice = scroll_config.solve_crisp(target, hz) print("asked for {:.0f} px/s -> {}".format(target, choice.describe())) @@ -137,7 +139,7 @@ def demo(hardware, target, seconds): ("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 26), ): try: - font = ImageFont.truetype(path, size) + font = load_truetype(path, size) break except OSError: continue diff --git a/scripts/validate_skin.py b/scripts/validate_skin.py index 52d7d61d..53cb6ebe 100644 --- a/scripts/validate_skin.py +++ b/scripts/validate_skin.py @@ -27,6 +27,8 @@ from PIL import Image, ImageDraw, ImageFont # noqa: E402 +from src.common.font_layout import load_truetype # noqa: E402 + FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures" MODES = ("live", "recent", "upcoming") SPORTS = ("baseball", "basketball", "football", "hockey") @@ -52,12 +54,12 @@ def _load_fonts(self) -> dict: try: press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf") small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf") - fonts['score'] = ImageFont.truetype(press, 10) - fonts['time'] = ImageFont.truetype(press, 8) - fonts['team'] = ImageFont.truetype(press, 8) - fonts['status'] = ImageFont.truetype(small, 6) - fonts['detail'] = ImageFont.truetype(small, 6) - fonts['rank'] = ImageFont.truetype(press, 10) + fonts['score'] = load_truetype(press, 10) + fonts['time'] = load_truetype(press, 8) + fonts['team'] = load_truetype(press, 8) + fonts['status'] = load_truetype(small, 6) + fonts['detail'] = load_truetype(small, 6) + fonts['rank'] = load_truetype(press, 10) except IOError: default = ImageFont.load_default() for key in ('score', 'time', 'team', 'status', 'detail', 'rank'): diff --git a/src/base_classes/sports/core.py b/src/base_classes/sports/core.py index 3291a1bf..251ba5cb 100644 --- a/src/base_classes/sports/core.py +++ b/src/base_classes/sports/core.py @@ -15,6 +15,7 @@ import pytz import requests from PIL import Image, ImageDraw, ImageFont +from src.common.font_layout import load_truetype from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry @@ -449,12 +450,12 @@ def _load_fonts(self) -> Dict[str, Any]: press_start = self._resolve_font_path("PressStart2P-Regular.ttf") four_by_six = self._resolve_font_path("4x6-font.ttf") try: - fonts['score'] = ImageFont.truetype(press_start, 10) - fonts['time'] = ImageFont.truetype(press_start, 8) - fonts['team'] = ImageFont.truetype(press_start, 8) - fonts['status'] = ImageFont.truetype(four_by_six, 6) # Using 4x6 for status - fonts['detail'] = ImageFont.truetype(four_by_six, 6) # Added detail font - fonts['rank'] = ImageFont.truetype(press_start, 10) + fonts['score'] = load_truetype(press_start, 10) + fonts['time'] = load_truetype(press_start, 8) + fonts['team'] = load_truetype(press_start, 8) + fonts['status'] = load_truetype(four_by_six, 6) # Using 4x6 for status + fonts['detail'] = load_truetype(four_by_six, 6) # Added detail font + fonts['rank'] = load_truetype(press_start, 10) self.logger.info("Successfully loaded fonts") except OSError: # Name the directory we searched: the usual cause is an install @@ -1031,7 +1032,7 @@ def _load_custom_font_from_element_config( if os.path.exists(font_path): # Try loading as TTF first (works for both TTF and some BDF files with PIL) if font_path.lower().endswith('.ttf'): - font = ImageFont.truetype(font_path, font_size) + font = load_truetype(font_path, font_size) self.logger.debug(f"Loaded font: {font_name} at size {font_size}") self._font_cache[cache_key] = font return font @@ -1049,7 +1050,7 @@ def _load_custom_font_from_element_config( # correct one: the newer copies call truetype() on a BDF at # any size (which simply fails) or refuse BDF outright. try: - font = ImageFont.truetype(font_path, font_size) + font = load_truetype(font_path, font_size) self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}") self._font_cache[cache_key] = font return font @@ -1061,7 +1062,7 @@ def _load_custom_font_from_element_config( self._bdf_native_size_cache[font_path] = native_size if native_size and native_size != font_size: try: - font = ImageFont.truetype(font_path, native_size) + font = load_truetype(font_path, native_size) self.logger.debug( f"Loaded BDF font: {font_name} at its native size {native_size} " f"(requested {font_size} isn't a valid strike for this file)" @@ -1089,7 +1090,7 @@ def _load_custom_font_from_element_config( _resolve_font_family_alias(base_default)) try: if os.path.exists(default_font_path): - font = ImageFont.truetype(default_font_path, font_size) + font = load_truetype(default_font_path, font_size) else: self.logger.warning("Default font not found, using PIL default") font = ImageFont.load_default() diff --git a/src/common/font_layout.py b/src/common/font_layout.py new file mode 100644 index 00000000..dda2cb8b --- /dev/null +++ b/src/common/font_layout.py @@ -0,0 +1,43 @@ +"""One text layout engine, everywhere. + +``PIL.ImageFont.truetype`` picks its layout engine at load time: Raqm when the +host Pillow was built with libraqm, Basic otherwise. The two disagree about +fractional glyph advances, so the *same* Pillow version renders the *same* +string differently depending on a build option of the host. + +That is invisible for ``PressStart2P-Regular.ttf`` at 8px, whose advances are +whole pixels either way — which is why most of the fleet's golden images +matched on every machine. It is not invisible for ``4x6-font.ttf`` at 6px, +where the advances are fractional: glyph positions drift cumulatively along a +run, and the committed goldens for geochron, of-the-day, christmas-countdown +and ledmatrix-weather's almanac passed on the machine that generated them and +failed everywhere else (ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391). + +Pinning the Basic engine makes a render depend on the font file and the size, +and nothing else. Basic gives up complex-script shaping (Arabic, Indic) and +kerning pairs; neither applies to the bitmap-grid faces this project draws +with on an LED panel. + +Use :func:`load_truetype` in place of ``ImageFont.truetype`` anywhere the +result is drawn to a panel or compared against a golden image. +""" + +from __future__ import annotations + +from typing import Any, Union + +from PIL import ImageFont + +#: The engine every core font load pins. Named once so the reason above has a +#: single referent, and so a future change is one line. +LAYOUT_ENGINE = ImageFont.Layout.BASIC + + +def load_truetype(font: Union[str, Any], size: int, **kwargs: Any) -> ImageFont.FreeTypeFont: + """``ImageFont.truetype`` with the layout engine pinned. + + Same signature and same exceptions as the PIL call it replaces, so it is a + drop-in at every call site. + """ + kwargs.setdefault("layout_engine", LAYOUT_ENGINE) + return ImageFont.truetype(font, size, **kwargs) diff --git a/src/common/sports_shared.py b/src/common/sports_shared.py index b120e532..7d41d006 100644 --- a/src/common/sports_shared.py +++ b/src/common/sports_shared.py @@ -86,6 +86,7 @@ import pytz import requests from PIL import Image, ImageDraw, ImageFont +from src.common.font_layout import load_truetype logger = logging.getLogger(__name__) @@ -712,11 +713,11 @@ def _scale_headline_fonts(self, fonts): while size > grid: if probe.textlength( self._SCORE_PROBE_TEXT, - font=ImageFont.truetype(path, size)) <= budget: + font=load_truetype(path, size)) <= budget: break size -= grid if size != getattr(fonts['score'], 'size', size): - fonts['score'] = ImageFont.truetype(path, size) + fonts['score'] = load_truetype(path, size) self._score_grew = True if not self._score_grew and not self._user_chose_size('score_text') \ @@ -736,7 +737,7 @@ def _scale_headline_fonts(self, fonts): if _size <= current: continue _path = _resolve_font_path(f"assets/fonts/{_name}") - _candidate = ImageFont.truetype(_path, _size) + _candidate = load_truetype(_path, _size) if probe.textlength(self._SCORE_PROBE_TEXT, font=_candidate) <= budget: fonts['score'] = _candidate @@ -751,7 +752,7 @@ def _scale_headline_fonts(self, fonts): if ceiling and size >= ceiling: size = max(grid, ceiling - grid) if size != getattr(fonts['time'], 'size', size): - fonts['time'] = ImageFont.truetype(path, size) + fonts['time'] = load_truetype(path, size) except Exception: self.logger.debug("Headline font scaling skipped", exc_info=True) return fonts diff --git a/src/common/text_helper.py b/src/common/text_helper.py index b6a8df3e..703476eb 100644 --- a/src/common/text_helper.py +++ b/src/common/text_helper.py @@ -10,6 +10,7 @@ from typing import Dict, List, Optional, Tuple, Union from PIL import Image, ImageDraw, ImageFont +from src.common.font_layout import load_truetype # Shared throwaway draw surface for measuring text without a target canvas. _measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1))) @@ -60,7 +61,7 @@ def load_fonts(self, font_config: Optional[Dict[str, Dict]] = None) -> Dict[str, size = config['size'] if font_path.exists(): - font = ImageFont.truetype(str(font_path), size) + font = load_truetype(str(font_path), size) fonts[font_name] = font self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})") else: diff --git a/src/display_manager.py b/src/display_manager.py index a5a9e95d..9649cd1f 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -34,6 +34,7 @@ from contextlib import contextmanager from pathlib import Path from PIL import Image, ImageDraw, ImageFont +from src.common.font_layout import load_truetype import threading import time from collections import OrderedDict @@ -55,6 +56,31 @@ logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) # Set to INFO level +#: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed +#: size regardless, but a Face needs an active size before its metrics -- +#: and therefore get_font_height() -- report anything but 0. +_CALENDAR_FONT_PX = 7 + + +def _bdf_native_size(face) -> int: + """The pixel height a BDF Face declares, or 0 if it does not say. + + Used only to rescue a Face that was built without ``set_char_size``, so a + zero line height never reaches layout code. + """ + try: + sizes = getattr(face, "available_sizes", None) or [] + if sizes: + return int(getattr(sizes[0], "height", 0) or 0) + except (AttributeError, IndexError, TypeError, ValueError) as exc: + # This runs on the measurement path for a face the caller already + # holds, so a malformed strike table must degrade to "unknown" rather + # than take the display down. Say which face, so a font that is + # actually broken is diagnosable rather than silently 8px. + logger.debug("Could not read BDF strike size from %r: %s", face, exc) + return 0 + + class _LogicalMatrix: """Proxy that reports a logical (per-screen) size for a physical matrix. @@ -377,7 +403,7 @@ def _setup_matrix(self): # Initialize font with Press Start 2P try: - self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) + self.font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8) logger.info("Initial Press Start 2P font loaded successfully") except Exception as e: logger.error(f"Failed to load initial font: {e}") @@ -557,19 +583,33 @@ def _local_ip() -> Optional[str]: pass def _fitting_font(self, lines, width): - """The largest font from the usual ladder that fits every line.""" + """The largest font from the usual ladder that fits every line. + + The ladder ends at 4x6 at 5px because a full dotted-quad address -- + "255.255.255.255", the widest this screen ever shows -- is 66px at + 6px and a 64px panel has 62 to give it. That used to squeak in only + because the measurement depended on which text layout engine the host + Pillow had; with the engine pinned it does not, so the rung the + worst case actually needs is here rather than implied. + """ candidates = [self.font, - ("assets/fonts/4x6-font.ttf", 6)] + ("assets/fonts/4x6-font.ttf", 6), + ("assets/fonts/4x6-font.ttf", 5)] + narrowest = None for candidate in candidates: try: font = candidate if isinstance(candidate, tuple): - font = ImageFont.truetype(candidate[0], candidate[1]) + font = load_truetype(candidate[0], candidate[1]) + narrowest = font if all(self.draw.textlength(t, font=font) <= width for t in lines): return font except (OSError, ValueError, AttributeError): continue - return self.font + # Nothing fit. Return the smallest face that loaded, not self.font -- + # falling back to the widest option is how "Initializing" ran off the + # side of a 64px panel in the first place. + return narrowest or self.font def _draw_startup_banner(self, lines, width: int, height: int) -> None: """Centre `lines` over whatever the test pattern already drew. @@ -933,11 +973,11 @@ def _load_fonts(self): self._text_width_cache.clear() try: # Load Press Start 2P font - self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) + self.regular_font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8) logger.info("Press Start 2P font loaded successfully") # Use the same font for small text (currently same size; adjust size here if needed) - self.small_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) + self.small_font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8) logger.info("Press Start 2P small font loaded successfully") # Load 5x7 BDF font for calendar events @@ -950,6 +990,17 @@ def _load_fonts(self): # Load with freetype for proper BDF handling face = freetype.Face(self.calendar_font_path) + # A freshly constructed Face has no active size, so + # face.size.height is 0 until set_char_size is called -- and + # get_font_height() reads exactly that. Without this, every + # caller measuring the 5x7 face got 0 and stacked rows on top + # of one another; the "Calendar font size: 0 pixels" line + # below has been printing the symptom on every start-up. + # font_manager._load_bdf_font already does this; the two paths + # disagreed about whether a Face was usable for measurement. + # 5x7.bdf is a fixed strike, so FreeType renders 7px whatever + # is asked for -- this sets the metrics, not the raster. + face.set_char_size(_CALENDAR_FONT_PX * 64, _CALENDAR_FONT_PX * 64, 72, 72) logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}") logger.info(f"Calendar font size: {face.size.height >> 6} pixels") @@ -970,7 +1021,7 @@ def _load_fonts(self): try: font_path = "assets/fonts/4x6-font.ttf" logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size 6") - self.extra_small_font = ImageFont.truetype(font_path, 6) + self.extra_small_font = load_truetype(font_path, 6) logger.info(f"4x6 TTF extra small font loaded successfully from {font_path}") except Exception as font_err: logger.error(f"Failed to load 4x6 TTF font: {font_err}. Falling back.") @@ -1028,7 +1079,13 @@ def get_font_height(self, font): try: if isinstance(font, freetype.Face): # For FreeType faces (BDF), the 'height' metric gives the recommended line spacing. - return font.size.height >> 6 + height = font.size.height >> 6 + if height: + return height + # A Face constructed without set_char_size reports 0, and a + # zero line height collapses every stacked row onto one line. + # Fall back to the strike the file declares. + return _bdf_native_size(font) or 8 else: # For PIL TTF fonts, getmetrics() provides ascent and descent. # The line height is the sum of ascent and descent. diff --git a/src/element_style.py b/src/element_style.py index 1d9a08ea..12b1abb2 100644 --- a/src/element_style.py +++ b/src/element_style.py @@ -51,6 +51,7 @@ from typing import Any, Dict, Optional, Tuple, Union from PIL import ImageFont +from src.common.font_layout import load_truetype try: import freetype @@ -155,7 +156,7 @@ def load_font(font_name: str, size: int) -> Any: face.set_char_size(size * 64, size * 64, 72, 72) font: Any = face else: - font = ImageFont.truetype(path, size) + font = load_truetype(path, size) except Exception as e: logger.warning("Error loading font %s at %spx: %s, using fallback", path, size, e) @@ -174,7 +175,7 @@ def _load_fallback_font(size: int) -> Any: if cached is not None: return cached try: - font = ImageFont.truetype(path, size) + font = load_truetype(path, size) _font_cache[cache_key] = font return font except Exception as e: diff --git a/src/font_manager.py b/src/font_manager.py index 93640808..9ddbad9d 100644 --- a/src/font_manager.py +++ b/src/font_manager.py @@ -38,6 +38,7 @@ from collections import OrderedDict from pathlib import Path from PIL import ImageFont +from src.common.font_layout import load_truetype from typing import Dict, Tuple, Optional, Union, Any, List logger = logging.getLogger(__name__) @@ -479,7 +480,7 @@ def get_font(self, family: str, size_px: int) -> Union[ImageFont.FreeTypeFont, f if font_path.endswith('.bdf'): font = self._load_bdf_font(font_path, size_px) else: - font = ImageFont.truetype(font_path, size_px) + font = load_truetype(font_path, size_px) except Exception as e: logger.error(f"Error loading font {font_path}: {e}") self.performance_stats["failed_loads"] += 1 @@ -859,7 +860,7 @@ def validate_font(self, font_path: str) -> Dict[str, Any]: return {"valid": True, "type": "bdf", "family": "unknown"} elif font_path.endswith('.ttf'): # Try to load TTF font - ImageFont.truetype(font_path, 12) + load_truetype(font_path, 12) return {"valid": True, "type": "ttf", "family": "unknown"} else: return {"valid": False, "error": "Unsupported font format"} diff --git a/src/logo_downloader.py b/src/logo_downloader.py index d6fbd7bc..43b8ff1a 100644 --- a/src/logo_downloader.py +++ b/src/logo_downloader.py @@ -14,6 +14,7 @@ from typing import Dict, List, Optional, Tuple from pathlib import Path from PIL import Image, ImageDraw, ImageFont +from src.common.font_layout import load_truetype from PIL.PngImagePlugin import PngInfo from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry @@ -747,7 +748,7 @@ def create_placeholder_logo(self, team_abbreviation: str, logo_dir: str) -> bool # Try to load a font, fallback to default try: - font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) + font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 12) except (OSError, IOError): try: font = ImageFont.load_default() diff --git a/src/plugin_system/testing/visual_display_manager.py b/src/plugin_system/testing/visual_display_manager.py index ccfed7c3..a713bf31 100644 --- a/src/plugin_system/testing/visual_display_manager.py +++ b/src/plugin_system/testing/visual_display_manager.py @@ -31,6 +31,7 @@ from typing import Any, List, Optional, Tuple from PIL import Image, ImageDraw, ImageFont +from src.common.font_layout import load_truetype from src.logging_config import get_logger @@ -141,8 +142,8 @@ def _load_fonts(self): # Press Start 2P — regular and small (both 8px) ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf') - self.regular_font = ImageFont.truetype(ttf_path, 8) - self.small_font = ImageFont.truetype(ttf_path, 8) + self.regular_font = load_truetype(ttf_path, 8) + self.small_font = load_truetype(ttf_path, 8) self.font = self.regular_font # alias used by some code paths # 5x7 BDF font via freetype @@ -162,7 +163,7 @@ def _load_fonts(self): # 4x6 extra small TTF try: xs_path = str(fonts_dir / '4x6-font.ttf') - self.extra_small_font = ImageFont.truetype(xs_path, 6) + self.extra_small_font = load_truetype(xs_path, 6) except (FileNotFoundError, OSError) as e: logger.debug("Extra small font not available, using fallback: %s", e) self.extra_small_font = self.small_font