ZWJ (Zero Width Joiner)
幅ゼロの結合子。Unicode で複数の文字や絵文字を 1 つの表示単位に結合するために使われる制御文字 (U+200D)。
ZWJ (Zero Width Joiner) は、Unicode の制御文字 (U+200D) で、画面上には表示されないが、前後の文字を結合して 1 つの書記素クラスタとして扱わせる役割を持ちます。最も身近な用途は絵文字の結合です。たとえば家族の絵文字 👨👩👧👦 は、男性 + ZWJ + 女性 + ZWJ + 女の子 + ZWJ + 男の子 という 7 つのコードポイントで構成されています。
ZWJ による絵文字結合は、限られたコードポイントから多様な絵文字を作る仕組みとして段階的に拡張されてきました。職業や性別との組み合わせ (👨🎤 = 男性 + ZWJ + マイク)、家族構成、複数人のグループなどがこの方式で表現されています。ただし、ZWJ シーケンスに対応していない環境では、結合されずに個別の絵文字が並んで表示されます。
よく混同されますが、肌の色は ZWJ とは別の仕組みです。肌の色は絵文字修飾子 (U+1F3FB〜U+1F3FF) を人物の絵文字の直後に置いて指定するもので、🙋🏽 は「手を挙げる人」+ 修飾子の 2 コードポイントであり ZWJ を含みません。ZWJ が担うのは、修飾済みの絵文字どうしを 1 つの表示単位に束ねる部分です。
文字数カウントにおいて ZWJ は厄介な存在です。見た目は 1 つの絵文字でも、内部的には複数のコードポイントで構成されているため、数え方によって「長さ」が変わります。👨👩👧👦 の場合、JavaScript の .length は UTF-16 コードユニット数を返すため 11、Python の len() は Unicode コードポイント数で 7、Swift は書記素クラスタ数で 1 です。JavaScript でも Array.from() やスプレッド構文はコードポイント単位のため 7 にしかならず、人の目に見える 1 文字として数えるには Intl.Segmenter を書記素粒度で使う必要があります。
SNS の文字数制限では、X (旧 Twitter) の数え方が独特です。X は加重長という方式を採り、上限は 280 で、ラテン文字などは重み 1、日本語や絵文字は重み 2 です (日本語だけなら実質 140 文字)。重要なのは、ZWJ シーケンスも丸ごと 1 つの絵文字として重み 2 で数えられる点です。X が公開している文字数カウントの適合テストには、家族絵文字 👨👩👧👦 を 140 個並べた文字列の加重長が 280 で投稿可能、という例が含まれています。コードポイント数なら 980 になる文字列が、単純な絵文字 140 個と同じ扱いになるわけです。したがって「ZWJ シーケンスは文字数を食うから避ける」という節約は X では成り立ちません。一方、自作のフォームなどでコードポイント数や UTF-16 コードユニット数を上限にしている場合は、家族の絵文字のような長い ZWJ シーケンスでは 1 つで 7〜11 を消費します (シーケンスの構成要素数に応じて変わります)。上限を設計するときは、どの単位で数えるかを先に決めておく必要があります。
もう 1 つの落とし穴は文字列の切り詰めです。ZWJ シーケンスの途中で切ると、1 つだった絵文字が別の絵文字の並びに変わったり、結合先を失った ZWJ が末尾に残ったりします。切り詰めはコードポイント単位ではなく書記素クラスタ単位で行うのが安全です。