宽度检查认为字符串可以安全截断,结果却把一个汉字劈成了两半。
一个名字输入到终端表格中,输出时却损坏了。姓氏是 𠮷田。
第一个字符并不是常见的“吉”。它是“𠮷”(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。
Optional learning community: https://greymoth-jp.github.io/cjk-failure-corpus/
