Grapheme Clusters and Terminal Emulators

Mitchell Hashimoto

書記素クラスターとターミナルエミュレーター

ターミナルエミュレーターで「🧑‍🌾」をコピー&ペーストしてみてください。カーソルは何セル分進みましたか? ターミナルエミュレーターによっては、2、4、5、または6セル進んだかもしれません1。ひどい話です。この記事では、なぜこうなるのか、そしてターミナルエミュレーターやプログラムの作者がすべての文字について一貫した間隔を実現するにはどうすればよいかを説明します。


歴史的に見た文字グリッド

ターミナルは固定サイズのセルからなるグリッド上で動作します。プログラムがターミナルに送信できる制御シーケンスの多くがセルを単位に動作するため、これはターミナルエミュレーターの仕組みの根幹です。

たとえば、カーソルを左へ CSI n D、右へ CSI n C、上へ CSI n A、下へ CSI n B 動かす制御シーケンスがあります。いずれの「n」も移動するセル数です。カーソル位置を要求するシーケンス CSI 6 n もあります。ターミナルはプログラムに対し CSI y ; x R という形式で応答し、「y」と「x」はともにセル座標です。

従来、ターミナルは入力バイトストリームをそのまま読み、個々のバイトをグリッド内のセルに対応付けていました。たとえば、ストリーム「1234」は4バイトです。プログラマーは非常に簡単に、一度に1バイトを読み取って次のセルへ配置し、カーソルを右に1つ移動させ、これを繰り返せます。

やがて「幅広文字」が登場しました。一般的な幅広文字には、橋のようなアジアの文字や、😃 のような絵文字があります。幅広文字のセル単位の幅を返す wcwidth 関数が libc に追加されました。幅広文字には(通常)「2」という幅が与えられました。したがって、ターミナルエミュレーターで橋と入力すると、その文字はグリッドの2セルを占め、カーソルは2セル分進むはずです。

現在でも、大半のターミナルエミュレーターとターミナルプログラム(シェル、TUI など)はこのように実装されています。入力文字を wcwidth で処理し、それに応じてカーソルを移動させます。しばらくの間は、これでまったく問題ありませんでした。しかし今日では不十分であり、多くのエラーを生じさせます。


書記素クラスタリング

世界中のあらゆるユーザー知覚文字を、単一の32ビット値で表すことはできないと分かっています。「ユーザー知覚文字」は、Unicode Standard が書記素を定義する際の表現です。

絵文字「🧑‍🌾」を考えてみましょう。お使いのコンピューターが対応していない場合に備えると、この絵文字はおおむねこのように見えるはずです。これは単一の「ユーザー知覚文字」、すなわち書記素だという点には、誰もが同意すると思います。Unicode Standard 自身もこれを単一の書記素として定義しています。個人的な見解がどうであれ、国際標準ではこれを1つの書記素としています。

コンピューターにとっては、それほど自明ではありません。「🧑‍🌾」は3つのコードポイント(U+1F9D1 🧑、U+200D、U+1F33E 🌾)から成ります。UTF-32 では3つの32ビット値であり、UTF-8 では11バイト2です。

歴史的にそうしてきたように、32ビット値だけを考慮して、それぞれを個別に wcwidth に渡すと、各コードポイントに対して順に「2」「0」「2」という結果を得ます。したがって、カーソルは4セル分進みます。これが、この記事の執筆時点で大半のターミナルではカーソルが4セル分進む理由です。

幅ゼロ文字とは何でしょうか。コードポイント U+200Dゼロ幅接合子(ZWJ)として知られ、標準で定義された幅はゼロです。ZWJ は、周囲にあるコードポイントを結合して1つの文字として扱うよう、テキスト処理システムに指示します。だから「🧑‍🌾」と「🧑🌾」の両方を入力できます。引用符内のこの2つの値の違いは、左の農家の2つの絵文字の間にゼロ幅接合子があるかどうかだけです。

そこで書記素クラスタリングの登場です。書記素クラスタリングとは、コードポイントのストリームから単一の書記素を判定する処理です。これにより、プログラムは3つの32ビット値を、1つのユーザー知覚文字として認識できます。書記素クラスタリングのアルゴリズムは、UAX #29「Unicode Text Segmentation」で定義されています。アルゴリズムの詳細には立ち入りません。お使いのプログラミング言語向けのモダンな Unicode ライブラリを使えば、書記素クラスタリングを実行できるはずです。

書記素クラスタリングで特に難しいのは、これが状態を持つことです。書記素の区切り位置を堅牢に判定するには、前のコードポイント、現在のコードポイント、そして整数の状態値にアクセスできなければなりません。そのため、既存のプログラムに組み込むのはものすごく簡単というわけではありません(とはいえ難しくはありません)。

書記素クラスタリングを使えば、ターミナルは「🧑‍🌾」を1つの幅広文字の書記素として扱い、4セルではなく2セルだけカーソルを進めます。

これは絵文字だけの話ではありません。 絵文字を例にしていますが、世界の言語を正しく扱うためにも書記素クラスタリングは極めて重要です。たとえばアラビア文字は、多くの場合複数のコードポイントで構成されます。

余談: フォントシェーピング。書記素クラスタリングが解決するのは、コードポイントストリーム中の書記素境界を判定する問題だけです。コードポイントストリームを描画する問題は解決しません。そのためには、Harfbuzz のようなフォントシェイパーが必要です。Harfbuzz はコードポイントのストリームを受け取り、書記素を検出し、それらの書記素をフォント内の個々の字形へ対応付けられます。

この記事ではフォントシェーピングは扱いません。ただし、ターミナルに「🧑‍🌾」をペーストして1つではなく2つの絵文字が表示されるなら、ターミナルがゼロ幅接合子を取り除いているか、より可能性が高いのはフォントシェーピングに対応していないためです。


ターミナルにおける書記素クラスタリング

現在、大半のターミナルは書記素クラスタリングに対応していません。主な理由は、シェルやテキストエディターのような高度なターミナルアプリケーションが、カーソルがどこにあるかを常に正確に把握し、ターミナルのグリッド状態と同期を保つ必要があるためです。

ターミナルは歴史的に wcwidth を使用してきたため、シェル、エディター、その他の TUI アプリも wcwidth を使っており、現在も使い続けています。複数コードポイントからなる書記素では誤った値になりますが、少なくともその誤った値は、ターミナルエミュレーター間で一貫して誤っていることが多いのです。

自分のターミナル向けに初めてテキスト処理を実装したとき、良いことだろうと思い、完全な書記素クラスター対応を実装しました。しかし、適切な書記素クラスタリングを行うと、カーソルを動かした際に fish shell がプロンプトを誤った位置に再描画することがすぐに分かり、がっかりしました。😞 シェルはターミナルが wcwidth を使うと仮定し、ターミナルはプログラムが気にしないと仮定していました。どちらも間違っていたので、書記素クラスタリングを無効にしました……

ここで mode 2027 の登場です。Mode 2027 は、ターミナルにおける書記素対応の提案です。この提案は、Contour terminal の作者によるものです。ターミナル上で動作するプログラムが、書記素クラスタリングの完全対応で動作したいことをターミナルに通知でき、この機能のオン・オフを切り替えられるようにするという考えです。実行中のプログラムは、ターミナルがこの機能に対応しているか問い合わせることもできます。

最近、自分のターミナルに mode 2027 のサポートを実装しました。下の図で動作を確認できます。mode 2027 をオフにすると、農家の絵文字の後でカーソルは5列目に移動します(幅4)。mode 2027 をオンにすると、カーソルは3列目に移動します(幅2)。


ターミナルの比較

以下の表は、各種ターミナルが報告する「🧑‍🌾」の幅を示しています。各ターミナルについて、2023年10月2日時点で見つけられた最新版を使用しました。自分のターミナルを一覧の先頭に置き、それ以外はアルファベット順に並べています。

ターミナルMode 2027注記
Ghostty2mode 2027 が無効なら wcwidth にフォールバックする
Alacritty4シェーピングに対応しておらず、別々の2つの絵文字として表示する
Contour2Mode 2027 を考案。常に書記素クラスタリングを行う
Foot2mode 2027 が無効なら wcwidth にフォールバックする
Gnome4シェーピングに対応しておらず、別々の2つの絵文字として表示する
iTerm2常に書記素クラスタリングを行う
Kitty4
Tmux4これがターミナルエミュレーターと一致しないと、特に面白いことになる
Terminal.app6🤡 呪われた小さな独自世界で生きている
Warp4シェーピングに対応しておらず、別々の2つの絵文字として表示する
Wezterm2常に書記素クラスタリングを行う
Windows Terminal5🧐 ZWJ を1セルとみなし、別々の2つの絵文字として表示する
Xfce4シェーピングに対応しておらず、別々の2つの絵文字として表示する
xterm4シェーピングに対応しておらず、別々の2つの絵文字として表示する

Mode 2027 の対応はまだ珍しく、サポートも比較的少ない状況です。ただし、すべてのターミナルで報告される幅にこれほどばらつきがあるのは興味深いところです。「2」も「4」も理解できる値です。「5」や「6」を報告するターミナルは、奇妙な現実を生きています。

tmux や zellij のようなターミナルマルチプレクサーには、特有の難しさがあります。上の表で分かるように、tmux は wcwidth を使うため、カーソルを4セル分進めます。しかし tmux 自体もターミナルエミュレーターの中で動作しており、値が出力されるとそのターミナルエミュレーターも値を認識します。tmux とターミナルが一致しなければ、カーソルの同期がずれ、結果として起こる不具合は滑稽なものになり得ます3


今日、プログラム作者にできること

ターミナル上で動作するプログラム(テキストエディター、CLI、TUI など)の作者であれば、書記素クラスターをより適切に扱うために今すぐできることがあります。

第一に、気にしないという選択ができます。プログラムがカーソル位置を追跡する必要も、手動で改行する必要もないなら、単に気にしないで、ターミナルの挙動に任せればよいのです。

第二に、mode 2027 の対応状況を問い合わせ、使えるなら mode 2027 を使うべきです。標準シーケンス DECRQM(モード要求)で mode 2027 の対応状況を問い合わせられます: CSI ? 2027 $ p

第三に、テキスト列を出力した後、CSI 6 n を使用してカーソル位置を問い合わせられます。するとターミナルがカーソル位置を報告するので、それを使ってテキストの幅を計算できます。

行うべきでないことは、wcwidth の挙動または書記素クラスタリングの挙動を前提にすることです。前節の表から分かるとおり、一般的なものや主流のものだけを考慮したとしても、どちらの前提も複数のターミナルエミュレーターで間違っています。


将来は、より多くのターミナルとターミナルプログラムが mode 2027 と適切な書記素クラスタリングに対応することを願っています。前述のとおり、影響を受けるのは絵文字のような「かわいい」機能だけではありません。アラビア語など、世界のさまざまな言語にも影響します。

ターミナルやターミナルプログラムの作者ではない方にも、この記事が有益だったなら幸いです。ターミナルエミュレーターを実装したことで、テキスト処理の複雑さとレガシーな挙動がもたらす負担を目の当たりにし、私自身にとっても確かに目を開かされる経験になりました。

mode 2027 を提案した Christian Parpart にも、簡単に感謝を述べたいと思います。書記素クラスターとターミナルの問題を公に語り、何か対策しようと決めた人として、私が最初に見たのが Christian でした。ありがとう!

脚注

  1. 幸いなことに、3セル進むターミナルエミュレーターはまだ見つけていません。

  2. ここでは8ビットを1バイトと仮定します。現在ではかなり安全な仮定です。

  3. 幸い、tmux はカーソル位置をかなり積極的に手動制御しているため、これを引き起こすのは非常に困難です。

原文は Mitchell Hashimoto により に公開されました。

この記事は「gpt-5.6-terra」を使用して翻訳されました。