字素叢集與終端機模擬器
在你的終端機模擬器中複製並貼上「🧑🌾」。你的游標向前移動了幾個儲存格?取決於你的終端機模擬器,可能移動了 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 個位元組,程式設計師可以非常輕易地一次讀取一個位元組、將其放入下一個儲存格、將游標向右移動一格,然後重複此過程。
後來出現了「寬字元」。常見的寬字元是亞洲文字,例如 橋,或是表情符號,例如 😃。libc 中新增了一個函式 wcwidth,用來回傳寬字元以儲存格為單位的寬度。寬字元通常被賦予寬度「2」。因此,若你在終端機模擬器中輸入 橋,這個字元會佔用兩個網格儲存格,且你的游標應向前跳動兩個儲存格。
而這就是現今大多數終端機模擬器與終端機程式(殼層、TUI 等)的實作方式:它們透過 wcwidth 處理輸入字元,並據此移動游標。在短暫的一段時間內,這麼做完全沒問題。但時至今日,這已不再足夠,並導致許多錯誤。
Grapheme Clustering(字素叢集)
事實上,單一的 32 位元數值並不足以表示世界上每一個使用者感知的字元。Unicode 標準將「使用者感知的字元」定義為 Grapheme(字素)。
我們來看看表情符號「🧑🌾」。如果你電腦不支援顯示,這個表情符號應該看起來像這樣。我想每個人都會同意這是一個單一的「使用者感知的字元」或 Grapheme。Unicode 標準本身就將其定義為單一的 Grapheme,因此無論你個人看法如何,國際標準都認定這是一個 Grapheme。
對電腦而言,事情就沒那麼直觀了。「🧑🌾」是由三個碼位(U+1F9D1 🧑、U+200D 與 U+1F33E 🌾)組成,以 UTF-32 編碼時是三個 32 位元數值,或以 UTF-8 編碼時是 11 個位元組2。
如果我們只考慮 32 位元數值,並如過去那樣將每一個分別傳送給 wcwidth,我們會依序得到每個碼位的結果為「2」、接著「0」、再接著「2」。因此,游標會向前移動 4 個儲存格。這就是為何在撰寫本文時,你會看到大多數終端機的游標向前移動了 4 個儲存格
那個零寬字元是怎麼回事?碼位 U+200D 被稱為零寬連接符(ZWJ),其標準定義的寬度為零。ZWJ 會告知文字處理系統將其前後的碼位視為連接成單一字元。這就是為什麼你可以輸入「🧑🌾」與「🧑🌾」兩者;這兩個引號內數值的唯一差異,在於左邊的農夫在兩個表情符號之間有一個零寬連接符。
接著是 Grapheme Clustering。Grapheme Clustering 是從一串碼位中判定單一 Grapheme 的過程。Grapheme Clustering 能讓程式將三個 32 位元數值視為單一的使用者感知字元。Grapheme Clustering 的演算法定義於UAX #29,〈Unicode Text Segmentation〉。我不會詳細說明演算法如何運作,只要為你的程式語言選用任何現代的 Unicode 函式庫,它應該就能執行 Grapheme Clustering。
Grapheme Clustering 的一個特定挑戰在於它是有狀態的。你必須能夠取得前一個碼位、目前的碼位以及一個整數狀態值,才能穩健地判斷 Grapheme 的斷點。因此,要將其套用到既有程式中並非超級容易(但也不難)。
有了 Grapheme Clustering,終端機會將「🧑🌾」視為單一的寬字元 Grapheme,並將游標僅向前移動兩個儲存格,而非四個。
這不僅僅是表情符號的問題。我以表情符號為例,但 Grapheme Clustering 對於正確處理世界各地的語言也極為重要。例如,阿拉伯文字常由多個碼位組成。
附註:Font Shaping(字形塑形)。Grapheme Clustering 僅解決了在碼位流中判斷 Grapheme 邊界的問題。它並未解決算繪碼位流的問題。為此,你需要一個 Font Shaper,例如 Harfbuzz。Harfbuzz 會讀取一串碼位、偵測其中的 Grapheme,並能夠將這些 Grapheme 對應到字型中的個別字形。
這篇部落格文章不會涵蓋 Font Shaping,但如果在終端機中貼上「🧑🌾」卻看到兩個表情符號而非一個,那可能是因為終端機移除了零寬連接符,或更可能是因為終端機不支援 Font Shaping。
終端機中的 Grapheme Clustering
現今大多數終端機並不支援 Grapheme Clustering。一個主要原因是,諸如殼層與文字編輯器等進階終端機應用程式,往往需要隨時精確掌握游標位置,並與終端機網格狀態保持同步。
由於歷史上終端機使用 wcwidth,殼層、編輯器及其他 TUI 應用程式也使用 wcwidth,且至今仍持續如此。儘管它對多碼位的 Grapheme 會產生錯誤的數值,至少這些錯誤的數值在不同終端機模擬器之間往往是一致地錯誤。
當我最初為我的終端機實作文字處理時,我實作了完整的 Grapheme Cluster 支援,因為我以為那會是件好事。然而,當我實作了正確的 Grapheme Clustering 後,卻立刻失望地發現,當我移動游標時,fish shell 會在錯誤的位置重繪我的提示字元。😞 我的殼層假設我的終端機使用 wcwidth,而我的終端機則假設程式不在乎。我們都錯了,所以我停用了 Grapheme Clustering……
接著是模式 2027。模式 2027 是一項針對終端機中 Grapheme 支援的提案。這項提案來自 Contour terminal 的作者。其構想是,在終端機中執行的程式可以通知終端機,它希望在完整支援 Grapheme Clustering 的模式下運作,而此功能可以開啟與關閉。執行中的程式也可以查詢終端機是否支援此功能。
我最近在我的終端機中實作了對模式 2027 的支援,你可以在下方看到其運作情形。關閉模式 2027 時,游標在農夫表情符號後會移動到第 5 欄(寬度為 4)。開啟模式 2027 時,游標則會移動到第 3 欄(寬度為 2)。

終端機比較
下表顯示各家終端機回報的「🧑🌾」寬度。對於每款終端機,我使用截至 2023 年 10 月 2 日所能找到的最新版本。我將自己的終端機列在最前面,其餘則按字母順序排列。
| 終端機 | 寬度 | 模式 2027 | 備註 |
|---|---|---|---|
| Ghostty | 2 | ✅ | 若停用模式 2027 則退回至 wcwidth |
| Alacritty | 4 | ❌ | 不支援塑形,顯示為兩個獨立的表情符號 |
| Contour | 2 | ✅ | 發明了模式 2027,始終執行 Grapheme Clustering |
| Foot | 2 | ✅ | 若停用模式 2027 則退回至 wcwidth |
| Gnome | 4 | ❌ | 不支援塑形,顯示為兩個獨立的表情符號 |
| iTerm | 2 | ❌ | 始終執行 Grapheme Clustering |
| Kitty | 4 | ❌ | |
| Tmux | 4 | ❌ | 當此與終端機模擬器不一致時特別有趣 |
| Terminal.app | 6 | ❌ | 🤡 活在自己詛咒的小世界裡 |
| Warp | 4 | ❌ | 不支援塑形,顯示為兩個獨立的表情符號 |
| Wezterm | 2 | ✅ | 始終執行 Grapheme Clustering |
| Windows Terminal | 5 | ❌ | 🧐 將 ZWJ 視為一個儲存格,顯示為兩個獨立的表情符號 |
| Xfce | 4 | ❌ | 不支援塑形,顯示為兩個獨立的表情符號 |
| xterm | 4 | ❌ | 不支援塑形,顯示為兩個獨立的表情符號 |
對模式 2027 的支援仍然稀少且相對缺乏支援。然而,有趣的是可以看到所有終端機回報寬度的差異。「2」與「4」都是可以理解的數值。回報「5」與「6」的終端機則活在一種奇特的現實中。
一個特殊的挑戰是諸如 tmux 或 zellij 等終端機多工器。請注意上表中 tmux 使用 wcwidth,因此將游標向前移動四格。但 tmux 本身也運行於終端機模擬器之中,該模擬器在列印時同樣會看到這個數值。如果 tmux 與終端機不一致,游標就會不同步,進而產生的錯誤可能會相當滑稽3。
程式作者今日能做什麼?
如果你是在終端機中執行之程式(文字編輯器、CLI、TUI 等)的作者,今日已有一些做法可以更妥善地處理 Grapheme Cluster。
第一,你可以直接不在乎。如果你的程式不需要追蹤游標位置、不需要手動換行等,那麼你就可以簡單地不在乎,讓終端機自行處理。
第二,你可以也應該查詢對模式 2027 的支援,並在可行時嘗試使用模式 2027。你可以使用標準序列 DECRQM(請求模式):CSI ? 2027 $ p 來查詢對模式 2027 的支援。
第三,你可以在輸出一串文字後,使用 CSI 6 n 查詢游標位置。終端機接著會回報游標所在位置,你便可藉此計算文字的寬度。
你不該做的是假設 wcwidth 的行為或 Grapheme Clustering 的行為。從前一節的表格可以看出,即使僅考慮熱門或主流的終端機模擬器,任何一種假設在多款終端機模擬器上都會是錯誤的。
希望未來有更多終端機與終端機程式能支援模式 2027 與正確的 Grapheme Clustering。如前所述,這不僅影響表情符號這類「可愛」功能,也會影響阿拉伯文等各種世界語言。
如果你不是終端機或終端機程式的作者,希望你覺得這篇部落格文章有啟發性。對我而言,實作終端機模擬器確實讓人大開眼界,見識到文字處理的複雜性以及舊有行為所帶來的負擔。
我也想特別感謝提出模式 2027 的 Christian Parpart(克里斯提安·帕爾帕特)。克里斯提安·帕爾帕特是我所見第一位非常公開地談論 Grapheme Cluster 與終端機相關問題,並決定嘗試採取行動的人。謝謝你!
註腳
隨機一篇部落格