A glyph the registered face does not have is painted as a solid filled rectangle in the text colour, not as .notdef and not skipped. On macOS you rarely see it because AppKit draws the text. Off macOS the reference rasteriser is the only text path there is, so this is every glyph in the app.
All references are v0.10.1, and I checked main (5 commits ahead, none touching src/primitives/canvas/font_ttf.zig, reference.zig or src/platform/linux/).
The mechanism
drawGlyphBox inks the outline and, on failure, fills the cell:
// src/primitives/canvas/reference.zig:838-840
if (codepoint) |cp| {
if (self.drawGlyphOutline(command, value, draw_bounds, cp, pen_x, baseline, block_rect.width)) return;
}
self.fillTextRect(command.transform.transformRect(block_rect).normalized(), draw_bounds, value.color, command.opacity);
Three limits make that fallback easy to reach, and none can be worked around from the app:
- No cascade. One font id resolves to exactly one face and stops (
referenceFaceForFontId, reference.zig:1152-1157). The comment at :1154 states it: "a registered face never cascades".
- cmap format 4 only.
parse accepts only a format-4 subtable (font_ttf.zig:201) and glyphIndex starts with if (codepoint > 0xFFFF) return 0; (font_ttf.zig:226). Most emoji are astral, so they can never resolve, whatever face is registered.
glyf/loca only, no CFF and no colour tables (font_ttf.zig:1-9), so registering a colour emoji font instead is not an option either.
Why it matters
Your bundled src/primitives/canvas/fonts/Geist-Regular.ttf maps 650 codepoints: Latin and Cyrillic. Everything else hits the fallback. CJK, kana, Hangul, Arabic, Hebrew, Devanagari, Thai, and every emoji. U+2713, U+2764, U+20BF and U+2318 are absent too.
Two effects make it look worse than "a few boxes":
- CJK has no spaces, so a Japanese paragraph inks as one unbroken bar per line.
- The renderer walks per codepoint, so one ZWJ family emoji paints as seven separate blocks.
On Linux this is the whole UI: at v0.10.1 src/platform/linux/root.zig contains the string "font" zero times, while src/platform/macos/root.zig has 34 matches. There is no host text layer to fall back to.
It also reaches macOS in one place worth knowing about: native automate screenshot renders through the same reference surface, so screenshots of a correct macOS window come back with blocks where the emoji are.
Reproduction
No Linux needed:
- In any canvas app, draw a string containing a codepoint outside the registered face, for example
日本語 or ✅.
native automate screenshot <view-label>
- The PNG has solid rectangles where those characters are.
Suggested fixes, smallest first
1. Draw .notdef instead of filling the cell. This is the one that changes how the app reads, and it needs no new font machinery. Every TrueType face has glyph 0, and Geist's is a hollow box. drawGlyphOutline already resolves a codepoint to a glyph index internally, so the change is to split out the part that renders a known glyph index and call it with 0 on the miss, falling back to drawing nothing if glyph 0 has no contours. fillTextRect then only serves the genuinely unrenderable case.
Alone, this is the difference between "some characters are missing" and "the app looks broken".
2. Cascade on a miss. In referenceFaceForFontId, on a zero glyph index, try the other registered faces and then the bundled geist_regular / geist_mono before giving up. One caution: text_metrics.zig must resolve the same way, or text is measured against one face and inked with another, and the layout drifts. Keeping the cascade to the miss path only should preserve today's determinism.
3. Accept cmap format 12. parse currently scans for format 4 (font_ttf.zig:190-201); accepting format 12 as well (platform 3 encoding 10, or platform 0 encoding 4) and dropping the > 0xFFFF guard at :226 unlocks astral codepoints. Format 12 is a sorted array of (startCharCode, endCharCode, startGlyphID) groups, so lookup is a binary search over nGroups. This is the change that makes emoji possible at all.
4. Colour glyphs (CBDT/sbix/COLR) are a much bigger job. Monochrome emoji via 3 would already be a large improvement.
1 and 2 together would make a Linux build presentable with the faces you already bundle.
I am shipping a Zig canvas app on macOS and preparing a Linux build, so I can test a fix on both.
Related but separate, and I am happy to file it on its own if you prefer: the reserved weight ids resolve to the regular face in the reference renderer (reference.zig:1152-1157), and an app cannot register into them (error.ReservedFontId below min_registered_font_id, canvas_fonts.zig:147), so off macOS every weight renders regular. It is the same subsystem, which is why I mention it here.
A glyph the registered face does not have is painted as a solid filled rectangle in the text colour, not as
.notdefand not skipped. On macOS you rarely see it because AppKit draws the text. Off macOS the reference rasteriser is the only text path there is, so this is every glyph in the app.All references are v0.10.1, and I checked
main(5 commits ahead, none touchingsrc/primitives/canvas/font_ttf.zig,reference.zigorsrc/platform/linux/).The mechanism
drawGlyphBoxinks the outline and, on failure, fills the cell:Three limits make that fallback easy to reach, and none can be worked around from the app:
referenceFaceForFontId, reference.zig:1152-1157). The comment at :1154 states it: "a registered face never cascades".parseaccepts only a format-4 subtable (font_ttf.zig:201) andglyphIndexstarts withif (codepoint > 0xFFFF) return 0;(font_ttf.zig:226). Most emoji are astral, so they can never resolve, whatever face is registered.glyf/locaonly, no CFF and no colour tables (font_ttf.zig:1-9), so registering a colour emoji font instead is not an option either.Why it matters
Your bundled
src/primitives/canvas/fonts/Geist-Regular.ttfmaps 650 codepoints: Latin and Cyrillic. Everything else hits the fallback. CJK, kana, Hangul, Arabic, Hebrew, Devanagari, Thai, and every emoji. U+2713, U+2764, U+20BF and U+2318 are absent too.Two effects make it look worse than "a few boxes":
On Linux this is the whole UI: at v0.10.1
src/platform/linux/root.zigcontains the string "font" zero times, whilesrc/platform/macos/root.zighas 34 matches. There is no host text layer to fall back to.It also reaches macOS in one place worth knowing about:
native automate screenshotrenders through the same reference surface, so screenshots of a correct macOS window come back with blocks where the emoji are.Reproduction
No Linux needed:
日本語or✅.native automate screenshot <view-label>Suggested fixes, smallest first
1. Draw
.notdefinstead of filling the cell. This is the one that changes how the app reads, and it needs no new font machinery. Every TrueType face has glyph 0, and Geist's is a hollow box.drawGlyphOutlinealready resolves a codepoint to a glyph index internally, so the change is to split out the part that renders a known glyph index and call it with0on the miss, falling back to drawing nothing if glyph 0 has no contours.fillTextRectthen only serves the genuinely unrenderable case.Alone, this is the difference between "some characters are missing" and "the app looks broken".
2. Cascade on a miss. In
referenceFaceForFontId, on a zero glyph index, try the other registered faces and then the bundledgeist_regular/geist_monobefore giving up. One caution:text_metrics.zigmust resolve the same way, or text is measured against one face and inked with another, and the layout drifts. Keeping the cascade to the miss path only should preserve today's determinism.3. Accept cmap format 12.
parsecurrently scans for format 4 (font_ttf.zig:190-201); accepting format 12 as well (platform 3 encoding 10, or platform 0 encoding 4) and dropping the> 0xFFFFguard at :226 unlocks astral codepoints. Format 12 is a sorted array of(startCharCode, endCharCode, startGlyphID)groups, so lookup is a binary search overnGroups. This is the change that makes emoji possible at all.4. Colour glyphs (CBDT/sbix/COLR) are a much bigger job. Monochrome emoji via 3 would already be a large improvement.
1 and 2 together would make a Linux build presentable with the faces you already bundle.
I am shipping a Zig canvas app on macOS and preparing a Linux build, so I can test a fix on both.
Related but separate, and I am happy to file it on its own if you prefer: the reserved weight ids resolve to the regular face in the reference renderer (reference.zig:1152-1157), and an app cannot register into them (
error.ReservedFontIdbelowmin_registered_font_id, canvas_fonts.zig:147), so off macOS every weight renders regular. It is the same subsystem, which is why I mention it here.