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
+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)