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 个字节,程序员可以非常容易地一次读取一个字节并将其放入下一个单元格,光标右移一格,如此重复。

后来,“宽字符”出现了。常见的宽字符包括亚洲字符(如 橋)或 Emoji(如 😃)。libc 中加入了一个函数 wcwidth,用于返回宽字符在单元格中的宽度。宽字符通常被赋予宽度“2”。因此,如果你在终端模拟器中输入 橋,该字符会占据两个网格单元格,你的光标应该向前跳两格。

这就是今天大多数终端模拟器和终端程序(shell、TUI 等)的实现方式:它们通过 wcwidth 处理输入字符并相应地移动光标。在一段短暂的时间里,这完全没问题。但如今,这已经不再够用,并导致了许多错误。


Grapheme Clustering(字素聚类)

事实证明,单个 32 位值不足以表示世界上每一个用户可感知的字符。“用户可感知的字符”是 Unicode 标准对 grapheme(字素) 的定义。

让我们考虑 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 被称为 Zero-Width Joiner(零宽连接符)(ZWJ),其标准定义的宽度为零。ZWJ 告诉文本处理系统把它周围的码点视为连接成一个单一字符。这就是为什么你可以同时输入“🧑‍🌾”和“🧑🌾”;这两个引号内的值之间唯一的区别是左边的农夫在两个 emoji 之间有一个零宽连接符。

于是就有了 grapheme clustering(字素聚类)。字素聚类是从码点流中确定单个字素的过程。正是这个过程让程序能够把三个 32 位值看作一个用户可感知的字符。字素聚类的算法在 UAX #29《Unicode Text Segmentation》 中定义。我不会详细讲解算法的工作原理——只要为你的编程语言选择任何一个现代 Unicode 库,它应该都能执行字素聚类。

字素聚类的一个特殊挑战在于它是有状态的。要稳健地确定字素的断点,你必须能访问前一个码点、当前码点以及一个整数状态值。因此,把它接入现有程序并不是特别容易(但也不难)。

有了字素聚类,终端会把“🧑‍🌾”看作一个单一的宽字符字素,并且只把光标向前移动两个单元格而不是四个。

这不只是针对 emoji。我用 emoji 作为例子,但字素聚类对于正确处理世界各地的语言也极其重要。例如,阿拉伯文字通常由多个码点组成。

题外话:字体整形。字素聚类只解决了在码点流中确定字素边界的问题。它并没有解决渲染码点流的问题。为此,你需要一个 font shaper(字体整形器),比如 Harfbuzz。Harfbuzz 接收码点流,检测出字素,然后能够把这些字素映射到字体中的各个字形。

这篇博客文章不会涉及字体整形,但如果你把“🧑‍🌾”粘贴到终端里却看到两个 emoji 而不是一个,要么是因为终端剥离了零宽连接符,更可能是因为终端不支持字体整形。


终端中的字素聚类

如今大多数终端都不支持字素聚类。一个主要原因是,shell 和文本编辑器等高级终端应用常常需要时刻确切地知道光标的位置,并与终端网格状态保持同步。

由于历史上终端使用 wcwidth,shell、编辑器和其他 TUI 应用也在使用 wcwidth,并且至今仍在继续。尽管它对多码点字素会产生错误的值,但至少这些错误的值在各终端模拟器之间往往是一致地错

当我第一次为我的终端实现文本处理时,我实现了完整的字素簇支持,因为我以为那是一件好事。然而,我立刻失望地发现,当我做了正确的字素聚类后,fish shell 在我移动光标时会在错误的位置重绘我的提示符。😞 我的 shell 假定我的终端使用 wcwidth,而我的终端假定程序不在乎。我们俩都错了,于是我禁用了字素聚类……

Mode 2027 登场。Mode 2027 是一项关于终端字素支持的提案。该提案来自 Contour 终端的作者。其想法是:运行在终端中的程序可以通知终端,它希望在完整支持字素聚类的模式下工作,而且这一特性可以开启和关闭。运行的程序还可以查询终端是否支持此特性。

我最近在我的终端中实现了对 mode 2027 的支持,你可以在下面看到它的效果。关闭 mode 2027 时,光标在农夫 emoji 之后移动到第 5 列(宽度为 4)。开启 mode 2027 时,光标移动到第 3 列(宽度为 2)。


终端对比

下表显示了各终端报告的“🧑‍🌾”的宽度。对于每个终端,我使用的是截至 2023 年 10 月 2 日我能找到的最新版本。我把我自己的终端放在列表第一位,其余按字母顺序排列。

终端宽度Mode 2027备注
Ghostty2若 mode 2027 被禁用则回退到 wcwidth
Alacritty4不支持整形,显示为两个独立的 emoji
Contour2发明了 Mode 2027,始终执行字素聚类
Foot2若 mode 2027 被禁用则回退到 wcwidth
Gnome4不支持整形,显示为两个独立的 emoji
iTerm2始终执行字素聚类
Kitty4
Tmux4当它与终端模拟器不一致时尤其有趣
Terminal.app6🤡 活在自己受诅咒的小世界里
Warp4不支持整形,显示为两个独立的 emoji
Wezterm2始终执行字素聚类
Windows Terminal5🧐 把 ZWJ 算作一个单元格,显示为两个独立的 emoji
Xfce4不支持整形,显示为两个独立的 emoji
xterm4不支持整形,显示为两个独立的 emoji

对 mode 2027 的支持仍然罕见且相对缺乏支持。不过有趣的是可以看到所有终端报告宽度的差异。“2”和“4”都是可以理解的值。而报告“5”和“6”的终端活在一个奇怪的现实里。

一个特殊的挑战是 tmux 或 zellij 这类终端复用器。注意上面 tmux 使用 wcwidth,因此会把光标向前移动四格。但 tmux 本身又运行在一个同样会看到打印值的终端模拟器里。如果 tmux 和终端不一致,两者的光标就会失去同步,由此产生的 bug 可能非常滑稽3


程序作者现在能做什么?

如果你是一个运行在终端里的程序(文本编辑器、CLI、TUI 等)的作者,你现在就可以做一些事情来更恰当地处理字素簇。

第一,你可以干脆不管。如果你的程序不需要跟踪光标位置,不需要手动换行等等,那么你完全可以不管,让终端爱怎么干就怎么干。

第二,你可以而且应该查询 mode 2027 支持,并在可行的情况下尝试使用 mode 2027。你可以使用标准序列 DECRQM(请求模式)来查询 mode 2027 支持:CSI ? 2027 $ p

第三,你可以在输出一系列文本后使用 CSI 6 n 查询光标位置。终端随后会报告光标的位置,你可以用它来计算文本的宽度。

你不应该做的是假定 wcwidth 行为字素聚类行为。从前一节的表格可以看出,无论哪种假设,在多个终端模拟器上都会出错,即使你只考虑流行或主流的那些。


希望未来会有更多终端和终端程序支持 mode 2027 以及正确的字素聚类。如前所述,这不仅影响 emoji 这类“可爱”的功能,还影响阿拉伯语等各种世界语言。

如果你不是终端或终端程序的作者,希望你从这篇博客文章中获得了一些启发。实现一个终端模拟器无疑让我大开眼界,看到了文本处理的复杂性以及遗留行为带来的负担。

我还想快速感谢 Christian Parpart(克里斯蒂安·帕帕特)提出 mode 2027。Christian 是我见到的第一个非常公开地谈论字素簇与终端相关问题、并决定尝试为此做点什么的人。谢谢你!

脚注

  1. 谢天谢地,我还没找到会移动 3 格的终端模拟器。

  2. 我们将假设 8 位等于一个字节,这在如今是一个相当安全的假设。

  3. 幸运的是,这种情况很难触发,因为 tmux 会非常主动地手动控制光标位置。

原文由 Mitchell Hashimoto 发布

本文章由 stealth/ox-alpha 进行翻译