書記素クラスタ
人間が 1 文字と認識する最小の表示単位。複数のコードポイントで構成されることがある。
書記素クラスタ (Grapheme Cluster) とは、人間が視覚的に 1 文字と認識する最小の表示単位です。1 つの書記素クラスタは 1 つまたは複数の Unicode コードポイントで構成されます。Unicode 標準の UAX #29 (Unicode Text Segmentation) で定義されており、テキスト処理における「文字」の境界を正確に判定するための基盤となる概念です。
書記素クラスタが複数のコードポイントで構成される代表的な例として、結合文字と絵文字があります。日本語の「が」は、合成済み形式 (U+304C) では 1 コードポイントですが、分解形式では「か」(U+304B) と濁点 (U+3099) の 2 コードポイントで構成されます。どちらの形式でも、書記素クラスタとしては 1 つです。国旗絵文字 🇯🇵 は 2 つの Regional Indicator コードポイント (U+1F1EF + U+1F1F5) の組み合わせであり、家族絵文字 👨👩👧👦 は 7 つのコードポイント (4 つの人物絵文字 + 3 つの ZWJ) で構成されますが、いずれも書記素クラスタとしては 1 つです。
プログラミング言語によって「文字数」の定義は異なり、これが書記素クラスタの理解を重要にしています。JavaScript の String.length は UTF-16 のコードユニット数を返すため、絵文字 1 つが 2 以上にカウントされることがあります。Python の len() はコードポイント数を返しますが、結合文字を含む場合は見た目の文字数と一致しません。正確な「見た目の文字数」を得るには、書記素クラスタ単位でのカウントが必要です。前述の家族絵文字を実際に数えると、JavaScript の String.length は 11、コードポイント数は 7、書記素クラスタ数は 1 になります。同じ「1 文字」がどの単位で数えるかによって 3 通りの答えを持つ、という点が書記素クラスタを理解する要になります。
JavaScript では Intl.Segmenter API を使って書記素単位の分割が可能です。new Intl.Segmenter('ja', { granularity: 'grapheme' }) でセグメンターを作成し、segment() メソッドで文字列を書記素クラスタに分割できます。Python では grapheme ライブラリ、Swift では String.count がデフォルトで書記素クラスタ単位のカウントを提供します。Intl.Segmenter で第 1 引数にロケールを渡す例をよく見かけますが、granularity: 'grapheme' の分割結果はロケールをどう指定しても基本的に変わりません。ロケールが効くのは単語 ('word') や文 ('sentence') の分割です。一方で、書記素の境界判定は実行環境が内蔵する Unicode データのバージョンに依存します。新しい絵文字シーケンスを古いブラウザや古い実行環境で数えると、想定より多い数が返ることがあるため、サーバー側とクライアント側でカウント結果を突き合わせる設計では両者の環境差を確認しておくと安全です。
書記素クラスタに関するよくある誤解として、「1 コードポイント = 1 文字」という前提があります。この前提は ASCII の範囲では正しいですが、結合文字、サロゲートペア、ZWJ シーケンスを含むテキストでは破綻します。テキストの切り詰め (truncation) やカーソル移動の実装で書記素クラスタを考慮しないと、文字の途中で分割されて文字化けが発生したり、カーソルが見た目の 1 文字を飛び越えたりする問題が起きます。
文字数カウントツールが正確であるためには、書記素クラスタを正しく扱うことが不可欠です。ユーザーが「1 文字」と認識するものを正確に 1 としてカウントすることで、フォームの入力制限や原稿の分量把握など、実用的な場面で信頼性の高い結果を提供できます。ただし注意したいのは、書記素クラスタ数がそのまま各サービスの上限判定と一致するとは限らないことです。X (Twitter) のように文字ごとの重みで上限を判定する方式では、見た目が 1 文字でも消費する枠は 1 ではありません。「人間にとっての 1 文字」を示す指標と「そのサービスの上限をどれだけ消費したか」を示す指標は役割が別だと切り分けて考える必要があります。Unicode のバージョンアップに伴い新しい絵文字シーケンスが追加されるため、書記素クラスタの判定ルールも継続的に更新されている点に留意が必要です。