Files
LEDMatrix/src/common/text_helper.py
T
ChuckandClaude Opus 5 968b953a51 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>
2026-09-08 09:40:00 -04:00

311 lines
11 KiB
Python

"""
Text Helper
Handles text rendering with outlines, fonts, and positioning for LED matrix displays.
Extracted from LEDMatrix core to provide reusable functionality for plugins.
"""
import logging
from pathlib import Path
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)))
class TextHelper:
"""
Helper class for text rendering with outlines and font management.
Provides functionality for:
- Loading and managing fonts
- Drawing text with outlines for better readability
- Calculating text dimensions and positioning
- Managing font resources
"""
def __init__(self, font_dir: Optional[Union[str, Path]] = None,
logger: Optional[logging.Logger] = None):
"""
Initialize the TextHelper.
Args:
font_dir: Directory containing font files (defaults to assets/fonts)
logger: Optional logger instance
"""
self.logger = logger or logging.getLogger(__name__)
self.font_dir = Path(font_dir) if font_dir else Path("assets/fonts")
self._font_cache: Dict[str, ImageFont.ImageFont] = {}
def load_fonts(self, font_config: Optional[Dict[str, Dict]] = None) -> Dict[str, ImageFont.ImageFont]:
"""
Load fonts for different text elements.
Args:
font_config: Custom font configuration dictionary
Returns:
Dictionary mapping font names to PIL ImageFont objects
"""
if font_config is None:
font_config = self._get_default_font_config()
fonts = {}
for font_name, config in font_config.items():
try:
font_path = self.font_dir / config['file']
size = config['size']
if font_path.exists():
font = load_truetype(str(font_path), size)
fonts[font_name] = font
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
else:
# Fallback to default font
font = ImageFont.load_default()
fonts[font_name] = font
self.logger.warning(f"Font file not found: {font_path}, using default")
except Exception as e:
self.logger.error(f"Error loading font {font_name}: {e}")
fonts[font_name] = ImageFont.load_default()
return fonts
def draw_text_with_outline(self, draw: ImageDraw.ImageDraw, text: str,
position: Tuple[int, int], font: ImageFont.ImageFont,
fill: Tuple[int, int, int] = (255, 255, 255),
outline_color: Tuple[int, int, int] = (0, 0, 0),
outline_width: int = 1) -> None:
"""
Draw text with an outline for better readability on LED displays.
Args:
draw: PIL ImageDraw object
text: Text to draw
position: (x, y) position tuple
font: PIL ImageFont object
fill: Text color (R, G, B)
outline_color: Outline color (R, G, B)
outline_width: Width of outline in pixels
"""
x, y = position
# Draw outline by drawing text in outline color at offset positions
for dx in range(-outline_width, outline_width + 1):
for dy in range(-outline_width, outline_width + 1):
if dx != 0 or dy != 0: # Skip center position
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
# Draw main text
draw.text((x, y), text, font=font, fill=fill)
def get_text_width(self, text: str, font: ImageFont.ImageFont) -> int:
"""
Get the width of text when rendered with the given font.
Args:
text: Text to measure
font: PIL ImageFont object
Returns:
Width in pixels
"""
try:
return int(_measure_draw.textlength(text, font=font))
except AttributeError:
# Fallback for older PIL versions
bbox = _measure_draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0]
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
"""
Get the height of text when rendered with the given font.
Args:
text: Text to measure
font: PIL ImageFont object
Returns:
Height in pixels
"""
bbox = _measure_draw.textbbox((0, 0), text, font=font)
return bbox[3] - bbox[1]
def get_text_dimensions(self, text: str, font: ImageFont.ImageFont) -> Tuple[int, int]:
"""
Get both width and height of text.
Args:
text: Text to measure
font: PIL ImageFont object
Returns:
(width, height) tuple
"""
return (self.get_text_width(text, font), self.get_text_height(text, font))
def center_text(self, text: str, font: ImageFont.ImageFont,
container_width: int, container_height: int) -> Tuple[int, int]:
"""
Calculate position to center text within a container.
Args:
text: Text to center
font: PIL ImageFont object
container_width: Width of container
container_height: Height of container
Returns:
(x, y) position tuple for centered text
"""
text_width, text_height = self.get_text_dimensions(text, font)
x = (container_width - text_width) // 2
y = (container_height - text_height) // 2
return (x, y)
def wrap_text(self, text: str, font: ImageFont.ImageFont,
max_width: int, max_lines: Optional[int] = None) -> List[str]:
"""
Wrap text to fit within specified width.
Args:
text: Text to wrap
font: PIL ImageFont object
max_width: Maximum width in pixels
max_lines: Maximum number of lines (None for unlimited)
Returns:
List of text lines
"""
words = text.split()
lines = []
current_line = []
for word in words:
# Test if adding this word would exceed width
test_line = ' '.join(current_line + [word])
if self.get_text_width(test_line, font) <= max_width:
current_line.append(word)
else:
# Start new line
if current_line:
lines.append(' '.join(current_line))
current_line = [word]
else:
# Single word is too long, add it anyway
lines.append(word)
# Add remaining words
if current_line:
lines.append(' '.join(current_line))
# Limit lines if specified
if max_lines is not None:
lines = lines[:max_lines]
return lines
def draw_multiline_text(self, draw: ImageDraw.ImageDraw, text: str,
position: Tuple[int, int], font: ImageFont.ImageFont,
line_spacing: int = 2, **kwargs) -> None:
"""
Draw multiline text with proper spacing.
Args:
draw: PIL ImageDraw object
text: Text to draw (can contain newlines)
position: Starting (x, y) position
font: PIL ImageFont object
line_spacing: Pixels between lines
**kwargs: Additional arguments for draw_text_with_outline
"""
x, y = position
lines = text.split('\n')
for line in lines:
if line.strip(): # Skip empty lines
self.draw_text_with_outline(draw, line, (x, y), font, **kwargs)
y += self.get_text_height(line, font) + line_spacing
def create_text_image(self, text: str, font: ImageFont.ImageFont,
background_color: Tuple[int, int, int] = (0, 0, 0),
text_color: Tuple[int, int, int] = (255, 255, 255),
padding: int = 5) -> Image.Image:
"""
Create an image containing only the specified text.
Args:
text: Text to render
font: PIL ImageFont object
background_color: Background color (R, G, B)
text_color: Text color (R, G, B)
padding: Padding around text in pixels
Returns:
PIL Image containing the text
"""
# Calculate dimensions
text_width, text_height = self.get_text_dimensions(text, font)
img_width = text_width + (padding * 2)
img_height = text_height + (padding * 2)
# Create image
img = Image.new('RGB', (img_width, img_height), background_color)
draw = ImageDraw.Draw(img)
# Draw text
self.draw_text_with_outline(draw, text, (padding, padding), font,
fill=text_color)
return img
def _get_default_font_config(self) -> Dict[str, Dict]:
"""Get default font configuration."""
return {
'score': {
'file': 'PressStart2P-Regular.ttf',
'size': 10
},
'time': {
'file': 'PressStart2P-Regular.ttf',
'size': 8
},
'team': {
'file': 'PressStart2P-Regular.ttf',
'size': 8
},
'status': {
'file': '4x6-font.ttf',
'size': 6
},
'detail': {
'file': '4x6-font.ttf',
'size': 6
},
'rank': {
'file': 'PressStart2P-Regular.ttf',
'size': 10
}
}
def clear_font_cache(self) -> None:
"""Clear the font cache."""
self._font_cache.clear()
self.logger.debug("Font cache cleared")
def get_font_cache_stats(self) -> Dict[str, int]:
"""
Get font cache statistics.
Returns:
Dictionary with cache statistics
"""
return {
'cached_fonts': len(self._font_cache)
}