宽度检查认为字符串可以安全截断,结果却把一个汉字劈成了两半。

一个名字输入到终端表格中,输出时却损坏了。姓氏是 𠮷田。

第一个字符并不是常见的“吉”。它是“𠮷”(U+20BB7)。这是真实日本姓氏中使用的罕见写法。表格为了适应列宽对单元格进行了截断,结果留下了一个残缺的字符。

这个 Bug 隐藏在单行代码中。那是一个优化逻辑,它通过索引判断一个字符串是否可以安全截断。

JavaScript 字符串有三种不同的长度: • 代码单元 (Code units, .length): "𠮷".length 为 2。 • 代码点 (Code points): [..."𠮷"].length 为 1。 • 显示宽度 (Display width): 𠮷 占用 2 列。

对于标准的英文文本,这些数值都是相同的。这种巧合让代码看起来很安全。

字符“𠮷”打破了这一规则。因为它是一个代理对 (surrogate pair),所以它有 2 个代码单元;因为它是一个宽字符,所以它占用 2 列。数值虽然匹配 (2 = 2),但原因却完全不同。

cli-table3 库使用了一个快速路径 (fast path): 如果代码单元长度等于显示宽度,则按索引截断字符串。

这套逻辑运行多年都没出问题,因为像“漢”这样常见的日文汉字,其长度为 1,宽度为 2。它们永远不会触发这个快速路径。

快速路径仅在遇到像“𠮷”或表情符号 (emoji) 这样的罕见字符时才会触发。这些字符的长度为 2,宽度也为 2。代码误以为它们是简单的单单元字符,于是按索引将它们截断了一半。这导致留下了一个孤立的代理单元,这就是为什么终端会显示一个破碎的方块。

要修复这个问题,你必须:

  • 为快速路径增加防护,排除代理对。
  • 按代码点 (code points) 而不是代码单元 (code units) 进行截断。

使用 Array.from(str) 会有所帮助,因为它按代码点进行迭代。这能确保你永远不会把一个字符劈成两半。

教训很简单:永远不要用一种单位进行测量,却用另一种单位进行截断。如果你测量的是显示宽度或代码点,那么截断时也必须使用相同的单位。

请使用 CJK 扩展 B 区字符或表情符号来测试你的代码。ASCII 字符永远不会暴露这个 Bug。

Source: https://dev.to/greymothjp/a-width-check-said-the-string-was-safe-to-cut-it-split-a-kanji-in-half-4hjk

Optional learning community: https://greymoth-jp.github.io/cjk-failure-corpus/