fix(fallback): keep a grapheme in one run so shaping can span it - #168
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
fallbackFontrouted 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+200Dfor 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/shapewas 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.
MeasureandDrawshareruns, so metrics and pixels stay consistent: the composed astronaut now measures exactly one glyph's advance.Verification
go test -race→ 100.0% of statements, race-clean.🤖 Generated with Claude Code