fix(display): pin one text layout engine, and give the 5x7 BDF face a size (#539)

* fix(display): pin one text layout engine, and give the 5x7 face a size

Two ways a font could render differently on two machines running the same
code, both found while diagnosing four plugins whose golden images passed on
the machine that generated them and failed everywhere else.

**Layout engine.** `ImageFont.truetype` picks its engine at load time: Raqm
where the host Pillow was built with libraqm, Basic otherwise. The two round
fractional glyph advances differently. `PressStart2P-Regular.ttf` at 8px has
whole-pixel advances, so they agree — which is why most of the fleet matched
everywhere and hid this. `4x6-font.ttf` at 6px does not: glyph positions drift
cumulatively along a run, and the four plugins that draw body text in it
(geochron, of-the-day, christmas-countdown, ledmatrix-weather's almanac) are
exactly the four whose goldens travelled badly.

Every core font load now goes through `src/common/font_layout.load_truetype`,
which pins the Basic engine, so a render depends on the font file and the size
and nothing else. Basic gives up complex-script shaping and kerning pairs;
neither applies to bitmap-grid faces on an LED panel. Output is unchanged on a
host without libraqm.

**Zero font height.** `DisplayManager` built the 5x7 BDF face with
`freetype.Face(path)` and never called `set_char_size`, so `face.size.height`
stayed 0 and `get_font_height()` returned 0 for it — callers stacking rows by
`prev_y + prev_height + gap` drew two lines on top of each other. The
start-up line `Calendar font size: 0 pixels` has been printing the symptom all
along. `font_manager._load_bdf_font` already called `set_char_size`, so
whether measurement worked depended on which path loaded the face.

`DisplayManager` now sets it too, and `get_font_height()` falls back to the
strike the file declares rather than returning a zero line height.

Fixes ChuckBuilds/ledmatrix-plugins#397
Refs ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(display): give the startup banner a rung that fits a full address at 64px

CI caught what pinning the layout engine exposed rather than caused.
`_fitting_font` walks PressStart2P then 4x6 at 6px, and "255.255.255.255" --
the widest thing the startup banner ever shows -- measures 66px at 4x6/6px
against the 62 a 64x32 panel has to give. It used to squeak in only because
the measurement depended on which layout engine the host Pillow happened to
have; with the engine pinned it does not, so the rung the worst case actually
needs is now in the ladder instead of implied: 4x6 at 5px, which measures 51.

The fallback was wrong in the same place. When nothing in the ladder fit, it
returned `self.font` -- the *widest* option, and precisely how "Initializing"
came to run off the side of a 64px panel to begin with. It returns the
narrowest face that loaded now.

test/test_initializing_screen.py: 34 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(display): name the exceptions the BDF strike read can raise

Codacy flagged the try/except/pass. It was already narrow in intent -- a
malformed strike table on the measurement path must degrade to "size unknown"
rather than take the display down -- but a bare `except Exception: pass` says
neither of those things and hides a genuinely broken font behind a silent 8px
fallback. It now catches what reading `available_sizes` can actually raise and
logs which face failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: drop logo PNGs the render harness downloaded into the worktree

These are fetched at runtime by the logo cache; they are not source, and they
rode in on a `git add -A` while I was running check_plugin.py against this
branch. Nothing in the change needs them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-08 09:40:00 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent e23f1f45d3
commit 968b953a51
11 changed files with 150 additions and 39 deletions
+3 -1
View File
@@ -123,6 +123,8 @@ def demo(hardware, target, seconds):
"""Scroll text at the crisp speed nearest `target`.""" """Scroll text at the crisp speed nearest `target`."""
from PIL import Image, ImageDraw, ImageFont 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) hz = float(hardware.get("limit_refresh_rate_hz") or scroll_config.DEFAULT_REFRESH_HZ)
choice = scroll_config.solve_crisp(target, hz) choice = scroll_config.solve_crisp(target, hz)
print("asked for {:.0f} px/s -> {}".format(target, choice.describe())) 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), ("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 26),
): ):
try: try:
font = ImageFont.truetype(path, size) font = load_truetype(path, size)
break break
except OSError: except OSError:
continue continue
+8 -6
View File
@@ -27,6 +27,8 @@ sys.path.insert(0, str(PROJECT_ROOT))
from PIL import Image, ImageDraw, ImageFont # noqa: E402 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" FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
MODES = ("live", "recent", "upcoming") MODES = ("live", "recent", "upcoming")
SPORTS = ("baseball", "basketball", "football", "hockey") SPORTS = ("baseball", "basketball", "football", "hockey")
@@ -52,12 +54,12 @@ class FixtureHost:
try: try:
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf") press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf") small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
fonts['score'] = ImageFont.truetype(press, 10) fonts['score'] = load_truetype(press, 10)
fonts['time'] = ImageFont.truetype(press, 8) fonts['time'] = load_truetype(press, 8)
fonts['team'] = ImageFont.truetype(press, 8) fonts['team'] = load_truetype(press, 8)
fonts['status'] = ImageFont.truetype(small, 6) fonts['status'] = load_truetype(small, 6)
fonts['detail'] = ImageFont.truetype(small, 6) fonts['detail'] = load_truetype(small, 6)
fonts['rank'] = ImageFont.truetype(press, 10) fonts['rank'] = load_truetype(press, 10)
except IOError: except IOError:
default = ImageFont.load_default() default = ImageFont.load_default()
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'): for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
+11 -10
View File
@@ -15,6 +15,7 @@ from typing import Any, Dict, List, Optional, Tuple
import pytz import pytz
import requests import requests
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
@@ -449,12 +450,12 @@ class SportsCore(ABC):
press_start = self._resolve_font_path("PressStart2P-Regular.ttf") press_start = self._resolve_font_path("PressStart2P-Regular.ttf")
four_by_six = self._resolve_font_path("4x6-font.ttf") four_by_six = self._resolve_font_path("4x6-font.ttf")
try: try:
fonts['score'] = ImageFont.truetype(press_start, 10) fonts['score'] = load_truetype(press_start, 10)
fonts['time'] = ImageFont.truetype(press_start, 8) fonts['time'] = load_truetype(press_start, 8)
fonts['team'] = ImageFont.truetype(press_start, 8) fonts['team'] = load_truetype(press_start, 8)
fonts['status'] = ImageFont.truetype(four_by_six, 6) # Using 4x6 for status fonts['status'] = load_truetype(four_by_six, 6) # Using 4x6 for status
fonts['detail'] = ImageFont.truetype(four_by_six, 6) # Added detail font fonts['detail'] = load_truetype(four_by_six, 6) # Added detail font
fonts['rank'] = ImageFont.truetype(press_start, 10) fonts['rank'] = load_truetype(press_start, 10)
self.logger.info("Successfully loaded fonts") self.logger.info("Successfully loaded fonts")
except OSError: except OSError:
# Name the directory we searched: the usual cause is an install # Name the directory we searched: the usual cause is an install
@@ -1031,7 +1032,7 @@ class SportsCore(ABC):
if os.path.exists(font_path): if os.path.exists(font_path):
# Try loading as TTF first (works for both TTF and some BDF files with PIL) # Try loading as TTF first (works for both TTF and some BDF files with PIL)
if font_path.lower().endswith('.ttf'): 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.logger.debug(f"Loaded font: {font_name} at size {font_size}")
self._font_cache[cache_key] = font self._font_cache[cache_key] = font
return font return font
@@ -1049,7 +1050,7 @@ class SportsCore(ABC):
# correct one: the newer copies call truetype() on a BDF at # correct one: the newer copies call truetype() on a BDF at
# any size (which simply fails) or refuse BDF outright. # any size (which simply fails) or refuse BDF outright.
try: 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.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}")
self._font_cache[cache_key] = font self._font_cache[cache_key] = font
return font return font
@@ -1061,7 +1062,7 @@ class SportsCore(ABC):
self._bdf_native_size_cache[font_path] = native_size self._bdf_native_size_cache[font_path] = native_size
if native_size and native_size != font_size: if native_size and native_size != font_size:
try: try:
font = ImageFont.truetype(font_path, native_size) font = load_truetype(font_path, native_size)
self.logger.debug( self.logger.debug(
f"Loaded BDF font: {font_name} at its native size {native_size} " f"Loaded BDF font: {font_name} at its native size {native_size} "
f"(requested {font_size} isn't a valid strike for this file)" f"(requested {font_size} isn't a valid strike for this file)"
@@ -1089,7 +1090,7 @@ class SportsCore(ABC):
_resolve_font_family_alias(base_default)) _resolve_font_family_alias(base_default))
try: try:
if os.path.exists(default_font_path): if os.path.exists(default_font_path):
font = ImageFont.truetype(default_font_path, font_size) font = load_truetype(default_font_path, font_size)
else: else:
self.logger.warning("Default font not found, using PIL default") self.logger.warning("Default font not found, using PIL default")
font = ImageFont.load_default() font = ImageFont.load_default()
+43
View File
@@ -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)
+5 -4
View File
@@ -86,6 +86,7 @@ from typing import Any, ClassVar, Dict, List, Optional, Tuple
import pytz import pytz
import requests import requests
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -712,11 +713,11 @@ class SportsCoreSharedMixin:
while size > grid: while size > grid:
if probe.textlength( if probe.textlength(
self._SCORE_PROBE_TEXT, self._SCORE_PROBE_TEXT,
font=ImageFont.truetype(path, size)) <= budget: font=load_truetype(path, size)) <= budget:
break break
size -= grid size -= grid
if size != getattr(fonts['score'], 'size', size): if size != getattr(fonts['score'], 'size', size):
fonts['score'] = ImageFont.truetype(path, size) fonts['score'] = load_truetype(path, size)
self._score_grew = True self._score_grew = True
if not self._score_grew and not self._user_chose_size('score_text') \ if not self._score_grew and not self._user_chose_size('score_text') \
@@ -736,7 +737,7 @@ class SportsCoreSharedMixin:
if _size <= current: if _size <= current:
continue continue
_path = _resolve_font_path(f"assets/fonts/{_name}") _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, if probe.textlength(self._SCORE_PROBE_TEXT,
font=_candidate) <= budget: font=_candidate) <= budget:
fonts['score'] = _candidate fonts['score'] = _candidate
@@ -751,7 +752,7 @@ class SportsCoreSharedMixin:
if ceiling and size >= ceiling: if ceiling and size >= ceiling:
size = max(grid, ceiling - grid) size = max(grid, ceiling - grid)
if size != getattr(fonts['time'], 'size', size): if size != getattr(fonts['time'], 'size', size):
fonts['time'] = ImageFont.truetype(path, size) fonts['time'] = load_truetype(path, size)
except Exception: except Exception:
self.logger.debug("Headline font scaling skipped", exc_info=True) self.logger.debug("Headline font scaling skipped", exc_info=True)
return fonts return fonts
+2 -1
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union from typing import Dict, List, Optional, Tuple, Union
from PIL import Image, ImageDraw, ImageFont 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. # Shared throwaway draw surface for measuring text without a target canvas.
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1))) _measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
@@ -60,7 +61,7 @@ class TextHelper:
size = config['size'] size = config['size']
if font_path.exists(): if font_path.exists():
font = ImageFont.truetype(str(font_path), size) font = load_truetype(str(font_path), size)
fonts[font_name] = font fonts[font_name] = font
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})") self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
else: else:
+66 -9
View File
@@ -34,6 +34,7 @@ else:
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
import threading import threading
import time import time
from collections import OrderedDict from collections import OrderedDict
@@ -55,6 +56,31 @@ from src.common.permission_utils import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) # Set to INFO level 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: class _LogicalMatrix:
"""Proxy that reports a logical (per-screen) size for a physical matrix. """Proxy that reports a logical (per-screen) size for a physical matrix.
@@ -377,7 +403,7 @@ class DisplayManager:
# Initialize font with Press Start 2P # Initialize font with Press Start 2P
try: 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") logger.info("Initial Press Start 2P font loaded successfully")
except Exception as e: except Exception as e:
logger.error(f"Failed to load initial font: {e}") logger.error(f"Failed to load initial font: {e}")
@@ -557,19 +583,33 @@ class DisplayManager:
pass pass
def _fitting_font(self, lines, width): 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, 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: for candidate in candidates:
try: try:
font = candidate font = candidate
if isinstance(candidate, tuple): 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): if all(self.draw.textlength(t, font=font) <= width for t in lines):
return font return font
except (OSError, ValueError, AttributeError): except (OSError, ValueError, AttributeError):
continue 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: def _draw_startup_banner(self, lines, width: int, height: int) -> None:
"""Centre `lines` over whatever the test pattern already drew. """Centre `lines` over whatever the test pattern already drew.
@@ -933,11 +973,11 @@ class DisplayManager:
self._text_width_cache.clear() self._text_width_cache.clear()
try: try:
# Load Press Start 2P font # 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") logger.info("Press Start 2P font loaded successfully")
# Use the same font for small text (currently same size; adjust size here if needed) # 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") logger.info("Press Start 2P small font loaded successfully")
# Load 5x7 BDF font for calendar events # Load 5x7 BDF font for calendar events
@@ -950,6 +990,17 @@ class DisplayManager:
# Load with freetype for proper BDF handling # Load with freetype for proper BDF handling
face = freetype.Face(self.calendar_font_path) 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"5x7 calendar font loaded successfully from {self.calendar_font_path}")
logger.info(f"Calendar font size: {face.size.height >> 6} pixels") logger.info(f"Calendar font size: {face.size.height >> 6} pixels")
@@ -970,7 +1021,7 @@ class DisplayManager:
try: try:
font_path = "assets/fonts/4x6-font.ttf" font_path = "assets/fonts/4x6-font.ttf"
logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size 6") 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}") logger.info(f"4x6 TTF extra small font loaded successfully from {font_path}")
except Exception as font_err: except Exception as font_err:
logger.error(f"Failed to load 4x6 TTF font: {font_err}. Falling back.") logger.error(f"Failed to load 4x6 TTF font: {font_err}. Falling back.")
@@ -1028,7 +1079,13 @@ class DisplayManager:
try: try:
if isinstance(font, freetype.Face): if isinstance(font, freetype.Face):
# For FreeType faces (BDF), the 'height' metric gives the recommended line spacing. # 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: else:
# For PIL TTF fonts, getmetrics() provides ascent and descent. # For PIL TTF fonts, getmetrics() provides ascent and descent.
# The line height is the sum of ascent and descent. # The line height is the sum of ascent and descent.
+3 -2
View File
@@ -51,6 +51,7 @@ from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union from typing import Any, Dict, Optional, Tuple, Union
from PIL import ImageFont from PIL import ImageFont
from src.common.font_layout import load_truetype
try: try:
import freetype 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) face.set_char_size(size * 64, size * 64, 72, 72)
font: Any = face font: Any = face
else: else:
font = ImageFont.truetype(path, size) font = load_truetype(path, size)
except Exception as e: except Exception as e:
logger.warning("Error loading font %s at %spx: %s, using fallback", logger.warning("Error loading font %s at %spx: %s, using fallback",
path, size, e) path, size, e)
@@ -174,7 +175,7 @@ def _load_fallback_font(size: int) -> Any:
if cached is not None: if cached is not None:
return cached return cached
try: try:
font = ImageFont.truetype(path, size) font = load_truetype(path, size)
_font_cache[cache_key] = font _font_cache[cache_key] = font
return font return font
except Exception as e: except Exception as e:
+3 -2
View File
@@ -38,6 +38,7 @@ import time
from collections import OrderedDict from collections import OrderedDict
from pathlib import Path from pathlib import Path
from PIL import ImageFont from PIL import ImageFont
from src.common.font_layout import load_truetype
from typing import Dict, Tuple, Optional, Union, Any, List from typing import Dict, Tuple, Optional, Union, Any, List
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -479,7 +480,7 @@ class FontManager:
if font_path.endswith('.bdf'): if font_path.endswith('.bdf'):
font = self._load_bdf_font(font_path, size_px) font = self._load_bdf_font(font_path, size_px)
else: else:
font = ImageFont.truetype(font_path, size_px) font = load_truetype(font_path, size_px)
except Exception as e: except Exception as e:
logger.error(f"Error loading font {font_path}: {e}") logger.error(f"Error loading font {font_path}: {e}")
self.performance_stats["failed_loads"] += 1 self.performance_stats["failed_loads"] += 1
@@ -859,7 +860,7 @@ class FontManager:
return {"valid": True, "type": "bdf", "family": "unknown"} return {"valid": True, "type": "bdf", "family": "unknown"}
elif font_path.endswith('.ttf'): elif font_path.endswith('.ttf'):
# Try to load TTF font # Try to load TTF font
ImageFont.truetype(font_path, 12) load_truetype(font_path, 12)
return {"valid": True, "type": "ttf", "family": "unknown"} return {"valid": True, "type": "ttf", "family": "unknown"}
else: else:
return {"valid": False, "error": "Unsupported font format"} return {"valid": False, "error": "Unsupported font format"}
+2 -1
View File
@@ -14,6 +14,7 @@ import json
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from PIL.PngImagePlugin import PngInfo from PIL.PngImagePlugin import PngInfo
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
@@ -747,7 +748,7 @@ class LogoDownloader:
# Try to load a font, fallback to default # Try to load a font, fallback to default
try: try:
font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
except (OSError, IOError): except (OSError, IOError):
try: try:
font = ImageFont.load_default() font = ImageFont.load_default()
@@ -31,6 +31,7 @@ from pathlib import Path
from typing import Any, List, Optional, Tuple from typing import Any, List, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from src.logging_config import get_logger from src.logging_config import get_logger
@@ -141,8 +142,8 @@ class VisualTestDisplayManager:
# Press Start 2P — regular and small (both 8px) # Press Start 2P — regular and small (both 8px)
ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf') ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf')
self.regular_font = ImageFont.truetype(ttf_path, 8) self.regular_font = load_truetype(ttf_path, 8)
self.small_font = ImageFont.truetype(ttf_path, 8) self.small_font = load_truetype(ttf_path, 8)
self.font = self.regular_font # alias used by some code paths self.font = self.regular_font # alias used by some code paths
# 5x7 BDF font via freetype # 5x7 BDF font via freetype
@@ -162,7 +163,7 @@ class VisualTestDisplayManager:
# 4x6 extra small TTF # 4x6 extra small TTF
try: try:
xs_path = str(fonts_dir / '4x6-font.ttf') 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: except (FileNotFoundError, OSError) as e:
logger.debug("Extra small font not available, using fallback: %s", e) logger.debug("Extra small font not available, using fallback: %s", e)
self.extra_small_font = self.small_font self.extra_small_font = self.small_font