fix(display): pin one text layout engine, and give the 5x7 BDF face a size - #539
Conversation
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. FixesChuckBuilds/ledmatrix-plugins#397 Refs ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Warning Review limit reachedNext included review available in 2 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (11)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Up to standards ✅🟢 Issues |
| Metric | Results |
|---|---|
| Complexity | 4 |
| Duplication | 0 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewerTIP This summary will be updated as you push new changes.
CI is the only machine that can settle this, and it says the refresh is not finished. This runner has libraqm and mine does not, which is why the drift reports existed at all — and after pinning the layout engine plugin-side, geochron still drifted here by 135px at max Δ=60–83, unchanged by the 1-bit experiment in the commit reverted above (geochron's text was already 1-bit; that change was a no-op and is gone). So the four golden sets and the plugin-side engine pins come out, restoring geochron, of-the-day, christmas-countdown and ledmatrix-weather to exactly what `main` has, and their three version bumps come out with them. What stays is everything that does not depend on a Linux render: eighteen issues, and the two documentation renderers from #383, whose determinism is about host *font files* rather than golden images and which pomodoro's harness already passes. The diagnosis stands and is recorded on the four issues. The core half is ChuckBuilds/LEDMatrix#539, which is independently correct and green. The golden refresh wants doing from a Linux run — a `--update-golden` on this runner — rather than guessed at from Windows, which is the mistake this reverts. Refs #371, #375, #378, #391 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… 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>
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>
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>
* fix(tests): honour LEDMATRIX_CORE instead of a developer's home directory Twenty test files across sixteen plugins probed `/home/rackpi/projects/LEDMatrix` first, then guessed a sibling checkout, and inserted whichever hit at `sys.path[0]`. Both beat the PYTHONPATH that `scripts/run_plugin_tests.py --core` sets, so the flag whose whole purpose is choosing a core was ignored whenever either candidate happened to exist — tests ran against the wrong checkout and reported pass or fail with equal confidence. They now read `LEDMATRIX_CORE`, which the runner sets to an absolute path for exactly this reason, and only guess at the sibling layout when nobody told them. The hardcoded home directory is gone from the repo. Closes#467 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(sports): build card dates portably instead of with glibc-only strftime `%-m`, `%-d` and `%-I` are glibc extensions. On Windows and on musl (Alpine) `strftime` raises `ValueError`, so the fourteen call sites across ten plugins could not render a game card anywhere but a glibc host. In baseball-scoreboard's scroll renderer the damage was visible rather than fatal: the enclosing `except` swallowed the error, dropped the start time and drew the raw ISO date in its place, which is what made three of its upcoming goldens the only cards failing `test_scroll_card_renders.py` off glibc. Output is byte-identical on glibc, so no goldens move. `sports.py` is the copied-not-shared lineage, so all nine siblings are fixed here per docs/plugin-development/08-shared-sports-code.md. Closes#414Closes#447 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(scoreboards): make declared settings reach the code that draws with them Six settings-level defects across the scoreboard lineage, all the same shape: the schema and the web UI promise something the runtime never reads, or reads with a different default. * nrl: four `manager.py` fallbacks disagreed with `config_schema.json` (`recent_games_to_show` 5→1, `upcoming_games_to_show` 10→1, `show_odds` false→true, `update_interval_seconds` 300→3600), plus the same `show_odds` drift in `get_info()`. They only fire for a hand-written or partial config — and a fresh install before the UI has saved once. (#427) * lacrosse: `defaults.display_duration` fell back to 30 where the schema says 15, and `game_display_duration` was read but declared nowhere, so the UI could not offer it. Declared now, beside `display_duration`. (#429) * every scoreboard: `other_games_min_quality`'s help text described a `broadcast` value the enum does not offer — it was retired, and the code migrates it to `ranked`. 29 descriptions and three README references corrected. (#426) * afl and nrl: the `scroll_settings` block was unreachable. The scroll classes read `scroll_mode`, and `additionalProperties: false` meant a user could not write that name either, so all twelve settings were dead. `SCROLL_CONFIG_KEY` now names the block the schema declares; `scroll_speed`'s default moves 1.0 → 50.0 to match the core default the block had been shadowing, so nothing changes for an existing install. (#422) * hockey and lacrosse: the scroll and Vegas cards gated the shots line on a flat `show_shots` key at the league root that the schema declares nowhere, so the line never drew there whatever the user set. Both now walk the same ladder `manager.py` resolves for the switch card. (#432) * soccer: `leagues.<slug>.display_options` was read by nothing — 30 settings in the UI doing nothing, and the opposite precedence to every sibling. The league block now wins with the plugin-level key as fallback. Note the web UI writes a value into every league block, so after a save the per-league copy is the one in play; the README says so. (#435) * afl, nrl, soccer, ufc: `dynamic_duration.min_duration_seconds` was declared and never applied — only the cap was. A floor is resolved on the same ladder and applied after the cap, so an explicit minimum wins, matching what ledmatrix-leaderboard and cricket-scoreboard already do. `max_workers` is now passed to `get_background_service` instead of a hardcoded 1; its schema default moves 3 → 1 so behaviour is unchanged, and its description says the service is a process-wide singleton whose first caller decides. (#373) Closes#426Closes#427Closes#429Closes#422Closes#432Closes#435Closes#373 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(static-image, flights): implement date_based rotation; drop a stranger's LAN IP * static-image: `rotation_mode: "date_based"` was a stub returning the first image, so it looked like broken rotation rather than an unimplemented feature — no log line, the display simply never advanced. It now picks by day-of-year, the same indexing `of-the-day` uses: a set of seven repeats weekly, a set of 365 walks the year, and a given date always shows the same image. (#372) * ledmatrix-flights: `skyaware_url` defaulted to `192.168.86.30` — one particular receiver on one home network — in the schema and four code paths. Every install that did not set it polled that address on its own LAN and reported "no data received" rather than "not configured", and `check_plugin.py` spent ~40s per run waiting out connect timeouts. The default is empty now, and the fetcher says which setting is missing instead of guessing. (#400) Closes#372Closes#400 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(fonts, stocks): load bitmap faces at their own size; give stocks harness data * Six plugins still asked FreeType for a .bdf face at an arbitrary point size. BDF is a fixed-size bitmap format, so that raises "invalid pixel size" for every size but the file's own strike, and the loader then fell back to a different face with only a log warning — twelve of the thirteen offered sizes failed that way, so picking a bitmap font in the web UI appeared to do nothing. afl, basketball, nrl, soccer and ufc (shared sports.py lineage) and ledmatrix-music now retry at the size the file's PIXEL_SIZE header declares, the same fix already carried by baseball, football, clock-simple, news, tide-display, jellyfin-now-playing, mqtt-notifications and youtube-stats. (#387) `cozette.bdf` is already gone from every schema, and f1-scoreboard already pins bitmap faces to their native size through its own table. * ledmatrix-stocks shipped no `test/harness.json`, so the safety harness had no quote data, `stock_data` stayed empty, and all eight sizes passed against a blank frame — nothing about the ticker layout, fonts, colours or inline chart was being checked. It now has one, built on the recorded fixture from #394: a gain, a loss and a crypto entry. The base run uses switch mode, because frame 0 of a scroll is legitimately blank; a `scroll` variant keeps the scroll path under the harness too. All eight sizes now render real content with no empty warning. (#395) Closes#387Closes#395 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat!: remove seventeen configuration settings that nothing reads Every setting below rendered in the web UI with a description promising specific behaviour, saved to config.json, and did nothing — verified against both the plugin source and the LEDMatrix core. BREAKING CHANGE: these keys are gone from the schemas, and all six plugins set `additionalProperties: false`. A config.json still carrying them fails schema validation until it is saved again from the web UI; the plugin still loads, but the UI flags its config as not matching the schema. * `transition.*` in christmas-countdown, nfl-draft, olympics and web-ui-info, plus `high_performance_transitions` in the first and third. Nothing in any of the four reads them, and the core implements no display transitions — every occurrence of "transition" under `src/` is a comment about plugin state machines or Vegas cycle boundaries. There is no `fade`, `slide`, `wipe`, `dissolve` or `pixelate` anywhere. If transitions are wanted later they belong in the core display controller, where every plugin gets them, not in five copied schema blocks. (#381, #377) * masters-tournament's whole `notifications` and `branding` blocks (12 settings), plus five leaves inside `display_modes` that the generic mode-handling never reaches: `player_cards.show_headshots`, `player_cards.duration_per_player`, `course_tour.show_animations`, `course_tour.duration_per_hole` and `course_tour.featured_holes`. The last one is not the `masters_featured_holes` display mode, which is live. (#418) * march-madness's `display_options.show_bracket_progress`, and the attribute it was assigned to — read once in `__init__`, never referenced again. (#406) `christmas-countdown`'s `tree_size` stays: it is read and validated, just not applied, which is a different defect and still documented in its README. Closes#381Closes#377Closes#418Closes#406 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(render): pin the text layout engine, refresh four golden sets, stop host fonts **Why the goldens travelled badly.** `ImageFont.truetype` picks its layout engine at load time: Raqm where the host Pillow was built with libraqm, Basic otherwise, and the two round fractional glyph advances differently. `PressStart2P-Regular.ttf` at 8px has whole-pixel advances so both engines agree — which is why fifteen plugins' goldens matched everywhere and hid this. `4x6-font.ttf` at 6px does not: glyph positions drift cumulatively along a run. The four plugins that draw body text in it are exactly the four with open drift reports, and of-the-day's own issue records the tell — its PressStart2P title is pixel-identical while its 4x6 body is not. geochron, of-the-day, ledmatrix-weather and christmas-countdown now pin the Basic engine where they load their own faces, and the four golden sets are regenerated against a core doing the same (ChuckBuilds/LEDMatrix fix/deterministic-text-rendering). All 32 plugins now pass `check_plugin.py` here; the other fifteen golden sets are untouched and still pass, which is the check that this pinned the right thing rather than papering over it. **Host fonts in the documentation renderers.** `scripts/render_docs_assets.py` and `plugins/pomodoro-timer/test/render_readme_assets.py` both picked their caption face from a list of host font paths — DejaVu on Linux, Segoe UI on Windows, and for the pomodoro one no Windows path at all, so it fell through to PIL's tiny default and rewrote every labelled contact sheet on a plugin nobody had touched. Both now use `ImageFont.load_default(size=...)`, which Pillow >= 10.1 serves from bundled Aileron: no font files, no host lookup. Aileron ships one cut, so bold is a point larger, and it has no em dash or multiplication sign, so the pomodoro captions are ASCII — both rendered as a .notdef box. Its four images are regenerated and now reproduce byte-for-byte across runs. The committed composites under `docs/assets/` are left as they are: their captions will change on the next regeneration of each, which is a mechanical pass better done per plugin than as one 217-image commit. Closes#371Closes#375Closes#378Closes#391Closes#383 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(store): bump the 23 changed plugins and regenerate plugins.json Six majors for the schema removals — christmas-countdown, nfl-draft, olympics, web-ui-info, masters-tournament and march-madness — because a config.json still carrying a removed key fails validation until it is saved again. Eight minors where a declared setting starts working or a default changes, nine patches for portability and rendering fixes that leave every existing panel drawing what it drew before. `plugins.json` regenerated with `update_registry.py`; it is never hand-edited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(geochron): draw the sidebar 1-bit, so its goldens stop being host-specific Pinning the layout engine fixed glyph *positions*, and CI showed what it did not fix: the sidebar was still drawn anti-aliased, and edge coverage varies with the host FreeType build. geochron draws straight onto the display manager's canvas with its own font, so it never inherited the core's 1-bit text setting the way plugins going through `draw_text` do. The text is now drawn 1-bit for the duration of the readout, restoring the previous `fontmode` afterwards so nothing else on that shared canvas changes. That is what an LED grid wants regardless — a half-lit pixel is a dim pixel, not a smooth edge — and it removes the last variable from the render: the sidebar is exactly three colours now, background plus the two text colours. Goldens regenerated. Refs #375 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Revert "fix(geochron): draw the sidebar 1-bit, so its goldens stop being host-specific" This reverts commit f906a93. * revert: take the golden refresh out of this PR CI is the only machine that can settle this, and it says the refresh is not finished. This runner has libraqm and mine does not, which is why the drift reports existed at all — and after pinning the layout engine plugin-side, geochron still drifted here by 135px at max Δ=60–83, unchanged by the 1-bit experiment in the commit reverted above (geochron's text was already 1-bit; that change was a no-op and is gone). So the four golden sets and the plugin-side engine pins come out, restoring geochron, of-the-day, christmas-countdown and ledmatrix-weather to exactly what `main` has, and their three version bumps come out with them. What stays is everything that does not depend on a Linux render: eighteen issues, and the two documentation renderers from #383, whose determinism is about host *font files* rather than golden images and which pomodoro's harness already passes. The diagnosis stands and is recorded on the four issues. The core half is ChuckBuilds/LEDMatrix#539, which is independently correct and green. The golden refresh wants doing from a Linux run — a `--update-golden` on this runner — rather than guessed at from Windows, which is the mistake this reverts. Refs #371, #375, #378, #391 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(afl, nrl): keep the card gap at 24 now that scroll_settings is reachable Caught reviewing #422 for merge safety rather than by a test, because no test covers it and the harness renders frame 0, where the gap between cards does not show. Making the block reachable means every key in it becomes an override, and one of them disagreed with the value the code had been resolving. Both plugins set `gap_between_games: 24` in `scroll_settings_defaults()`, overriding the core's 48; the schema block declared 48. So the fix for #422 would have silently doubled the gap between game cards on every afl and nrl install — the same trap as `scroll_speed`, which I caught only because its factor was 50x rather than 2x. The schema now declares 24, and every key in the block agrees with what the resolver produced while it was unreachable: scroll_speed 50.0 (core default) scroll_delay 0.01 (core default) gap_between_games 24 (plugin override) <- this commit show_league_separators true (core default) dynamic_duration true (core default) game_card_width 128 (already guarded as "unchosen") `game_card_width` needed nothing: both plugins already treat a value equal to the schema default as unchosen and substitute their computed width, which is the same guard this needed and did not have. Reachability is now behaviour-neutral for a default config, which is what #422 should have been all along. READMEs corrected. Refs #422 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
Two ways the same code rendered differently on two machines, both found while
diagnosing four plugins whose golden images passed for their author and failed
for everyone else.
One text layout engine, everywhere
ImageFont.truetypepicks its layout engine at load time: Raqm where the hostPillow was built with libraqm, Basic otherwise. The two round fractional glyph
advances differently, so the same Pillow version renders the same string
onto different pixels depending on a build option of the host.
That is invisible for
PressStart2P-Regular.ttfat 8px, whose advances arewhole pixels either way — which is why fifteen of the plugin fleet's nineteen
golden sets matched everywhere and hid this for months. It is not invisible for
4x6-font.ttfat 6px: glyph positions drift cumulatively along a run.The four plugins that draw body text in that face are exactly the four with
open drift reports, and
of-the-day's own issue records the tell withoutnaming the cause — its PressStart2P title is pixel-identical while its 4x6 body
is not.
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 (Arabic, Indic) and
kerning pairs; neither applies to the bitmap-grid faces this project draws with
on an LED panel. Output is unchanged on any host without libraqm, which is
what CI and this machine both are.
28 call sites across 10 files, mechanical.
The 5x7 face had no size
DisplayManagerbuilt the calendar/5x7 BDF face withfreetype.Face(path)andnever called
set_char_size, soface.size.heightstayed 0 andget_font_height()returned 0 for it. Callers that stack rows asprev_y + prev_height + gaptherefore drew two lines on top of each other —ledmatrix-musicrenders the artist and album rows almost exactly overlappingwhenever a track has an album name.
font_manager._load_bdf_fontalready calledset_char_size, so whethermeasurement worked depended on which path had loaded the face. And the start-up
line
Calendar font size: 0 pixelshas been printing the symptom on every boot.DisplayManagernow sets it too, andget_font_height()falls back to thestrike the file declares rather than handing back a zero line height.
Verified directly:
5x7.bdfreports0beforeset_char_sizeand7after,and the
available_sizesfallback also reports7.Checks
scripts/check_plugin.pyacross all 32 plugins in the companion PR: 0 failures,including the fifteen golden sets that must not move — that is the check that
this pinned the right thing rather than papering over a difference.
Companion PR: ChuckBuilds/ledmatrix-plugins#470, which pins the same engine in
the four plugins that load their own faces and regenerates their goldens
against this branch.
FixesChuckBuilds/ledmatrix-plugins#397
Refs ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391
🤖 Generated with Claude Code