For eight years, Chrome broke up every emoji family that dared to go vertical. ๐จโ๐ฉโ๐งโ๐ฆ walked into writing-mode: vertical-rl and came out as four separate people.
Status: ๐ Landed
The Bug: A Family Torn Apart
Modern emoji are often not one character. The family emoji ๐จโ๐ฉโ๐งโ๐ฆ is actually seven code points: man, ZWJ, woman, ZWJ, girl, ZWJ, boy. The ZERO WIDTH JOINER (U+200D) glues them together, and the font renders the whole sequence as a single glyph.
That worked fine in horizontal text. But switch to vertical writing mode - the kind used for Japanese novels, signage, and anything with writing-mode: vertical-rl - and Chrome split the sequence apart:
<div style="writing-mode: vertical-rl">ๅฎถๆใฏ๐จโ๐ฉโ๐งโ๐ฆใงใ</div>
Instead of one family glyph, you got ๐จ ๐ฉ ๐ง ๐ฆ - four individual emoji stacked on top of each other. Same for the polar bear ๐ปโโ๏ธ (which decomposed into a regular bear and a snowflake โ๏ธ), the pirate flag ๐ดโโ ๏ธ (a plain black flag and a skull), and every other ZWJ sequence.
The bug was filed in 2018 against Chrome 68: "Elements with writing-mode:tb-rl don't display ZWJ Emoji sequences expectedly." It sat there for eight years while emoji ZWJ sequences only got more common.
Try It: Live Samples
The first two columns are real content_shell screenshots from the same patched build, with the EmojiZWJVerticalOrientation flag off (before) and on (after). The third column is live in your browser - if it has the fix, it matches the "after" shot; if not, it looks like "before".
(content_shell)
(content_shell)
(your browser)
And in context, the way the original reporter hit it - emoji inside vertical Japanese text ("The family is ๐จโ๐ฉโ๐งโ๐ฆ" / "There are ๐ปโโ๏ธ at the North Pole"):
ๅๆฅตใซใฏ๐ปโโ๏ธใใใใ
Why It Broke: The ZWJ Is "Rotated"
Vertical text in Blink goes through an OrientationIterator that splits text into runs before shaping. Each run gets one of two orientations from UAX #50:
- Upright - CJK characters, emoji: drawn as-is, stacked vertically
- Rotated - Latin letters, punctuation: rotated 90 degrees sideways
Emoji are Upright. But the invisible ZWJ between them is classified Rotated. The iterator only treated Grapheme_Extend characters as "part of the previous cluster", and ZWJ is not Grapheme_Extend - it is its own grapheme-break class.
So for ๐จโ๐ฉโ๐งโ๐ฆ the iterator produced seven runs:
๐จ โ Upright
ZWJ โ Rotated โ run boundary!
๐ฉ โ Upright โ run boundary!
ZWJ โ Rotated ...
๐ง โ Upright
ZWJ โ Rotated
๐ฆ โ Upright
Each run is shaped separately by HarfBuzz. The font never sees the full sequence in one shaping call, so the ligature that forms the single family glyph can never apply. The family gets split up by an invisible character whose entire purpose is to hold them together. ๐
The Fix: Follow the Grapheme Rules
UAX #29 already defines exactly when characters belong to the same grapheme cluster:
- GB9: don't break before Extend or ZWJ
- GB11: don't break between
Extended_Pictographic ZWJand anotherExtended_Pictographic
The fix teaches the orientation iterator those two rules (CL 8494798):
// UAX #29 rule GB9 keeps Extend and ZWJ in the preceding grapheme cluster, and
// rule GB11 keeps a pictograph that a ZWJ joins to a preceding pictograph:
// \p{Extended_Pictographic} Extend* ZWJ x \p{Extended_Pictographic}.
bool ExtendsGraphemeCluster(UChar32 character,
UChar32 cluster_base,
bool after_zwj) {
if (Character::IsGraphemeExtended(character)) {
return true;
}
if (character == uchar::kZeroWidthJoiner) {
return true;
}
return after_zwj && Character::IsExtendedPictographic(character) &&
Character::IsExtendedPictographic(cluster_base);
}
Now the whole sequence stays in one Upright run, HarfBuzz shapes it in one pass, and the font's ligature does its job. The orientation still comes from the cluster's first character, exactly as UAX #50 prescribes for grapheme clusters.
The GB11 condition matters for correctness: a ZWJ between two Latin letters (a + ZWJ + b) does not merge runs - only pictograph-to-pictograph joins do. The new behaviour sits behind a runtime flag (EmojiZWJVerticalOrientation), on by default, as a kill switch in case something regresses.
More Victims, Reunited
A small gallery of sequences that were being decomposed. Each triple is the same content_shell build with the flag off, with the flag on, and your own browser rendering it live.
The same applied to every other ZWJ sequence - ๐งโ๐ ๐งโ๐ณ โค๏ธโ๐ฅ ๐ถโ๐ซ๏ธ - anything held together by an invisible joiner fell apart the moment the text turned vertical.
The Takeaway
Run segmentation happens before shaping, so any segmenter that splits inside a grapheme cluster silently defeats font ligatures - no error, no warning, just a bear standing next to a snowflake wondering what happened. If an iterator decides where shaping runs begin and end, it has to respect UAX #29 cluster boundaries, not just the Grapheme_Extend property.
Four files, ~80 lines, one eight-year-old bug, and the family is back together. ๐จโ๐ฉโ๐งโ๐ฆ
Links
- MERGED Keep emoji ZWJ sequences in one vertical orientation run
- Issue 41384307: Elements with writing-mode:tb-rl don't display ZWJ Emoji sequences expectedly
- Interactive sampler: before/after vertical emoji rendering
- UAX #29: Unicode Text Segmentation (GB9, GB11)
- UAX #50: Unicode Vertical Text Layout
Thanks to:
- Kent Tamura for the review and for suggesting the runtime-flag kill switch