Skip to content

fix: fall back to system font when a font lacks a glyph for a character - #3536

Merged
chubes4 merged 5 commits into
mainfrom
fix/853-og-card-glyph-fallback
Sep 21, 2026
Merged

chubes4 merged 5 commits into
mainfrom
fix/853-og-card-glyph-fallback

Conversation

@chubes4

@chubes4 chubes4 commented Sep 21, 2026

Copy link
Copy Markdown
Member

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() called imagettftext() with a single font and no fallback. When a codepoint is missing from that font's cmap table, FreeType/GD draws the font's own .notdef placeholder glyph — which, for the Extra Chill display font (Wilco Loft Sans), happens to be a literal NO GLYPH label baked into the glyph slot by the foundry. Production symptom: event OG cards for international venues rendered BAGGELYCKE G[NO GLYPH]RD instead of BAGGELYCKE GÅRD.

Why this layer owns it

GDRenderer is 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 across data-machine, data-machine-events, and data-machine-socials). The bug is in how draw_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 cmap parser (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. See test_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 cmap lives 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 $x between 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) — pure PHPUnit\Framework\TestCase, no WP bootstrap, run against real fonts:

PHPUnit 9.6.34 by Sebastian Bergmann and contributors.

GDRenderer Glyph Fallback (DataMachine\Tests\Unit\Abilities\Media\GDRendererGlyphFallback)
 ✔ System fallback covers basic latin
 ✔ System fallback covers accented latin
 ✔ System fallback lacks cjk
 ✔ Display font lacks accented latin but not ascii
 ✔ Unreadable font assumes coverage
 ✔ Run splitting isolates uncovered characters
 ✔ Run splitting returns single run for fully covered text

OK (7 tests, 19 assertions)

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 — no NO GLYPH box. Screenshots and full render harness are in the linked data-machine-events PR, which is what actually exercises this code end-to-end.

Lintphpcs (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

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.
@chubes4

chubes4 commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

Merging: the shard-4 failure is pre-existing and unrelated

ScaffoldAbilitiesTest::test_layer_scaffolding_skips_machine_managed_files and
::test_layer_scaffolding_aggregates_per_file_failures fail by shard
composition
, not because of anything in this PR.

Root cause is tracked in #3539: MemoryFileRegistry::reset() also resets
WP_Agent_Memory_Registry, whose entries are registered at bootstrap and are
not restored by re-firing the datamachine_memory_files action. One test
calling reset() therefore leaves the registry permanently short for every test
after it in the process. ScaffoldAbilitiesTest has no set_up() and depends
entirely on ambient state, so its result is decided by which shard it lands in.

Evidence it is not this change

PR Change Shards
#3536 GDRenderer cmap glyph fallback ❌ same two assertions
#3538 BrandTokens logo resolution ❌ same two assertions
#3535, #3510, #3484 unrelated work ✅ shards pass

Two PRs with nothing in common fail identically, while unrelated PRs pass the
same shards. The only thing #3536 and #3538 share is adding a test file, which
reshuffles shard routing.

Holding correct fixes behind another component's test-isolation bug is the wrong
trade, particularly while NO GLYPH boxes render on production OG cards for
accented venue names.

#3539 stays open and owns the fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant