文字乱码了 — 不要猜,要判定
一句话总结
文件里并没有写明编码。所以编码不是“查出来”的,而是按顺序逐个排除候选来判定的,而这个判定的最后一个问题是:“这个文件还能恢复吗?”
为什么需要它
打开客户发来的名单,姓名一列全是方块或看不懂的符号。这时大多数人最先做的,是在编辑器的编码菜单里一个个点过去试。这种办法有时能对,但问题在于,对不对只能靠眼睛来判断。文件有 300 个的话,眼睛就用不了了。
更麻烦的是,有些文件打开看着完好,其实是错的。韩文看得清清楚楚,却查不到;名字看起来一样,连接(join)却是 0 条。眼睛的判断失效的地方,总是这样的地方。
文本文件只是一串字节,而这些字节用哪张表来读,文件里并没有写明。Unicode 联盟关于 UTF-8 和 BOM 的解答明确指出,遵守规范的程序不得把错误的或偏离规范的字节序列解释成字符,RFC 3629则规定了什么样的字节序列才是合法的 UTF-8。这种严格性白白给了我们一样东西:并不是任何字节都能成为 UTF-8。所以“能以 UTF-8 读出来”这个事实本身,就是很强的证据。
工作原理
判定分四步往下走,从上到下是越来越确定的顺序。
- 看 BOM。如果文件以
EF BB BF开头,就是 UTF-8,而这三个字节不是数据。在 Python 中,去掉这三个字节再读取的编解码器是utf-8-sig(codecs 文档)。不去掉就读取,第一个列名会变成member_id,表头比较就会悄悄失败。 - 尝试严格的 UTF-8 解码。成功的话,几乎可以肯定是 UTF-8。一个韩文字符占 3 个字节,而且后续字节的高位比特也是固定的,所以其他编码的韩文字节恰好能通过 UTF-8 解码的概率非常低。
- 失败就建立候选。如果是韩文数据,候选就是 CP949 和 EUC-KR。CP949 是 EUC-KR 的扩展,所以能用 EUC-KR 读出来的,也能用 CP949 读出来。因此不区分两者,统一按 CP949 读取,需要时才做区分。
- 核对样本。要确定一把让机器判断用候选读出来的字符是否说得通的尺子。如果是韩文名单,去掉 ASCII 之后,预组合的完成型韩文音节(U+AC00 到 U+D7A3)所占的比例就是这把尺子。没有这把尺子,就会只凭“解码没有报错就结束了”放过去,而这是不够的。
接下来才是本实验真正的主题。判定结束后,文件会分成三类。
바이트
├─ 올바른 인코딩으로 읽힌다 → 정상. 읽어서 UTF-8 로 통일한다
├─ 한 번 잘못 읽혀 그대로 굳었다(mojibake) → 잘못 읽은 그 표로 되돌려 다시 읽으면 살아난다
└─ 잘못 읽으면서 글자가 뭉개졌다 → 못 되살린다. 원본을 다시 받아야 한다
该代码块中的韩文依次说明:字节;用正确的编码读取,为正常,读取后统一成 UTF-8;曾被错误地读取并就此固定下来(mojibake),用当时错误读取所用的表还原后重新读取就能恢复;错误读取时字符被弄成一团,无法恢复,必须重新获取原始文件。
中间一类是双重编码。把 UTF-8 字节按 latin-1 来读,一个韩文字符会显示成三个字母。这时没有丢失任何一个字节:latin-1 是一张给 0 到 255 的每个字节各配一个字符的表,所以还原的路没有被堵死。因此只需一行 깨진문자열.encode("latin-1").decode("utf-8")(占位符为被弄乱的字符串),原文就回来了。
右边的是已丢失的文件。错误读取的一方过于宽松时,就会变成这样。把读不出来的字节换成 U+FFFD(替换字符)然后继续,多个不同的字节就会被揉成同一个字符。这样的文件里,已经没有信息可以查明原来的字节。这种区分是判定,而不是努力的问题:看到 U+FFFD,就必须重新获取这个文件。
在现场相遇的样子
第一,看起来一样,连接却是 0 条。同一个韩文音节,既可以用一个完成型音节(U+BC15)来写,也可以用三个字母(U+1107 U+1161 U+11A8)来写。屏幕上看起来完全一样,却是不同的字符串。在 macOS 上创建的文件名和数据尤其常见这种情况。UAX #15 定义了在两者之间转换的规范化,合成的一方是 NFC,分解的一方是 NFD。比较之前,两边都必须统一规范化成同一种。在 Python 中用的是 unicodedata.normalize。
第二,看不见的字符混进了键。从网页上复制的值里,会带着为了防止换行而插入的 NBSP(U+00A0),或者为了确定换行位置而插入的零宽空格(U+200B)。这两者都无法用 strip() 去掉:NBSP 在 Python 里被当作空白所以会被去掉,但在 Java 或某些数据库函数中会留下来。零宽字符在哪里都去不掉。所以要用作键的值,要逐个码点扫描,只去掉清单中列出的字符。
第三,修好编码后覆盖了原始文件。如果把恢复好的文件保存到原始文件的位置,判定一旦出错,就无处可回。收到的字节要原样保留,把修好的版本放到另一个目录里。
第四,看了一个文件就对全部下结论。即使在同一批交付的数据里,各个文件的编码不同也是常有的事,因为各个系统的导出路径不同。判定要按文件逐个做,并把结果留成表格。
实际工作中真正重要的事
- 把猜测变成流程。不要在编辑器菜单里逐个点,而要写出按同样顺序往下走的代码。这样即使有 300 个文件,也会得到同样的答案。
- 把能恢复的和已丢失的区分开来说。“坏了”不是报告。“三个文件中两个已恢复,有一个需要你们重新提供原始文件”才是报告。
- 比较之前先规范化。规范化是为了比较,而不是为了修改数据。原始文件保持原样。
- 用清单去掉看不见的字符。把去掉了什么统计下来留存,之后条数对不上时才能解释。
下一项实验要做什么
亲手运行一个把同一份名单做成七种状态的复现器,建立接收目录,然后把判定工具 enc_probe.py 一步步做大。从 BOM 和 UTF-8 能否解码的检查开始,依次加上候选编码和样本核对,还原双重编码,把留有 U+FFFD 的文件划为已丢失。接着用数字确认,以 NFD 送来的姓名为什么一个都对不上,再按码点统计并去掉看不见的字符。评分器会在每次以不同的姓名生成自己的文件,真实运行你的工具并核对判定。