清理从别处拿来的文本
从 PDF、电子表格单元格、CMS 字段、邮件客户端或聊天应用里拿到的文本,会带着产生它的那个软件所做的排版决定,而这些决定几乎没有一个在屏幕上看得见。结果就是一个看起来正确、打印出来也正确的字符串,却毫无缘由地在比较、搜索、JSON 解析或数据库查找时失败。
你看不见的字符
在 Unicode 眼里它们都是真实的字符。它们只是没有可见的字形,或者和别的字符长得一样。
| 字符 | 码位 | 通常来自 | 会破坏什么 |
|---|---|---|---|
| 不换行空格 | U+00A0 | HTML 中的 、Word、PDF 排版 | 按空格拆分、精确匹配 |
| 窄不换行空格 | U+202F | 法语排版、来自 Word 的日期 | 同上,只是更难察觉 |
| 表意文字空格 | U+3000 | 中日韩输入法 | 看上去是一段很宽的间隔 |
| 零宽空格 | U+200B | CMS 的断行提示、从网页复制的文本 | 不可见,而且不算空白字符 |
| 零宽不连字 / 零宽连字 | U+200C, U+200D | 波斯语、印度系文字、emoji 序列 | 词边界、字符计数 |
| 单词连接符 | U+2060 | 排版工具 | 视觉上毫无痕迹,文本层面无所不破 |
| 软连字符 | U+00AD | Word 的自动断词、PDF 导出 | 一个词不再与它自己匹配 |
| 字节序标记 | U+FEFF | UTF-8 文件开头的那几个字节 | 第一行的第一个字段 |
| 从左到右 / 从右到左标记 | U+200E, U+200F | 双向文本 | 数字周围出现游离的标记 |
| 行分隔符与段分隔符 | U+2028, U+2029 | 某些 Mac 应用和排版软件 | 按行拆分、较旧的 JavaScript 解析器 |
搜索之所以会失败,是因为搜索比较的是码位,不是字形。如果 PDF 给你的是 New U+00A0 York,而你输入的是带普通空格 U+0020 的 New York,那么这两个字符串就是不同的,而且没有任何东西会告诉你原因。
"New York".includes("New York") false, the gap is U+00A0
" text".trim().length 5, the zero-width space survives
在多数 trim 函数和多数正则引擎的 \s 看来,不换行空格算空白字符,所以它在字符串两端会被去掉,却在中间存活下来,而中间恰恰是拆分逻辑期待一个普通空格的地方。零宽空格在任何地方都不算空白字符,所以去除首尾空白和合并空白都不会碰它。这也是为什么从邮件客户端复制出来的文本,邮箱地址提取器可能什么都提不到:地址内部只要有一个零宽字符,模式就匹配不上了。
隐藏字符检测器能显示里面到底有什么,而 Unicode 转义工具能给出单个值的精确码位。不过不要见到就一律删除:emoji 序列里的零宽连字,以及波斯文或天城文里的零宽不连字,都是内容,不是噪声。
文字处理软件替你改掉的标点
自动更正会在你输入的同时替换成排印用的字符,而这些替换会随着文本一起离开文档。
| 你输入的 | 你现在拿到的 | 码位 |
|---|---|---|
' | 右单引号 | U+2019 |
" 和 " | 左双引号和右双引号 | U+201C, U+201D |
词与词之间的 - | en dash 或 em dash | U+2013, U+2014 |
... | 水平省略号 | U+2026 |
数字前面的 - | 减号或不换行连字符 | U+2212, U+2011 |
这在散文里没问题,在任何要由机器解析的东西里都是致命的。
{ “name”: “Ada” } unexpected token, only U+0022 is a JSON string quote
git commit -m “fix” the shell sees three words, not one quoted argument
WHERE name = ‘Ada’ SQL syntax error near ‘
10\u201320 not a number range, U+2013 is not a hyphen
在 CSV 里,失败要安静得多。解析器只把直双引号当作字段的定界符,所以被文字处理软件套上弯引号的值会被当成没有加引号;此后它内部的任何一个逗号都会把这一行切开,后面每一列都跟着错位。一列如果用 U+2212 表示负数,导入之后会变成文本,求和时会被无声地略过。
用一份简短的替换清单跑一遍查找与替换就能修好,但只对将要送进代码、CSV 或键名的文本这么做。对准备发布的正文这么跑,会把原本刻意使用的标点抹平。
行尾符与行尾空白
Windows 上的工具用 CR LF(U+000D U+000A)结束一行,Unix 上的工具只用 LF,还有少数很老的 Mac 导出文件仍然只用 CR。多数解析器应付得来。粗糙的拆分应付不来。
"UK\r\n" split on "\n" -> "UK\r"
"UK\r" === "UK" false
"UK\r".length 3
在表格、日志或差异视图里渲染得一模一样的两个字符串,仍然可能相差一个回车、一个行尾空格或一个制表符。当文本差异对比把某一行标为已更改、你却看不出任何变化时,答案就在这里。这也是粘贴过来的一列文本经过行加引号工具或行合并工具之后,引号里会多出一个空格的原因。
一次过完成规范化,把 CR LF 和单独的 CR 一起匹配掉(用 \r\n? 替换成 \n),否则分两步替换会让空行成倍增加。
同一个字母,两种写法
Unicode 允许一个带重音的字母是单个码位,也允许它是一个基础字母后面跟一个组合记号。两者都正确,而且它们并不相等。
| 形式 | é 的存储方式 | 在 JavaScript 中的代码单元数 |
|---|---|---|
| NFC(预组合) | U+00E9 | 1 |
| NFD(分解) | U+0065 U+0301 | 2 |
macOS 历来交出的是分解形式的文件名,若干 PDF 生成器和输入法也产出分解形式的文本,所以来自某个来源的值,会和你在键盘上敲出来的同一个值匹配不上。
"é" === "é" false
"é".normalize("NFC") === "é" true
连带的影响不止于相等判断。按码位比较的排序会把分解形式排在普通的 e 旁边,把预组合形式排到很远的地方,于是一份姓名列表回来时分成了两坨。两种形式的字符数不同,这对 280 个字符的限制或者一个 VARCHAR(50) 列是有影响的,文本统计工具也会对看起来相同的文本报出不同的数字。截断更糟:在固定长度处切开一个分解形式的字符串,可能正好切在基础字母和它的重音之间,让那个重音去附着到后面的任何字符上,所以对未规范化的输入使用文本截断工具,可能得到一个肉眼可见错误的末尾字符。
兼容性形式 NFKC 和 NFKD 走得更远,它们会把仅仅长得像的字符也折叠掉:连字 U+FB01 变成 fi,全角的 A U+FF21 变成 A,微符号 U+00B5 变成希腊字母 mu U+03BC,上标 ² 变成普通的 2。这对搜索或去重用的键极好,对任何要展示的内容则是破坏性的,因为 x² 会悄悄变成 x2。
实用规则是:存储和展示用 NFC,NFKC 只用于没人会看见的键。
大小写转换不是一个操作
转大写不是逐字符的映射,也不是与区域设置无关的。
- 德语的
ßU+00DF 转大写会变成SS,于是字符串变长了,再转回小写也回不到原样。 - 希腊语的 sigma 在词尾转小写是
ς,在别处是σ,这同样破坏了往返转换。 - 土耳其语和阿塞拜疆语有一个无点的
ıU+0131 和一个带点的大写İU+0130。在这些区域设置下,I转小写是ı,i转大写是İ。
最后这一条是经典的生产事故。把请求头名称、文件扩展名或协议字符串转成小写的代码,在任何地方都能正常工作,直到它跑在一台默认区域设置为土耳其语的机器上,在那里 "FILE" 转小写会变成 fıle,从此再也匹配不上 file。在 Java 和 .NET 里,不带参数的方法使用机器的默认区域设置,所以 toUpperCase() 和 ToUpper() 就是那个陷阱;任何面向机器的字符串都要显式指定不变区域设置。做比较时,大小写折叠(Python 里的 casefold())优于转小写,因为它会把 ß 处理成 ss。
标题式大小写没有唯一的定义,这就是为什么“把每个词首字母大写”会产出“The Lord Of The Rings”和“IPhone”。多数文体指南的做法是:首词和末词大写,其余除冠词、并列连词和短介词之外都大写,带连字符的复合词两半都保持大写,并且绝不去动缩写词或者像 McDonald、O'Brien、iPhone 这类内部含大写字母的名字。
编程用的命名风格有它自己的边界问题。把 HTTPResponseCode 转成蛇形命名,结果完全取决于拆分器如何对待一串连续的大写字母;大小写转换器给出 http_response_code,粗糙的实现给出 h_t_t_p_response_code。
一个行得通的顺序
每一步都假定前一步已经跑过。顺序一乱,它们就会互相打架。
- 先把编码定下来。 像
café这样的乱码意味着字节被按错误的编码解读了,所以要去重新解码原始字节,而不是修补症状。BOM 只在文本的最开头才去掉。 - 规范化行尾符,一次过全部转成 LF。
- 删掉你判定为噪声的格式字符:软连字符、零宽空格、单词连接符、双向标记。这一步要在规范化之前做,因为如果一个不可见字符夹在基础字母和它的重音之间,NFC 就无法把两者组合起来。
- 替换形似的标点,前提是目标是代码、CSV、JSON 或者一个键。
- 执行 Unicode 规范化,默认用 NFC。
- 现在再处理空白字符。 把剩下的异体空格转成 U+0020,合并连续空白,然后去除首尾空白。如果这一步放在第 3 步之前,那么凡是不换行空格挨着普通空格的地方都会留下双空格;如果去除首尾空白放在第 2 步之前,每一行最后一个值上都会粘着一个回车。
- 最后再转换大小写,并且显式写明区域设置。
词级别的操作也应该放在第 3 步之后。软连字符还在的时候用文本拆分工具去拆分文本,会得到虚高的计数和半截的词,任何按词扫描的功能都一样,包括文本屏蔽工具。
如果做完这一切之后某个值仍然拒绝匹配,那就别再盯着它看了。打印它的长度,把它转义成码位,然后直接比较两个转义之后的形式;差别在那里一目了然,在屏幕上则永远不会。