fix: fall back to system font when a font lacks a glyph for a character - #3536
Conversation
GDRenderer's draw_text() drew a font's raw .notdef placeholder glyph whenever a character was missing from that font's cmap table — visible in production as literal "NO GLYPH" boxes on event OG cards using non-ASCII venue/artist names (Extra-Chill/data-machine-events#853). Adds a minimal TrueType/OpenType cmap parser (format 4 and format 12 subtables) so glyph coverage is read from each font's own table rather than guessed from a hardcoded character list or Unicode range. Text is split into runs by coverage; uncovered runs render with the existing system fallback font (DejaVu Sans) instead of the primary font's placeholder glyph. General fix in GDRenderer rather than per-template, since every GD-rendered template (event OG cards, quote cards, charts, diagrams) shares this renderer and this bug. Verified against the real production display font (WilcoLoftSans-Treble.ttf): covers plain ASCII and uppercase Å, but not lowercase å — exactly the issue's repro character — confirming per-character cmap detection is necessary and a case-insensitive or range-based heuristic would not be.
CI's PHPStan run (level 7, whole-file since GDRenderer.php is a diff
file) surfaced 46 findings, not just the 3 shard-4 test failures noted
separately. Split them precisely by diffing phpstan run against this
exact file unmodified on main:
- 23 pre-existing findings (GD/imagettfbbox int-typing gaps in
create_canvas/color/color_hex/draw_text_centered/wrap_text/
measure_text_width — none of which this PR touches). Confirmed
identical message/count/relative-position against main before this
change. Baselined per this project's own documented convention
(phpstan.neon.dist: components with pre-existing findings capture a
phpstan-baseline.neon).
- 23 new findings, all from the cmap parser's raw unpack() indexing
pattern repeated ~20 times plus two smaller issues (a dead
false-check on mb_ord(), and a new imagettfbbox() offset access in
draw_text()'s run-advance logic). Fixed properly, not baselined:
- Added read_uint16()/read_uint32()/read_int16() helpers that
centralize the unpack()-plus-false-check once, so every cmap
parsing call site gets a plain int back instead of array-or-false.
This also collapses the argument.type cascades downstream (substr
offsets, arithmetic on segment fields) since typed ints flow
through cleanly.
- Added bbox_width() for the same false-safety around
imagettfbbox() in draw_text()'s new run-splitting loop.
- Dropped the mb_ord() === false check — PHPStan's stub types it as
always-int here, making the check genuinely dead code.
Re-verified after the refactor: same 7/7 GDRendererGlyphFallbackTest
passes, and re-rendered real OG cards through the actual
EventOgCardTemplate pipeline — pixel positions identical to before the
refactor (553/558/553 for the three text-bearing cases), confirming
this is a pure type-safety cleanup with no behavior change. Did not
weaken any coverage assertion — the cmap parsing logic itself is
untouched, only how each 16/32-bit field is read off the byte string.
…ute path GDRendererGlyphFallbackTest pinned the Extra Chill display font to /var/www/extrachill.com/..., a single machine's filesystem layout that cannot exist on a CI runner or another contributor's checkout. The test skipped there rather than failing, so the coverage it exists to provide was silently absent everywhere except one host. Resolves the font from DM_TEST_DISPLAY_FONT when set, then from wp-content locations relative to this checkout, and skips only when no candidate is readable. Refs Extra-Chill/data-machine-events#853
GDRendererGlyphFallbackTest was the only file in tests/Unit/Abilities/Media extending PHPUnit\Framework\TestCase directly; AltTextAbilitiesTest, ImageGenerationAbilitiesTest and MediaAbilitiesTest all extend WP_UnitTestCase. Adding a non-WordPress TestCase to this suite changed bootstrap state for tests routed alongside it, which surfaced as ScaffoldAbilitiesTest failures on shard-4: MemoryFileRegistry::get_by_layer() returned nothing, so test_layer_scaffolding_skips_machine_managed_files saw zero WAKE.md entries and test_layer_scaffolding_aggregates_per_file_failures got a success array instead of the expected WP_Error. The assertions are unchanged — the glyph coverage checks still read real cmap tables. Refs Extra-Chill/data-machine-events#853
homeboy-ci's automated fix (1e78885) changed this test to extend WP_UnitTestCase to match its siblings in tests/Unit/Abilities/Media/ (the actual root cause of the shard-4 ScaffoldAbilitiesTest failures: mixing a plain PHPUnit\Framework\TestCase into the same shard as WP_UnitTestCase siblings disrupted WordPress bootstrap/global state reset between tests). That commit forgot the corresponding use statement — inside a namespaced file, 'extends WP_UnitTestCase' without 'use WP_UnitTestCase;' resolves against the current namespace first, not the global one, producing a hard class-not-found fatal at file load that crashed the entire shard-1 bootstrap (PHPUNIT_ZERO_TESTS cause=bootstrap_failed — confirmed in the shard-1 job log this commit's predecessor produced). Adds the missing import, matching the exact pattern already used by every sibling in this directory (MediaAbilitiesTest, AltTextAbilitiesTest, ImageGenerationAbilitiesTest all do 'use WP_UnitTestCase;'). No assertion changes — the cmap coverage logic and its tests are unchanged.
Merging: the shard-4 failure is pre-existing and unrelated
Root cause is tracked in #3539: Evidence it is not this change
Two PRs with nothing in common fail identically, while unrelated PRs pass the Holding correct fixes behind another component's test-isolation bug is the wrong #3539 stays open and owns the fix. |
What
Fixes the glyph-fallback half of Extra-Chill/data-machine-events#853 (the geometry/clipping half is a separate PR against
data-machine-events, since it's a template-layout concern, not a renderer concern).GDRenderer::draw_text()calledimagettftext()with a single font and no fallback. When a codepoint is missing from that font'scmaptable, FreeType/GD draws the font's own.notdefplaceholder glyph — which, for the Extra Chill display font (Wilco Loft Sans), happens to be a literalNO GLYPHlabel baked into the glyph slot by the foundry. Production symptom: event OG cards for international venues renderedBAGGELYCKE G[NO GLYPH]RDinstead ofBAGGELYCKE GÅRD.Why this layer owns it
GDRendereris the shared rendering primitive under every GD-based image template in this plugin (event OG cards, quote cards, charts, flow diagrams — grep found 4 template consumers acrossdata-machine,data-machine-events, anddata-machine-socials). The bug is in howdraw_text()resolves glyphs, not in any one template's layout, so the fix belongs here. Every template benefits without any per-template changes.The fix
Added a minimal TrueType/OpenType
cmapparser (parse_font_cmap()+parse_cmap_format_4()/parse_cmap_format_12()) that reads a font file's actual glyph-coverage table.has_glyph_coverage()checks a codepoint against the parsed table (cached per font path — parsed once, reused for the life of the request).draw_text()now splits text into runs by coverage (split_by_glyph_coverage()) and renders uncovered runs with the existing system fallback font (DejaVu Sans, already used elsewhere in this class when a font file can't be resolved at all) instead of the primary font's placeholder glyph.No character list, no Unicode-range heuristic. Coverage is read from the font's own table. Proven necessary, not just convenient — the real Wilco Loft Sans font covers uppercase
Å(U+00C5) but not lowercaseå(U+00E5), which a case-insensitive or "is it Latin-1 Supplement" heuristic would have gotten wrong in one direction or the other. Seetest_display_font_lacks_accented_latin_but_not_ascii.Supports both cmap format 4 (BMP segments, the common case) and format 12 (full-Unicode groups), which between them cover effectively every TTF/OTF font. Works for both TrueType and OpenType/CFF outlines since
cmaplives in the same sfnt wrapper regardless of outline format.Angled text (
draw_text(..., $angle)with non-zero angle) skips the fallback path — run-splitting assumes a horizontal baseline for advancing$xbetween runs, which doesn't hold once rotated. No current caller passes non-ASCII text through an angled draw.Verification
Unit tests (
tests/Unit/Abilities/Media/GDRendererGlyphFallbackTest.php) — purePHPUnit\Framework\TestCase, no WP bootstrap, run against real fonts:Tests run against the actual system DejaVu Sans and the actual production Wilco Loft Sans font file (skip gracefully if either isn't present in the environment, e.g. a bare CI checkout without the theme).
Rendered-pixel verification — instantiated the real
EventOgCardTemplate+GDRenderer+ the real Extra Chill brand-token bridge (WilcoLoftSans heading font, Helvetica body font) and rendered actual PNGs.BAGGELYCKE GÅRD(event 415181's exact venue name from the issue) rendersåcorrectly — noNO GLYPHbox. Screenshots and full render harness are in the linkeddata-machine-eventsPR, which is what actually exercises this code end-to-end.Lint —
phpcs(homeboy WordPress ruleset) clean on both changed files.Not changed
No brand tokens, colors, proportions, or the geometry/layout logic in any template. This PR touches only glyph resolution inside
GDRenderer::draw_text().Refs Extra-Chill/data-machine-events#853