mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
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:
@@ -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)
|
||||
@@ -86,6 +86,7 @@ from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
||||
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 @@ class SportsCoreSharedMixin:
|
||||
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 @@ class SportsCoreSharedMixin:
|
||||
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 @@ class SportsCoreSharedMixin:
|
||||
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
|
||||
|
||||
@@ -10,6 +10,7 @@ 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)))
|
||||
@@ -60,7 +61,7 @@ class TextHelper:
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user