Skip to content

fix(fallback): keep a grapheme in one run so shaping can span it - #168

Merged
tannevaled merged 1 commit into
mainfrom
feat/cluster-aware-fallback
Aug 8, 2026
Merged

fix(fallback): keep a grapheme in one run so shaping can span it#168
tannevaled merged 1 commit into
mainfrom
feat/cluster-aware-fallback

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

The bug

fallbackFont routed each rune to the first face covering it and started a new run whenever that face changed. Runs are what reach the shaper, so a split silently defeats every substitution that spans the boundary — and the splits were landing in the middle of graphemes.

The zero-width joiner is the clearest case. Several script faces ship U+200D for their own shaping — Thai, Arabic, Devanagari and Hebrew all carry it — so plain first-face-wins routing handed the joiner to whichever came first in the chain, never to the emoji face. An emoji sequence then reached the shaper as three runs (person, joiner, rocket) and the GSUB ligature composing them into one astronaut never fired, because it never saw them together. 🧑‍🚀 drew as a person standing next to a rocket.

Worth stressing: go-opentype/shape was doing its job all along. Handed the whole sequence it produces exactly one glyph. The damage was done before it was called.

The fix

A rune that continues the preceding grapheme — a combining mark (Mn/Mc/Me, which covers the variation selectors) or a format control (Cf, which covers the joiner and non-joiner) — now stays in the current run whenever the run's own face can render it, instead of starting a new one.

It is a preference, not a trap: when the current face genuinely cannot render the mark, routing still falls back to a face that can, exactly as before (TestUncoveredMarkStillFallsBack). A leading mark, with no run to attach to, still routes by coverage.

Not emoji-specific. The same split would break an Indic cluster written with a ZWNJ, or tear a combining mark off a base whose face another entry in the chain happens to also carry.

Measure and Draw share runs, so metrics and pixels stay consistent: the composed astronaut now measures exactly one glyph's advance.

Verification

  • go test -race100.0% of statements, race-clean.
  • Proved on pixels, not just metrics: the drawn sequence fits inside one glyph's advance from the pen, reaches less far right than the un-composed pair, and paints different ink from the person glyph alone.
  • Three-part sequences compose too (👨‍👩‍👦 → one glyph).

🤖 Generated with Claude Code

fallbackFont routed each rune to the first face covering it and started a new
run whenever that face changed. Runs are what reach the shaper, so a split
silently defeats every substitution that spans the boundary — and the splits
were landing in the middle of graphemes.

The zero-width joiner is the clearest case. Several script faces ship U+200D for
their own shaping (Thai, Arabic, Devanagari and Hebrew all carry it), so plain
first-face-wins routing handed the joiner to whichever came first in the chain,
never to the emoji face. An emoji sequence then reached the shaper as THREE runs
— person, joiner, rocket — and the GSUB ligature that composes them into one
astronaut never fired, because it never saw them together. 🧑‍🚀 drew as a person
standing next to a rocket.

A rune that continues the preceding grapheme — a combining mark (Mn/Mc/Me,
which covers the variation selectors) or a format control (Cf, which covers the
joiner and non-joiner) — now stays in the current run whenever the run's own
face can render it, instead of starting a new one.

It is a preference, not a trap: when the current face genuinely cannot render
the mark, routing still falls back to a face that can, exactly as before. A
leading mark, with no run to attach to, still routes by coverage.

This is not emoji-specific. The same split would break an Indic cluster written
with a ZWNJ, or tear a combining mark off a base whose face another entry in the
chain happens to also carry.

Measure and Draw share runs, so metrics and pixels stay consistent: the composed
astronaut now measures exactly one glyph's advance.

100% coverage held; race-clean. Proved on pixels, not just metrics — the drawn
sequence fits inside one glyph's advance and paints different ink from the
person glyph alone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit e9d3272 into main Aug 8, 2026
1 check passed
@tannevaled
tannevaled deleted the feat/cluster-aware-fallback branch August 8, 2026 19:28
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