Keeping Emoji Families Together in Vertical Text

โšก Chromium ๐Ÿ”ง C++ / Blink / Unicode ๐Ÿ‘ค Helmut Januschka

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".

before
(content_shell)
Pre-fix content_shell: four separate people stacked in vertical-rl
after
(content_shell)
Patched content_shell: one family glyph in vertical-rl
live
(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"):

before (content_shell)
Pre-fix content_shell: emoji confetti inside vertical Japanese text
after (content_shell)
Patched content_shell: family and polar bear as single glyphs inside vertical Japanese text
live (your browser)
ๅฎถๆ—ใฏ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆใงใ™ใ€‚
ๅŒ—ๆฅตใซใฏ๐Ÿปโ€โ„๏ธใŒใ„ใ‚‹ใ€‚

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:

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:

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.

pirate flag + polar bear
before
Pre-fix content_shell: flag, skull, bear and snowflake stacked separately
after
Patched content_shell: pirate flag and polar bear as single glyphs in vertical-rl
live
๐Ÿดโ€โ˜ ๏ธ๐Ÿปโ€โ„๏ธ
technologist + farmer
before
Pre-fix content_shell: woman, laptop, man and sheaf stacked separately
after
Patched content_shell: technologist and farmer as single glyphs in vertical-rl
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. ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ


Thanks to: