Grapheme Clusters and Terminal Emulators

Mitchell Hashimoto

字素叢集與終端機模擬器

原文由 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 個位元組,程式設計師可以非常輕鬆地一次讀取一個位元組、放到下一個儲存格、把游標向右移一格,然後重複這個過程。

後來出現了「寬字元」。常見的寬字元包括像 橋 這樣的亞洲文字,或是像 😃 這樣的 Emoji。libc 中新增了一個函式wcwidth,用來回傳寬字元以儲存格為單位的寬度。寬字元通常被賦予「2」的寬度。因此,如果你在終端機模擬器中輸入 橋,這個字元會佔據兩個網格儲存格,游標也應該向前跳兩格。

而這就是現今大多數終端機模擬器與終端機程式(像是 shell、TUI 等)的實作方式:它們透過 wcwidth 來處理輸入字元,並據此移動游標。有一段時間,這樣做完全沒問題。但到了今天,這已經不夠用了,而且會導致許多錯誤。


字素叢集

事實證明,單一的 32 位元數值並不足以表示世界上每一個使用者感知的字元。而「使用者感知的字元」正是 Unicode 標準對字素的定義。

以 emoji「🧑‍🌾」為例。這個 emoji 看起來應該像這樣,以防你的電腦無法顯示。我想每個人都會同意這是一個單一的「使用者感知的字元」,也就是一個字素。Unicode 標準本身就將它定義為單一字素,所以無論你個人怎麼想,國際標準都說這就是一個字素。

但對電腦來說就沒那麼直觀了。「🧑‍🌾」由三個碼位組成(U+1F9D1 🧑、U+200D 與 U+1F33E 🌾),在 UTF-32 編碼下是三個 32 位元數值,在 UTF-8 編碼下則是 11 個位元組2

如果我們只考慮這些 32 位元數值,並像過去那樣將它們逐一丟給 wcwidth,就會依序得到「2」、「0」、「2」的結果。因此,游標會向前移動 4 格。這就是為什麼在撰寫本文時,你在大多數終端機上會看到游標向前移動了 4 格的原因

那個零寬字元是怎麼回事?碼位 U+200D 被稱為零寬連接符(ZWJ),其標準定義的寬度為零。ZWJ 會告訴文字處理系統,將它前後的碼位視為連接成單一字元。這就是為什麼你可以輸入「🧑‍🌾」和「🧑🌾」兩種形式;這兩個引號內數值的唯一差別,就在於左邊的農夫中間多了一個零寬連接符。

這時就需要字素叢集了。字素叢集是從一串碼位中判斷出單一字素的過程。字素叢集能讓程式將三個 32 位元數值視為單一使用者感知的字元。字素叢集的演算法定義在UAX #29〈Unicode Text Segmentation〉中。我不會詳細說明演算法的運作方式,只要在你使用的程式語言中找一套現代的 Unicode 函式庫,應該就能執行字素叢集。

字素叢集的一個特別挑戰在於它是有狀態的。你必須同時取得前一個碼位、目前的碼位以及一個整數狀態值,才能穩健地判斷字素的斷點。因此,要把它套用到現有程式中,並非超級容易(但也不難)。

有了字素叢集,終端機就會將「🧑‍🌾」視為單一寬字元字素,只讓游標向前移動兩格,而不是四格。

這可不只是為了 emoji。我用 emoji 當例子,但字素叢集對於正確處理世界上的各種語言也極為重要。舉例來說,阿拉伯文字常常由多個碼位組成。

附註:字形塑形。字素叢集只解決了在一串碼位中判斷字素邊界的問題,並沒有解決如何算繪一串碼位的問題。要做到算繪,你需要像Harfbuzz 這樣的字形塑形器。Harfbuzz 會讀取一串碼位、偵測其中的字素,然後將這些字素對應到字型中的個別字形。

這篇部落格文章不會深入探討字形塑形,但如果你在終端機貼上「🧑‍🌾」卻看到兩個 emoji 而不是一個,那可能是因為終端機移除了零寬連接符,更有可能是因為終端機不支援字形塑形。


終端機中的字素叢集

現今大多數終端機都不支援字素叢集。一個主要原因是,像 shell 和文字編輯器這類進階的終端機應用程式,常常需要隨時精確掌握游標的位置,並與終端機的網格狀態保持同步。

由於過去終端機使用 wcwidth,shell、編輯器和其他 TUI 應用程式也跟著使用 wcwidth,而且至今仍在沿用。雖然它對多碼位字素會產生錯誤的數值,但至少這些錯誤在不同終端機模擬器之間往往是一致的錯誤

當我最初為我的終端機實作文字處理時,我實作了完整的字素叢集支援,因為我以為那會是件好事。然而,我馬上失望地發現,當我做了正確的字素叢集後,fish shell 在我移動游標時,會在錯誤的位置重繪提示字元。😞 我的 shell 假設我的終端機使用 wcwidth,而我的終端機則假設程式不在乎。我們都錯了,所以我只好把字素叢集關掉……

這時模式 2027 登場了。模式 2027 是一項為終端機提供字素支援的提案。這項提案來自Contour 終端機的作者。其概念是,在終端機中執行的程式可以通知終端機,希望以完整支援字素叢集的方式運作,而且這個功能可以隨時開啟或關閉。執行中的程式也可以查詢終端機是否支援這項功能。

我最近在我的終端機中實作了對模式 2027 的支援,你可以在下方看到它的運作情形。當模式 2027 關閉時,游標在農夫 emoji 之後會移到第 5 欄(寬度為 4)。當模式 2027 開啟時,游標則會移到第 3 欄(寬度為 2)。


終端機比較

下表顯示了各家終端機回報的「🧑‍🌾」寬度。對每一款終端機,我都使用了截至 2023 年 10 月 2 日能找到的最新版本。我把自己的終端機放在清單最前面,其餘則依字母順序排列。

終端機寬度模式 2027備註
Ghostty2如果停用模式 2027,則退回使用 wcwidth
Alacritty4不支援字形塑形,顯示為兩個獨立的 emoji
Contour2發明了模式 2027,總是執行字素叢集
Foot2如果停用模式 2027,則退回使用 wcwidth
Gnome4不支援字形塑形,顯示為兩個獨立的 emoji
iTerm2總是執行字素叢集
Kitty4
Tmux4當它與終端機模擬器不一致時特別有趣
Terminal.app6🤡 活在自己詛咒的小世界裡
Warp4不支援字形塑形,顯示為兩個獨立的 emoji
Wezterm2總是執行字素叢集
Windows Terminal5🧐 將 ZWJ 視為一格,顯示為兩個獨立的 emoji
Xfce4不支援字形塑形,顯示為兩個獨立的 emoji
xterm4不支援字形塑形,顯示為兩個獨立的 emoji

對模式 2027 的支援目前仍然很少見,也相對缺乏支援。不過,看到所有終端機回報的寬度差異如此之大,倒是很有趣。「2」和「4」都是可以理解的數值,而回報「5」和「6」的終端機則活在一種奇怪的現實中。

一個特別的挑戰是像 tmux 或 zellij 這類終端機多工器。請注意上面 tmux 使用 wcwidth,因此會讓游標向前移動四格。但 tmux 本身就運行在一個終端機模擬器中,而該模擬器在列印時也會看到這個數值。如果 tmux 和終端機的處理方式不一致,游標就會不同步,進而產生的錯誤有時會相當滑稽3


程式作者現在能做什麼?

如果你是在終端機中執行的程式(文字編輯器、CLI、TUI 等)的作者,現在就有一些方法可以更妥善地處理字素叢集。

第一,你可以直接不在乎。如果你的程式不需要追蹤游標位置、不需要手動換行等等,那麼你大可不在乎,讓終端機自己去處理就好。

第二,你可以也應該查詢是否支援模式 2027,並在可行的情況下嘗試使用模式 2027。你可以使用標準序列 DECRQM(請求模式):CSI ? 2027 $ p 來查詢是否支援模式 2027。

第三,你可以在輸出一串文字後,使用 CSI 6 n 來查詢游標位置。終端機接著會回報游標所在位置,你就可以用它來計算文字的寬度。

你不該做的是假設一定是 wcwidth 的行為字素叢集的行為。從上一節的表格就可以看出,無論做哪一種假設,在多款終端機模擬器上都會是錯的,即使只考慮熱門或主流的那些也一樣。


希望未來有更多終端機與終端機程式能支援模式 2027 和正確的字素叢集。 如前所述,這不僅影響 emoji 這類「可愛」的功能,也會影響阿拉伯文等各種世界語言。

如果你不是終端機或終端機程式的作者,希望這篇部落格文章能讓你有所收穫。實作終端機模擬器的過程確實讓人大開眼界,讓我看見了文字處理的複雜性以及舊有行為所帶來的負擔。

我也想特別感謝 Christian Parpart 提出了模式 2027。Christian 是我看到第一位非常公開地談論字素叢集與終端機問題,並決定採取行動試圖解決的人。謝謝你!

註腳

  1. 幸好我還沒找到會移動 3 格的終端機模擬器。

  2. 我們在此假設 8 位元就是一個位元組,這在現在是相當安全的假設。

  3. 幸好這很難被觸發,因為 tmux 非常主動地手動控制游標位置。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言