Skip to content

Title text: fontconfig fallback for codepoints outside the primary font#97

Open
nikicat wants to merge 1 commit into
PolyMeilex:masterfrom
nikicat:font-fallback
Open

Title text: fontconfig fallback for codepoints outside the primary font#97
nikicat wants to merge 1 commit into
PolyMeilex:masterfrom
nikicat:font-fallback

Conversation

@nikicat
Copy link
Copy Markdown

@nikicat nikicat commented May 30, 2026

Fixes #96.

Problem

Both the ab_glyph and the new skrifa title renderers load a single primary font (whatever fc-match returns for the GNOME titlebar-font preference, defaulting to Cantarell) and lay out every codepoint with that one font. Any codepoint not in the primary's charset — emoji, CJK, most symbol ranges — silently becomes .notdef, so its advance is consumed but no real glyph is drawn.

Most visible on GNOME/Mutter Wayland, which forces CSD on clients, so every titlebar Rio / Alacritty / winit-based app paints is affected. A title like hello 😀 你好 ★ renders with three identical hollow boxes.

Reproduction (verified empirically): on the pre-fix code, '★' (U+2605) and '█' (U+2588) — both absent from Cantarell — produce byte-identical pixmaps, because both are drawn as the same .notdef glyph.

Fix

New src/title/font_fallback.rs: for each title char not covered by the primary or any already-loaded face, run fc-match -f '%{file}' :charset=HEX, mmap the file once, and cache it (deduped by path; seen chars not re-queried). Discovery happens in update_title, not in render.

Both backends now parse the primary + fallback faces each render and pick the first face that has a glyph for each char (select / select_face). Layout rules:

  • Line metrics come from the primary, so fallback faces with wild ascent/descent don't grow the title bar.
  • Per-glyph advance comes from the covering face.
  • Kerning is only applied between adjacent glyphs that came from the same face.

crossfont has the same single-face limitation but a different code shape (it owns the rasterizer); leaving it as a follow-up.

The previously-warn!-level "Ignoring out of range pixel" log in the ab_glyph renderer is now debug! — it fires on the fallback path whenever a fallback glyph's outline extends past the primary's height bound, which is expected.

Tests

Two regression tests per backend (in ab_glyph_renderer.rs and skrifa_renderer.rs):

  • loads_fallback_for_missing_codepoint — bypasses the system font, sets title '★', asserts a fallback face was loaded. This is the structural invariant; the bug was that fallbacks would always be empty.
  • no_fallback_for_ascii_title — sets title "hello", asserts no fallback lookups happened.

Verified on the pre-fix code (separately) that two distinct missing codepoints produce byte-identical .notdef pixmaps — the smoking gun. The committed tests use the structural assertion rather than a pixmap comparison because the latter depends on font specifics (a .notdef that renders the hex codepoint inside the box would mask the bug).

All four feature configurations build clean (ab_glyph, skrifa, crossfont, default) and tests pass on the touched backends:

ab_glyph: 32 passed
skrifa:   38 passed

(Pre-existing clippy lints in crossfont_renderer.rs and a few mismatched_lifetime_syntaxes warnings in parts.rs / theme.rs are unrelated and untouched here.)

Downstream

Downstream user-visible report: raphamorim/rio#1622

The ab_glyph and skrifa title renderers each load a single primary
font (whatever fc-match returns for the GNOME titlebar-font setting,
defaulting to Cantarell). Any codepoint not in that one font -- emoji,
CJK, most symbol ranges -- silently rendered as the same .notdef
glyph. On GNOME/Mutter Wayland (which forces CSD), every titlebar was
affected: a title like 'hello 😀 你好 ★' rendered with three identical
hollow boxes.

This adds a shared font_fallback module that, for each title char not
covered by the primary or any already-loaded face, asks fc-match for
the smallest font with that codepoint in its charset, mmaps the file
once, and caches it. Both backends now parse the primary + fallbacks
each render and pick the first face that has a glyph for each char.
Line metrics still come from the primary so fallback faces with wild
ascent/descent don't grow the bar; per-glyph advance comes from the
covering face. Kerning is only applied within the same face.

The bug was verified by rendering two distinct missing codepoints
('★' U+2605 and '█' U+2588, both absent from Cantarell) on the
pre-fix code path -- the resulting pixmaps were byte-identical (both
.notdef). The regression tests assert that a fallback face is loaded
for a known-missing codepoint, which is the structural invariant.

crossfont has the same single-face limitation but a different code
shape (it owns the rasterizer); leaving as a follow-up.

Refs raphamorim/rio#1622
Refs PolyMeilex#96
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.

Title text: no font fallback — non-Cantarell codepoints (emoji, CJK, …) render as .notdef

1 participant