不可见字符
存在于文本数据中但不在屏幕上显示的字符的统称。包括零宽空格、双向控制字符、软连字符等,会影响字符计数结果。
不可见字符 (invisible character) 是指存在于文本中却没有视觉表现的字符的统称。与控制字符 (换行、制表符等) 不同,不可见字符大多作为"看不见的指令"影响文本的显示和处理方式。在字符计数中,不可见字符是最棘手的陷阱之一。
Unicode 定义了大量不可见字符。零宽空格 (U+200B) 是没有宽度的空白,插入在长单词中间用于标示允许换行的位置。零宽连接符 (ZWJ,U+200D) 连接前后的字符,用于 emoji 的合成 (👨+ZWJ+👩+ZWJ+👧 = 👨👩👧)。零宽非连接符 (ZWNJ,U+200C) 的作用相反,用于阻止连接,在阿拉伯语和波斯语中控制字母的连写。
双向控制字符也是不可见字符的一种。左至右标记 (LRM,U+200E) 和右至左标记 (RLM,U+200F) 在阿拉伯语、希伯来语与英语混排的文本中控制书写方向。作用更强的是嵌入与覆盖类 (U+202A 至 U+202E) 和隔离类 (U+2066 至 U+2069),它们指定一个范围,改变其中的排列顺序本身。这些字符都不会出现在屏幕上,却对文本的显示顺序有很大影响。
不可见字符对字符计数的影响很严重。从网页复制粘贴文本时,零宽空格或双向控制字符可能会混入其中。明明看起来是相同的文本,字符计数器却返回不同的数值,这时就应该怀疑混入了不可见字符。例如"Hello"没有被计为 5 个字符而是 7 个字符时,可能是"H"与"e"之间插入了 2 个零宽空格。
从安全的角度看,不可见字符同样会造成问题。2021 年有报告指出,在源代码中插入双向控制字符的覆盖类与隔离类,可以让屏幕上的排列顺序与实际被处理的顺序不一致,这种手法称为"特洛伊源码"。另外,在显示名或用户名中间夹入零宽字符,看上去完全一样,但作为字符串却是另一个东西。判断是否同名以及禁用词的比对都依据字符串的一致,因此这个差别可能成为绕过的缺口。
怀疑有混入时的确认步骤很简单。把字符数不相符的部分切出来,逐个字符展开成码位用眼睛确认。JavaScript 可以用 Array.from(s, c => c.codePointAt(0).toString(16)),Python 可以用 [hex(ord(c)) for c in s] 取得一览。如果是"a"、零宽空格、"b"这 3 个字符,就会排成 61 200b 62,哪里夹着什么一目了然。开头带有 U+FEFF 时,原因是文件的 BOM。
作为应对不可见字符的措施,推荐在文本处理的前一阶段进行净化 (去除不必要的不可见字符)。正则表达式 /[\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF]/g 可以把主要的零宽字符和双向控制字符一并拾取,但这个范围有 2 个陷阱。一个是开头作为代表例举出的软连字符 (U+00AD) 并不在这个范围内,需要时要明确地补上。另一个是行分隔符 (U+2028)、段分隔符 (U+2029)、窄不换行空格 (U+202F) 也包含在范围内。它们与其说是"看不见",不如说是作为空白或换行起作用的字符,一律删除会改变换行位置和字间。
ZWJ 的处理尤其需要注意。把前面提到的三口之家 emoji 按书写素簇计数,去除之前是 1 个字符,而落掉 ZWJ 后合成解开就变成 3 个字符。去除对象要分成"显示所必需的控制字符"和"混入的垃圾"两类来考虑,按用途选择性地排除是原则。