fix(fonts): load 4x6 on its pixel grid, from any working directory (#565)

* fix(fonts): load 4x6 on its pixel grid, from any working directory

`extra_small_font` loaded 4x6-font.ttf at 6, off the face's 7px grid.
Under `draw.fontmode = "1"` the mono rasteriser thresholds each glyph at
50% coverage, so every glyph lost its fourth column and deformed:
christmas-countdown rendered "UNTIL" as "VM1JL". The advance is 5px at
both sizes, so snapping to 7 reflows nothing.

- Sizes in DisplayManager._load_fonts go through crisp_size() instead of
  literals. crisp_size / FONT_PIXEL_GRID / FONT_NAME_ALIASES move to
  src/common/font_layout.py; sports_card re-exports them.
- Mirror the fix in VisualTestDisplayManager, the harness's fork of
  _load_fonts. Without it every golden is blessed at the old size.
- Resolve bundled font paths against the install root, not the cwd.
  FontManager._resolve_asset_path now delegates to
  font_layout.resolve_asset_path (kept by name; plugins probe for it).
- The startup banner's middle rung snaps to 7; the 5 rung stays off-grid
  on purpose (the only size that fits a dotted quad on 64px).
- loading.py reads all plugin JSON as UTF-8 (cp1252 on Windows aborted
  check_plugin.py on a 0x9d byte).
- check_plugin.py reports in ASCII and never dies on an unencodable char.

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

* fix(fonts): resolve relative asset paths from the install root, not the cwd

resolve_asset_path checked os.path.exists(relative_path) unconditionally,
so a relative asset path was still resolved against the process cwd first
-- exactly the dependency this module exists to remove. An unrelated
working directory that happens to contain assets/fonts/4x6-font.ttf (a
stale checkout, a copied assets folder, another project) would shadow the
real bundled font instead of the install root ever being consulted.

Only an absolute path is now returned as-is; a relative path always
resolves against _INSTALL_ROOT first, matching the docstring's stated
contract. FontManager._resolve_asset_path delegates to this function, so
it's covered by the same fix.

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-13 10:47:01 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 772258f73e
commit 92ac231138
8 changed files with 395 additions and 70 deletions
+27 -2
View File
@@ -35,6 +35,31 @@ sys.path.insert(0, str(PROJECT_ROOT))
os.environ['EMULATOR'] = 'true'
def _make_output_encoding_safe() -> None:
"""Stop an unencodable character from killing the run.
This script's own report is ASCII, but it echoes text it does not control
-- plugin ids, mode names and exception messages -- and a Windows console
is cp1252, which cannot encode most of what a plugin might put there. The
default 'strict' error handler turns that into a UnicodeEncodeError from
inside `print`, so a rendering run that had already succeeded exited
non-zero with a traceback instead of printing its results.
'replace' degrades the offending character to '?' and keeps going; the
encoding itself is left alone so output still matches the terminal.
"""
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(errors='replace')
except (AttributeError, ValueError, OSError):
# Not a reconfigurable TextIOWrapper (redirected, wrapped by a
# test harness). Nothing to do -- this is best-effort hardening.
pass
_make_output_encoding_safe()
from src.logging_config import get_logger # noqa: E402
from src.plugin_system.testing.loading import ( # noqa: E402
build_full_config, find_plugin_dir, load_harness_spec, load_manifest,
@@ -178,7 +203,7 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
status = "PASS"
detail = ""
if r.golden_checked:
detail = " (golden ✓)"
detail = " (golden ok)"
if r.update_error is not None:
detail += f" (update warn: {r.update_error})"
if r.fill_checked and r.fill_ok is None and r.fill_extent:
@@ -196,7 +221,7 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
status, detail = "FAIL", f" overflow bbox={r.overflow}"
elif r.golden_ok is False:
status = "FAIL"
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
detail = f" golden drift: {r.golden_diff_pixels}px (max delta={r.golden_max_delta})"
elif r.fill_ok is False:
ex, ey = r.fill_extent or (0.0, 0.0)
status = "FAIL"
+84 -1
View File
@@ -20,11 +20,23 @@ 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.
The module also owns the other two things that decide whether a bundled face
renders reproducibly, for the same reason — they are properties of the font
file, not of whoever is drawing with it:
* :func:`crisp_size` and :data:`FONT_PIXEL_GRID` — the size each face renders
on whole pixels at. ``4x6-font.ttf`` has a 7px grid, which is why the 6 that
reads as its natural size is the wrong number everywhere it appears.
* :func:`resolve_asset_path` — ``assets/fonts/...`` resolved against the
install root rather than the process cwd.
"""
from __future__ import annotations
from typing import Any, Union
import os
from pathlib import Path
from typing import Any, Dict, Union
from PIL import ImageFont
@@ -41,3 +53,74 @@ def load_truetype(font: Union[str, Any], size: int, **kwargs: Any) -> ImageFont.
"""
kwargs.setdefault("layout_engine", LAYOUT_ENGINE)
return ImageFont.truetype(font, size, **kwargs)
# --------------------------------------------------------------------------
# Bundled-asset path resolution
# --------------------------------------------------------------------------
#: The install root, derived from this module's own location
#: (``<root>/src/common/font_layout.py``) rather than from the process cwd.
_INSTALL_ROOT = Path(__file__).resolve().parents[2]
def resolve_asset_path(relative_path: str) -> str:
"""Resolve a repo-relative asset path independently of the process cwd.
Prefers the path as given — so an absolute path is returned untouched and
behaviour is unchanged wherever the cwd already happened to be the install
root — then the install root derived above, then the original string so a
caller that wants to raise and fall back still can.
Without the fallback, any process started outside the install root (the
plugin safety harness, a manual ``python run.py`` from ``$HOME``, a unit
file written without ``WorkingDirectory``) silently loses every font and
degrades to PIL's default face.
"""
if os.path.isabs(relative_path) and os.path.exists(relative_path):
return relative_path
candidate = _INSTALL_ROOT / relative_path
if candidate.exists():
return str(candidate)
return relative_path
# --------------------------------------------------------------------------
# Pixel-grid snapping
# --------------------------------------------------------------------------
#: Family aliases the web UI may write, mapped to the shipped filename.
FONT_NAME_ALIASES: Dict[str, str] = {
"press_start": "PressStart2P-Regular.ttf",
"four_by_six": "4x6-font.ttf",
}
#: Pixel grid each face renders crisply on. Off-grid sizes anti-alias, which
#: on an LED matrix is a dim lamp rather than a soft edge — and worse under
#: ``draw.fontmode = "1"``, where the mono rasteriser thresholds each glyph at
#: 50% coverage: an off-grid 4x6 glyph renders 3px wide instead of 4, so W/M
#: and 0/8 stop being distinguishable. Off-grid sizes also make ``getlength``
#: return a FreeType-dependent fractional advance, which is how two panels on
#: one config centre the same string differently.
FONT_PIXEL_GRID: Dict[str, int] = {
"PressStart2P-Regular.ttf": 8,
"4x6-font.ttf": 7,
}
def crisp_size(font_file, desired, aliases=None, grid_table=None):
"""Snap *desired* to the nearest size *font_file* renders crisply at.
A face with no known grid is returned unchanged, so a user-supplied font is
never second-guessed.
``aliases`` and ``grid_table`` default to the shared tables; a plugin that
ships an extra face can pass its own without forking this.
"""
aliases = FONT_NAME_ALIASES if aliases is None else aliases
grid_table = FONT_PIXEL_GRID if grid_table is None else grid_table
font_file = aliases.get(font_file, font_file)
grid = grid_table.get(font_file)
if not grid or not desired or desired <= 0:
return desired
return max(grid, int(round(float(desired) / grid)) * grid)
+10 -30
View File
@@ -22,6 +22,10 @@ from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
from zoneinfo import ZoneInfo
from src.common.font_layout import ( # noqa: F401 - re-exported, see below
FONT_NAME_ALIASES, FONT_PIXEL_GRID, crisp_size,
)
logger = logging.getLogger(__name__)
__all__ = [
@@ -52,18 +56,12 @@ FAVORITE_RESULT_COLOR_DEFAULTS: Dict[str, Tuple[int, int, int]] = {
"tie": (255, 200, 0),
}
#: Family aliases the web UI may write, mapped to the shipped filename.
FONT_NAME_ALIASES: Dict[str, str] = {
"press_start": "PressStart2P-Regular.ttf",
"four_by_six": "4x6-font.ttf",
}
#: Pixel grid each face renders crisply on. Off-grid sizes anti-alias, which
#: on an LED matrix is a dim lamp rather than a soft edge.
FONT_PIXEL_GRID: Dict[str, int] = {
"PressStart2P-Regular.ttf": 8,
"4x6-font.ttf": 7,
}
# Re-exported rather than defined: the grid tables and the snapping rule are
# properties of the font files, which the display core needs too (it loads the
# same two faces in DisplayManager._load_fonts). They live in
# src/common/font_layout.py so there is one definition; they stay in this
# module's namespace and __all__ so the eight scoreboards that delegate to
# `sports_card.crisp_size` are untouched.
MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
@@ -357,24 +355,6 @@ def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
def crisp_size(font_file, desired, aliases=None, grid_table=None):
"""Snap *desired* to the nearest size *font_file* renders crisply at.
A face with no known grid is returned unchanged, so a user-supplied
font is never second-guessed.
``aliases`` and ``grid_table`` default to the shared tables; a plugin
that ships an extra face can pass its own without forking this.
"""
aliases = FONT_NAME_ALIASES if aliases is None else aliases
grid_table = FONT_PIXEL_GRID if grid_table is None else grid_table
font_file = aliases.get(font_file, font_file)
grid = grid_table.get(font_file)
if not grid or not desired or desired <= 0:
return desired
return max(grid, int(round(float(desired) / grid)) * grid)
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
"""The font_size this plugin's config_schema.json declares, or None.
+61 -11
View File
@@ -34,7 +34,7 @@ else:
from contextlib import contextmanager
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
import threading
import time
from collections import OrderedDict
@@ -403,7 +403,9 @@ class DisplayManager:
# Initialize font with Press Start 2P
try:
self.font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
self.font = load_truetype(
self._font_asset(self._PRESS_START),
crisp_size(self._PRESS_START, 8))
logger.info("Initial Press Start 2P font loaded successfully")
except Exception as e:
logger.error(f"Failed to load initial font: {e}")
@@ -592,9 +594,19 @@ class DisplayManager:
Pillow had; with the engine pinned it does not, so the rung the
worst case actually needs is here rather than implied.
"""
# The middle rung is on the 7px grid; the bottom one is deliberately
# not. 4x6 advances the same whether it is asked for 6 or 7 -- the
# dotted quad is 66px at both -- so the middle rung costs no width and
# gains the fourth column in every glyph, which is the difference
# between reading an address off a wall and guessing at it. The 5 rung
# is the exception this screen needs: it drops the advance to 4px and
# the quad to 51px, the only rung that fits a 64px panel, and no
# on-grid size does that. It is the one place in the core that draws
# 4x6 off-grid on purpose.
candidates = [self.font,
("assets/fonts/4x6-font.ttf", 6),
("assets/fonts/4x6-font.ttf", 5)]
(self._font_asset(self._FOUR_BY_SIX),
crisp_size(self._FOUR_BY_SIX, 6)),
(self._font_asset(self._FOUR_BY_SIX), 5)]
narrowest = None
for candidate in candidates:
try:
@@ -966,6 +978,28 @@ class DisplayManager:
except Exception as e:
logger.error(f"Error drawing BDF text: {e}", exc_info=True)
#: The bundled faces, and the size each is *asked* for. Every size here is
#: run through `crisp_size`, so a number that drifts off the face's pixel
#: grid is snapped rather than rendered anti-aliased -- see the note on
#: `extra_small_font` below.
_FONT_DIR = "assets/fonts"
_PRESS_START = "PressStart2P-Regular.ttf"
_FOUR_BY_SIX = "4x6-font.ttf"
@classmethod
def _font_asset(cls, filename: str) -> str:
"""Install-root-relative path to a bundled face.
`_load_fonts` named these relative to the process cwd, which holds
under the packaged systemd unit (WorkingDirectory is the install root)
and nowhere else: the plugin safety harness, `python run.py` from
$HOME, or a unit file written without WorkingDirectory all loaded
nothing and fell through to `ImageFont.load_default()`. That failure is
silent -- the panel just renders in PIL's default face at whatever size
the layout was computed for.
"""
return resolve_asset_path(f"{cls._FONT_DIR}/{filename}")
def _load_fonts(self):
"""Load fonts with proper error handling."""
# Font objects get new id()s after reload, so the text-width cache would
@@ -973,16 +1007,17 @@ class DisplayManager:
self._text_width_cache.clear()
try:
# Load Press Start 2P font
self.regular_font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
press_start = self._font_asset(self._PRESS_START)
self.regular_font = load_truetype(press_start, crisp_size(self._PRESS_START, 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 = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
self.small_font = load_truetype(press_start, crisp_size(self._PRESS_START, 8))
logger.info("Press Start 2P small font loaded successfully")
# Load 5x7 BDF font for calendar events
try:
self.calendar_font_path = "assets/fonts/5x7.bdf"
self.calendar_font_path = self._font_asset("5x7.bdf")
logger.info(f"Attempting to load 5x7 font from: {self.calendar_font_path}")
if not os.path.exists(self.calendar_font_path):
@@ -1017,11 +1052,26 @@ class DisplayManager:
self.bdf_5x7_font = self.calendar_font
logger.info(f"Assigned calendar_font (type: {type(self.bdf_5x7_font).__name__}) to bdf_5x7_font.")
# Load 4x6 font as extra_small_font
# Load 4x6 font as extra_small_font.
#
# Asked for 6 -- the size the face's name suggests -- for years,
# and 6 is off its 7px pixel grid. Plugins draw this face with
# `draw.fontmode = "1"`, and the mono rasteriser thresholds each
# glyph at 50% coverage, so off-grid every glyph came out 3px wide
# instead of 4. The lost column deforms the letterforms rather than
# merely thinning them: christmas-countdown rendered "UNTIL" as
# "VM1JL" and "CHRISTMAS" as "CHAJS1MAS", and zero loses the left
# half of its bowl. Those renders were committed as golden images.
#
# `crisp_size` snaps it to 7. The advance is unchanged -- 5px per
# glyph at either size -- so nothing reflows and no layout gets
# tighter; a string is at most a pixel or two wider because the
# last glyph finally occupies the width it was always given.
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 = load_truetype(font_path, 6)
font_path = self._font_asset(self._FOUR_BY_SIX)
size = crisp_size(self._FOUR_BY_SIX, 6)
logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size {size}")
self.extra_small_font = load_truetype(font_path, size)
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.")
+8 -14
View File
@@ -38,7 +38,7 @@ import time
from collections import OrderedDict
from pathlib import Path
from PIL import ImageFont
from src.common.font_layout import load_truetype
from src.common.font_layout import load_truetype, resolve_asset_path
from typing import Dict, Tuple, Optional, Union, Any, List
logger = logging.getLogger(__name__)
@@ -665,20 +665,14 @@ class FontManager:
def _resolve_asset_path(relative_path: str) -> str:
"""Resolve a repo-relative asset path independently of the process cwd.
Prefers the working directory (preserving behavior when the process
runs from the install root), then falls back to the install root
derived from this module's own location. Without the fallback, any
process started outside the install root (e.g. the plugin safety
harness on CI) silently loses every font and degrades to PIL's
default face.
Thin delegate to :func:`src.common.font_layout.resolve_asset_path`,
which holds the one definition (``DisplayManager._load_fonts`` needs
the same resolution and must not import this class for it). The method
stays because plugins probe for it by name to share the core's notion
of "install root" -- see the `_resolve_font_path` helpers in the
scoreboard plugins.
"""
if os.path.exists(relative_path):
return relative_path
install_root = Path(__file__).resolve().parent.parent
candidate = install_root / relative_path
if candidate.exists():
return str(candidate)
return relative_path
return resolve_asset_path(relative_path)
def _initialize_fonts(self):
"""Initialize font catalog and validate configuration."""
+14 -5
View File
@@ -25,11 +25,20 @@ def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) ->
def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Load and return manifest.json from a plugin directory."""
"""Load and return manifest.json from a plugin directory.
Read as UTF-8 explicitly, not in the platform default encoding: JSON is
UTF-8 by RFC 8259, but `open()` honours the locale, which is cp1252 on
Windows. A manifest carrying any non-ASCII byte (an em dash in a
description, a degree sign in a mode name) therefore raised
UnicodeDecodeError and aborted the whole `check_plugin.py --all` run on the
byte rather than failing just that plugin. The three sibling loaders below
read JSON from the same plugin trees and had the same bug.
"""
manifest_path = Path(plugin_dir) / 'manifest.json'
if not manifest_path.exists():
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
with open(manifest_path, 'r') as f:
with open(manifest_path, 'r', encoding='utf-8') as f:
return json.load(f)
@@ -77,7 +86,7 @@ def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
schema_path = Path(plugin_dir) / 'config_schema.json'
if not schema_path.exists():
return {}
with open(schema_path, 'r') as f:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
return _defaults_from_properties(schema.get('properties', {}))
@@ -106,7 +115,7 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
spec_path = Path(plugin_dir) / 'test' / 'harness.json'
if not spec_path.exists():
return {}
with open(spec_path, 'r') as f:
with open(spec_path, 'r', encoding='utf-8') as f:
spec = json.load(f)
# Resolve mock_data path and inline its contents for convenience.
@@ -120,7 +129,7 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
f"harness.json references mock_data '{mock_rel}' but "
f"{mock_path} does not exist"
)
with open(mock_path, 'r') as mf:
with open(mock_path, 'r', encoding='utf-8') as mf:
spec['mock_data_contents'] = json.load(mf)
return spec
@@ -31,7 +31,7 @@ from pathlib import Path
from typing import Any, List, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from src.common.font_layout import crisp_size, load_truetype
from src.logging_config import get_logger
@@ -141,9 +141,10 @@ class VisualTestDisplayManager:
fonts_dir = project_root / 'assets' / 'fonts'
# Press Start 2P — regular and small (both 8px)
ttf_path = str(fonts_dir / 'PressStart2P-Regular.ttf')
self.regular_font = load_truetype(ttf_path, 8)
self.small_font = load_truetype(ttf_path, 8)
press_start = 'PressStart2P-Regular.ttf'
ttf_path = str(fonts_dir / press_start)
self.regular_font = load_truetype(ttf_path, crisp_size(press_start, 8))
self.small_font = load_truetype(ttf_path, crisp_size(press_start, 8))
self.font = self.regular_font # alias used by some code paths
# 5x7 BDF font via freetype
@@ -160,10 +161,15 @@ class VisualTestDisplayManager:
self.calendar_font = self.small_font
self.bdf_5x7_font = self.small_font
# 4x6 extra small TTF
# 4x6 extra small TTF, snapped to the face's 7px grid exactly as
# DisplayManager._load_fonts does. Sizing this independently is how
# the harness would render -- and bless goldens -- in a face the
# panel never uses: at the off-grid 6 this asked for, every glyph
# loses its fourth column under `draw.fontmode = "1"`.
try:
xs_path = str(fonts_dir / '4x6-font.ttf')
self.extra_small_font = load_truetype(xs_path, 6)
four_by_six = '4x6-font.ttf'
xs_path = str(fonts_dir / four_by_six)
self.extra_small_font = load_truetype(xs_path, crisp_size(four_by_six, 6))
except (FileNotFoundError, OSError) as e:
logger.debug("Extra small font not available, using fallback: %s", e)
self.extra_small_font = self.small_font
+178
View File
@@ -0,0 +1,178 @@
"""The bundled faces load on their pixel grid, from any working directory.
`4x6-font.ttf` renders crisply at multiples of 7, not at the 6 its name
suggests. Plugins draw it with ``draw.fontmode = "1"``, and the mono rasteriser
thresholds each glyph at 50% coverage: a glyph asked for at 6 loses its fourth
column, so "UNTIL" rendered as "VM1JL" and "CHRISTMAS" as "CHAJS1MAS" on real
panels. The committed golden images for christmas-countdown encoded exactly
that, because the harness sized the face independently of the display core and
so agreed with it about the wrong number.
These tests pin the three things that failure needed:
* the size comes from `crisp_size`, not a literal;
* `VisualTestDisplayManager` -- the harness's deliberate fork of
`DisplayManager` -- agrees with it, so a golden blessed by the harness
matches what the panel draws;
* the paths resolve against the install root, so a run from another directory
loads the real face instead of silently falling back to
`ImageFont.load_default()`.
"""
import os
import subprocess
import sys
from pathlib import Path
import pytest
from PIL import Image, ImageDraw
os.environ.setdefault("EMULATOR", "true")
from src.common.font_layout import ( # noqa: E402
FONT_PIXEL_GRID, crisp_size, load_truetype, resolve_asset_path,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
FOUR_BY_SIX = "4x6-font.ttf"
PRESS_START = "PressStart2P-Regular.ttf"
class TestCrispSize:
"""The snapping rule itself."""
def test_the_natural_looking_six_is_off_grid(self):
# The whole bug in one line: 6 is not a size this face renders on.
assert crisp_size(FOUR_BY_SIX, 6) == 7
def test_press_start_is_unchanged_at_eight(self):
assert crisp_size(PRESS_START, 8) == 8
def test_an_unknown_face_is_never_second_guessed(self):
assert crisp_size("SomeUserUpload.ttf", 11) == 11
@pytest.mark.parametrize("face,grid", sorted(FONT_PIXEL_GRID.items()))
def test_every_known_face_snaps_to_its_own_grid(self, face, grid):
for desired in range(1, grid * 4):
assert crisp_size(face, desired) % grid == 0
class TestGlyphsKeepTheirFourthColumn:
"""Why the grid matters, measured rather than asserted from the docs."""
@staticmethod
def _ink_width(font, ch):
img = Image.new("L", (32, 16), 0)
draw = ImageDraw.Draw(img)
draw.fontmode = "1" # what the plugins draw with
draw.text((2, 2), ch, font=font, fill=255)
box = img.getbbox()
return 0 if box is None else box[2] - box[0]
@pytest.mark.parametrize("ch", list("WM08"))
def test_on_grid_is_a_full_four_pixels_wide(self, ch):
path = resolve_asset_path(f"assets/fonts/{FOUR_BY_SIX}")
on_grid = load_truetype(path, crisp_size(FOUR_BY_SIX, 6))
off_grid = load_truetype(path, 6)
assert self._ink_width(on_grid, ch) == 4
# The regression this guards: the same glyph one pixel narrower.
assert self._ink_width(off_grid, ch) == 3
class TestAssetPathsIgnoreTheWorkingDirectory:
"""A run from anywhere else must not degrade to the default face."""
def test_resolves_from_an_unrelated_directory(self, tmp_path):
# Resolution must not depend on the cwd of the *test* process either,
# so this runs in a child with tmp_path as its working directory.
code = (
"from src.common.font_layout import resolve_asset_path;"
"import os;"
"p = resolve_asset_path('assets/fonts/4x6-font.ttf');"
"print(os.path.isabs(p) and os.path.exists(p))"
)
env = dict(os.environ, PYTHONPATH=str(PROJECT_ROOT))
out = subprocess.run( # nosec B603 - fixed argv, no shell
[sys.executable, "-c", code],
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=120,
)
assert out.stdout.strip() == "True", out.stderr
def test_a_same_named_file_in_the_cwd_does_not_shadow_the_bundled_asset(
self, tmp_path,
):
# A relative path is a repo-relative asset name, not a cwd-relative
# one: an unrelated decoy that happens to sit at the same relative
# path in whatever directory the process was started from must not
# be preferred over the real bundled font.
decoy_dir = tmp_path / "assets" / "fonts"
decoy_dir.mkdir(parents=True)
decoy = decoy_dir / FOUR_BY_SIX
decoy.write_bytes(b"not a real font")
code = (
"from src.common.font_layout import resolve_asset_path;"
"print(resolve_asset_path('assets/fonts/4x6-font.ttf'))"
)
env = dict(os.environ, PYTHONPATH=str(PROJECT_ROOT))
out = subprocess.run( # nosec B603 - fixed argv, no shell
[sys.executable, "-c", code],
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=120,
)
resolved = out.stdout.strip()
assert resolved != str(decoy), out.stderr
assert resolved == str(PROJECT_ROOT / "assets" / "fonts" / FOUR_BY_SIX)
def test_an_absolute_path_is_returned_untouched(self):
absolute = str(PROJECT_ROOT / "assets" / "fonts" / FOUR_BY_SIX)
assert resolve_asset_path(absolute) == absolute
def test_font_manager_shares_the_one_definition(self):
# Plugins probe for this method by name to borrow the core's notion of
# "install root"; it must stay, and must agree with the free function.
from src.font_manager import FontManager
rel = f"assets/fonts/{FOUR_BY_SIX}"
assert FontManager._resolve_asset_path(rel) == resolve_asset_path(rel)
class TestTheHarnessForkAgreesWithTheCore:
"""The divergence that let the wrong rendering be blessed as golden.
`VisualTestDisplayManager` is a deliberate fork of `DisplayManager` that
runs without hardware, and the harness renders every golden through it. It
carries its own `_load_fonts`, so a size fixed in one and not the other
means CI compares panels against a face no panel uses.
"""
@staticmethod
def _sizes(dm):
return {name: getattr(dm, name).size
for name in ("regular_font", "small_font", "extra_small_font")}
def test_extra_small_font_is_on_grid_in_the_harness(self):
from src.plugin_system.testing.visual_display_manager import (
VisualTestDisplayManager,
)
dm = VisualTestDisplayManager(width=128, height=32)
assert dm.extra_small_font.size == crisp_size(FOUR_BY_SIX, 6) == 7
def test_the_fork_and_the_core_load_the_same_sizes(self):
from unittest.mock import MagicMock, patch
from src.display_manager import DisplayManager
from src.plugin_system.testing.visual_display_manager import (
VisualTestDisplayManager,
)
fork = VisualTestDisplayManager(width=128, height=32)
with patch("src.display_manager.RGBMatrix") as matrix, \
patch("src.display_manager.RGBMatrixOptions"):
instance = MagicMock()
instance.width, instance.height = 128, 32
matrix.return_value = instance
DisplayManager._instance = None
core = DisplayManager.__new__(DisplayManager)
core._text_width_cache = {}
core._load_fonts()
assert self._sizes(core) == self._sizes(fork)