一段文本,肉眼看是 42 个字,len() 返回 47。多出来的 5 个字符,一个都看不见。
这类字符在 Unicode 里数量不小,各有各的正经用途,也各有各的滥用方式。想把它们处理干净,先得知道它们分别是谁、干什么用的——因为其中有几个删不得,删了会把文字弄坏。
常见的隐形码点清单

U+200B ZERO WIDTH SPACE(零宽空格) 本职是给不带空格的文字(中日韩、泰语)提供换行机会点,告诉排版引擎"这里可以断行但不要显示空格"。滥用场景最多:反爬虫在文字中间乱插、防复制脚本用它打断关键词、隐写方案拿它当二进制的 0 或 1。它在几乎所有正常中文和英文文本里都是多余的,可以放心清。
U+200C ZERO WIDTH NON-JOINER(零宽不连字)/ U+200D ZERO WIDTH JOINER(零宽连字) 这两个不能无脑删。
- 波斯语、阿拉伯语里,ZWNJ 用来阻止两个字母连写。删掉之后词形会变,在某些情况下会变成另一个词
- 印地语等天城文系文字里,ZWJ/ZWNJ 控制合字的显示形态
- emoji 的组合序列全靠 ZWJ 串起来。
👨+ ZWJ +👩+ ZWJ +👧是"一家三口"这一个 emoji,去掉 ZWJ 就散成三个独立小人。彩虹旗、职业 emoji 同理
所以处理这两个码点时要看上下文:夹在拉丁字母或汉字之间的基本可以判为噪音,夹在阿拉伯文字母之间或 emoji 序列里的必须保留。
U+FEFF ZERO WIDTH NO-BREAK SPACE / BOM 以前放在文件开头当字节序标记,现在 Unicode 已不推荐把它用作零宽不换行空格(那个角色由 U+2060 接管)。实际中它最常见的来源是 Windows 上的编辑器保存 UTF-8 时加的 BOM。文件中间出现的 U+FEFF 基本都是垃圾,会导致 JSON 解析失败、Shell 脚本首行 shebang 失效之类的问题。
U+00AD SOFT HYPHEN(软连字符)
只在需要断词时才显示为连字符,不断行时完全不显示。PDF 和排版软件里常见,从 PDF 复制文字出来时经常一并带出。它会让搜索匹配失败——文本里是 inter+U+00AD+national,搜 international 搜不到。
U+2060 WORD JOINER、U+180E、U+200E/U+200F U+2060 是零宽不换行连接;U+200E/U+200F 是左至右/右至左标记,用于混排双向文字,在纯中文纯英文环境里通常是噪音。
U+202A–U+202E 双向控制符 这一组值得单独说。它们控制文本的书写方向,包括 U+202E RIGHT-TO-LEFT OVERRIDE——强制后续字符从右往左渲染。恶意用法是把文件名或源码里的字符串做视觉倒转:磁盘上的字节序是一回事,屏幕上显示出来是另一回事。在源代码里,用双向控制符可以让一段注释在编辑器里看起来是注释、在编译器眼里却是有效代码,也就是所谓的 Trojan Source 类攻击。同一族的还有 U+2066–U+2069 隔离符。这类字符出现在代码或文件名里,基本可以直接判定为可疑。
U+E0001 和 U+E0020–U+E007F 标签字符(Tag Characters)
最经典的隐写载体。这一段最初是给语言标签设计的,后来被弃用,现在只有 emoji 的地区旗帜序列还在正经使用一小部分。妙处在于 U+E0020 到 U+E007E 与 ASCII 的空格到 ~ 一一对应——码点差正好是 0xE0000。也就是说,把任意一句英文的每个字符加上 0xE0000,就得到一串完全不渲染、可以直接粘在正文后面的字符。解码就是减回去。因为映射太直接,这一段几乎从不出于正常原因出现在普通文本里。
变体选择符 U+FE00–U+FE0F 与 U+E0100–U+E01EF 本职是指定前一个字符用哪种字形——比如 U+FE0F 让符号显示为彩色 emoji 而非黑白文字形态,表意文字变体选择符用于区分同一汉字的不同写法。但一共 256 个可选值,意味着每个选择符能编码一个字节,跟在任意字符后面就能连续存数据。清理时要留意:跟在 emoji 后面的 U+FE0F 一般是必需的,成串出现在普通汉字或字母后面的就非常可疑。
它们怎么藏信息

三种常见编码路子:
- 二进制映射。选两个零宽字符分别代表比特 0 和 1,每 8 位一组还原一个字节,再用第三个字符当分隔。一段 20 字节的标识符需要 160 个零宽字符,插在正文里肉眼完全无感,但字符数会明显偏大——这也是最容易被察觉的破绽
- 码点平移。标签字符那套,明文加固定偏移,长度 1:1,最省空间
- 变体选择符串。每个选择符携带一个字节,附着在某个可见字符后面,看起来只是一个普通汉字
判断依据其实很简单:正常文本里这些字符要么根本不出现,要么出现频率极低且位置有规律(比如只出现在 emoji 内部)。一旦看到几十上百个零宽字符均匀分布在段落里,那就不是排版需要。
想直接看一段文本里到底有什么,最省事的办法是把它扔进扫描器逐码点列一遍:
自己动手检测
命令行。grep -P 支持 Unicode 转义:
grep -nP '[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}-\x{206F}\x{FEFF}\x{00AD}]' file.txt
看某一行到底有什么字节,用 hexdump -C。UTF-8 下 U+200B 是 e2 80 8b,U+FEFF 是 ef bb bf,U+00AD 是 c2 ad,标签字符是四字节的 f3 a0 8x xx。
Python 一行:
[(i, hex(ord(c))) for i, c in enumerate(s) if not c.isprintable() or ord(c) in range(0x200B, 0x2070)]
或者更简单,用 unicodedata.category(c) 筛出 Cf(格式控制字符)这一类,零宽系列、双向控制符、软连字符、标签字符都落在里面。
编辑器。VS Code 默认会把不常见的不可见字符高亮成红框,设置项是 editor.unicodeHighlight.invisibleCharacters;如果被关掉了记得打开。Vim 里 :set list 只显示制表符和行尾,对零宽字符无效,得用 ga 查看光标下字符的码点。
清理时的几条注意
先看再删。清理前先统计每类字符出现了多少次、出现在哪里。如果 U+200D 全部出现在 emoji 中间,那它们是内容的一部分,不是污染。
按语言分档。处理阿拉伯语、波斯语、印地语、乌尔都语文本时,ZWJ/ZWNJ 默认保留;处理纯中英文时可以按噪音处理。没把握就保留,漏删一个零宽空格顶多影响字数统计,误删一个 ZWNJ 可能改变词义。
软连字符和异形空格一起清。从 PDF 复制来的文本除了 U+00AD,通常还带 U+00A0 不换行空格、U+2002–U+200A 各种宽度的排版空格、U+2011 不换行连字符。它们不是不可见字符(有宽度,看得见),但同样会破坏搜索和字符串比较,一般归一化成普通空格更好用。
代码和文件名从严。源码里出现任何 Cf 类字符都应该当成问题排查,尤其是双向控制符。CI 里加一条检查不难。
保留原文再改。清理是不可逆操作,先留底。
清完之后重新扫一遍,计数归零就是真干净了——这一点和统计类水印很不一样。零宽字符的清除是确定性的、可验证的:它要么在文本里,要么不在,没有中间状态。至于嵌在词元选择概率里的那类水印,扫描器扫不出来,也不是删字符能解决的问题。
顺带一提:为什么文本里会有这些东西
日常遇到的隐形字符,绝大多数不是谁刻意针对你藏了信息,而是渠道自带的:网页的防复制脚本、PDF 的断词标记、富文本编辑器的历史残留、聊天软件的格式标记、跨系统粘贴的编码转换。真正的隐写反而少见。复制粘贴带出来的隐形字符那篇按来源把这条污染链拆开讲了,排查线上莫名其妙的字符串比对失败时挺管用。
