Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion scripts/scroll_speeds.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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()))
Expand All@@ -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
Expand Down
14 changes: 8 additions & 6 deletions scripts/validate_skin.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
Expand All@@ -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'):
Expand Down
21 changes: 11 additions & 10 deletions src/base_classes/sports/core.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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
Expand All@@ -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
Expand All@@ -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)"
Expand DownExpand Up@@ -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()
Expand Down
43 changes: 43 additions & 0 deletions src/common/font_layout.py
Original file line numberDiff line numberDiff line change
@@ -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)
9 changes: 5 additions & 4 deletions src/common/sports_shared.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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__)

Expand DownExpand Up@@ -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') \
Expand All@@ -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
Expand All@@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/common/text_helper.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)))
Expand DownExpand Up@@ -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:
Expand Down
75 changes: 66 additions & 9 deletions src/display_manager.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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.
Expand DownExpand Up@@ -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}")
Expand DownExpand Up@@ -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.
Expand DownExpand Up@@ -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
Expand All@@ -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")

Expand All@@ -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.")
Expand DownExpand Up@@ -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.
Expand Down
5 changes: 3 additions & 2 deletions src/element_style.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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)
Expand All@@ -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:
Expand Down
Loading
Loading