グラフェムクラスタとターミナルエミュレータ
原文は 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」はいずれもセル座標です。
従来、ターミナルは入力バイトストリームを単純に読み込み、1バイトごとにグリッドの1セルへマッピングしていました。例えば「1234」というストリームは4バイトであり、プログラマは1バイトずつ読み取って次のセルに配置し、カーソルを1つ右に移動する、という処理を繰り返すだけで済みました。
やがて「ワイド文字」が登場しました。代表的なワイド文字は橋のようなアジアの文字や😃のような絵文字です。libcにはワイド文字のセル単位での幅を返すwcwidthという関数が追加されました。ワイド文字には(通常)幅「2」が与えられました。したがって、ターミナルエミュレータで橋と入力すると、その文字はグリッド上で2セル分を占有し、カーソルは2セル分進むことになります。
そして、これが今日のほとんどのターミナルエミュレータやターミナルプログラム(シェル、TUIなど)の実装方法です。入力文字をwcwidthで処理し、それに応じてカーソルを移動させます。しばらくの間、これはまったく問題なく機能していました。しかし今日では、もはや十分ではなく、多くの不具合を生んでいます。
グラフェムクラスタリング
単一の32ビット値では、世界中のすべてのユーザーが知覚する文字を表現するには不十分であることがわかってきました。「ユーザーが知覚する文字」こそ、Unicode標準がグラフェムと定義しているものです。
絵文字「🧑🌾」を例に考えてみましょう。この絵文字は、お使いのコンピュータで表示されない場合に備えてこんな見た目になるはずです。誰もがこれを1つの「ユーザーが知覚する文字」、すなわちグラフェムだと認めるでしょう。Unicode標準自体がこれを単一のグラフェムとして定義しているため、個人的な意見がどうであれ、国際標準上は1つのグラフェムなのです。
コンピュータにとっては、そう単純ではありません。「🧑🌾」は3つのコードポイント(U+1F9D1 🧑、U+200D、U+1F33E 🌾)であり、UTF-32エンコーディングでは3つの32ビット値、UTF-8エンコーディングでは11バイト2になります。
もし32ビット値だけを考え、従来どおり1つずつ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はコードポイントのストリームを読み取り、グラフェムを検出し、それらをフォント内の個々のグリフにマッピングすることができます。
この記事ではフォントシェーピングについては扱いませんが、もし「🧑🌾」をターミナルに貼り付けて2つの絵文字として表示された場合、それはターミナルがゼロ幅接合子を取り除いているか、あるいはより可能性が高いのはターミナルがフォントシェーピングに対応していないためです。
ターミナルにおけるグラフェムクラスタリング
今日のほとんどのターミナルはグラフェムクラスタリングに対応していません。大きな理由の一つは、シェルやテキストエディタのような高度なターミナルアプリケーションが、常にカーソルがどこにあるかを正確に把握し、ターミナルのグリッド状態と同期を保つ必要があるためです。
歴史的にターミナルがwcwidthを使ってきたため、シェルやエディタ、その他のTUIアプリもwcwidthを使い、今日でも使い続けています。複数コードポイントからなるグラフェムに対しては誤った値を返しますが、少なくともその誤った値はターミナルエミュレータ間でしばしば一貫して誤っているのです。
私のターミナル向けにテキスト処理を初めて実装したとき、私は良いことだと思い完全なグラフェムクラスタ対応を実装しました。しかし、適切なグラフェムクラスタリングを行うと、カーソルを移動した際にfish shellがプロンプトを間違った位置に再描画してしまうことにすぐに気づき、がっかりしました。😞 シェルはターミナルがwcwidthを使っていると想定し、ターミナルはプログラムは気にしないと想定していました。どちらも間違っていたため、私はグラフェムクラスタリングを無効にしました……
そこで登場するのがモード2027です。モード2027はターミナルにおけるグラフェム対応の提案です。この提案はContourターミナルの作者によるものです。アイデアは、ターミナル上で動作するプログラムが、グラフェムクラスタリングを完全にサポートした状態で動作したいことをターミナルに通知でき、その機能をオン・オフできるというものです。実行中のプログラムは、ターミナルがこの機能をサポートしているかどうかを問い合わせることもできます。
私は最近、自分のターミナルにモード2027のサポートを実装し、その動作を以下で確認できます。モード2027がオフの場合、農家の絵文字の後のカーソルは5列目に移動します(幅4)。モード2027がオンの場合、カーソルは3列目に移動します(幅2)。

ターミナルの比較
下の表は、さまざまなターミナルによる「🧑🌾」の報告幅を示しています。各ターミナルについては、2023年10月2日時点で入手できた最新バージョンを使用しました。自分のターミナルをリストの先頭に置いていますが、それ以外はアルファベット順に並べています。
| ターミナル | 幅 | モード2027 | 備考 |
|---|---|---|---|
| Ghostty | 2 | ✅ | モード2027が無効の場合はwcwidthにフォールバック |
| Alacritty | 4 | ❌ | シェーピング非対応、2つの別々の絵文字として表示 |
| Contour | 2 | ✅ | モード2027の考案元、常にグラフェムクラスタリングを実行 |
| Foot | 2 | ✅ | モード2027が無効の場合はwcwidthにフォールバック |
| Gnome | 4 | ❌ | シェーピング非対応、2つの別々の絵文字として表示 |
| iTerm | 2 | ❌ | 常にグラフェムクラスタリングを実行 |
| Kitty | 4 | ❌ | |
| Tmux | 4 | ❌ | ターミナルエミュレータと一致しないと特に厄介 |
| Terminal.app | 6 | ❌ | 🤡 独自の呪われた世界に生きている |
| Warp | 4 | ❌ | シェーピング非対応、2つの別々の絵文字として表示 |
| Wezterm | 2 | ✅ | 常にグラフェムクラスタリングを実行 |
| Windows Terminal | 5 | ❌ | 🧐 ZWJを1セルとみなし、2つの別々の絵文字として表示 |
| Xfce | 4 | ❌ | シェーピング非対応、2つの別々の絵文字として表示 |
| xterm | 4 | ❌ | シェーピング非対応、2つの別々の絵文字として表示 |
モード2027のサポートはまだまれで、ほとんど普及していません。しかし、すべてのターミナル間で報告される幅にばらつきがあるのは興味深いことです。「2」と「4」はいずれも理解できる値です。「5」や「6」を報告するターミナルは奇妙な現実を生きています。
特に厄介なのがtmuxやzellijのようなターミナルマルチプレクサです。上記のようにtmuxはwcwidthを使用するため、カーソルを4つ進めます。しかしtmux自体はターミナルエミュレータの中で動作しており、出力時にターミナルエミュレータも同じ値を認識します。tmuxとターミナルが一致しないと、カーソルがずれてしまい、結果として起こるバグは滑稽なものになりかねません3。
プログラム作者は今日何ができるか
もしあなたがターミナル上で動作するプログラム(テキストエディタ、CLI、TUIなど)の作者であるなら、今日からでもグラフェムクラスタをより適切に扱うためにできることがあります。
一つ目は、単に気にしないことです。プログラムがカーソル位置を追跡する必要がなく、手動で改行する必要もないのであれば、単に気にせずターミナルに任せてしまえばよいのです。
二つ目は、モード2027のサポートを問い合わせ、可能であればモード2027を使おうとすることです。モード2027のサポートは標準的なシーケンスDECRQM(モード要求):CSI ? 2027 $ pを使って問い合わせできます。
三つ目は、一連のテキストを出力した後にCSI 6 nを使ってカーソル位置を問い合わせることです。するとターミナルがカーソル位置を報告するので、それを使ってテキストの幅を計算できます。
やってはいけないことは、wcwidthの挙動やグラフェムクラスタリングの挙動を決めつけることです。前のセクションの表からわかるように、どちらの想定も、たとえ人気のある主流のターミナルエミュレータだけを考えても、複数のターミナルエミュレータでは誤りとなります。
願わくば今後、より多くのターミナルやターミナルプログラムがモード2027と適切なグラフェムクラスタリングをサポートしてくれることを期待します。前述のとおり、これは絵文字のような「かわいい」機能だけに影響するものではなく、アラビア語をはじめとする世界のさまざまな言語にも影響します。
もしあなたがターミナルやターミナルプログラムの作者でなくても、この記事が興味深いものであったことを願います。ターミナルエミュレータの実装は、テキスト処理の複雑さやレガシーな挙動の負債を目の当たりにする、目から鱗の経験でした。
最後に、モード2027を提案してくれたChristian Parpart氏に感謝を述べたいと思います。Christian氏はグラフェムクラスタとターミナルの問題について公の場で語り、さらに何とかしようと行動を起こした、私が知る限り最初の人物です。ありがとうございます!
脚注
記事をランダムに読む
コメント
ログインしてコメントする