Skip to content

canvas text: a glyph missing from the registered face paints as a solid block, and off macOS that is the only text path #421

Description

@sepehr-safari

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:

  1. 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".
  2. 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.
  3. 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:

  1. In any canvas app, draw a string containing a codepoint outside the registered face, for example 日本語 or .
  2. native automate screenshot <view-label>
  3. 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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions