本文提纲:

  • 故障现象
  • 原因分析
  • Windows 默认系统字符集的秘密
  • 用 Locale Emulator 来解决问题

【 故障现象 】

从 Python 3.6 起,Windows 版 Python 官方发行包中的 python.chm 在我电脑上的显示变得不正常了。Python 3.5 及之前,几乎是没有这个问题的。




python thinker 中文文档 python文档chm中文版_ini文件中文乱码 python


你觉得你可以猜得出来乱码的原文是什么?但有时候并没有那么好的运气,比如下面这篇:


python thinker 中文文档 python文档chm中文版_python_02


简单调查后就能发现,这是个典型的“浏览器错误识别网页编码”故障。在美国的老外、装英文版的 Windows ,看 python.chm 时是不会有这个问题的,但我们的中文版 Windows 就有麻烦了。

如果你只是偶尔查阅 python.chm ,有个快速的变通方法可以让你看到正确的内容。右键点击 chm 网页的空白处,查看属性,将其中的 URL 地址拷出。粘贴到 IE 浏览器中。


python thinker 中文文档 python文档chm中文版_ini文件中文乱码 python_03


网页空白处右键菜单 → 编码(E) → 确保编码是 "西欧(ISO)" ,即可看到正确内容。


python thinker 中文文档 python文档chm中文版_ini文件中文乱码 python_04


要是重度使用,那可麻烦得不行。

【 原因分析 】

Python 3.5 之前(几乎)没有这个问题,Python 3.6 时为什么就有了呢?

对比两者的 html 原文,可看到差别。 拿“右单引号”这个字符来举例(经常出现于 Python's XXX 这样的语境)……

Python 3.5 时,html 中的原文是 ’ ,表示第 8217 号 Unicode 字符,8217 此处为十进制,转为十六进制为 U+2019

Python 3.6 起,html 的单引号就直接就用一个字节表达了,该字节的值是 0x92 ,0x92 在西欧字符集中即是右单引号。IE 中所谓的西欧字符集,也叫 Windows-1252 字符集,又叫 iso-8859-1 字符集,早些年还叫 Latin-1 字符集。


python thinker 中文文档 python文档chm中文版_python thinker 中文文档_05


python thinker 中文文档 python文档chm中文版_ini文件中文乱码 python_06


那用 0x92 来表达单引号对不对呢?这取决于 html 的 <head> 中有没有明确告知本网页是用哪种字符集来编码的。有的,其内容是:


<head>
    <meta http-equiv="Content-Type" content="text/html; charset=cp1252" />
  </head>


宣称的字符集名称是 "cp1252" 。


python thinker 中文文档 python文档chm中文版_python thinker 中文文档_07


我们要知道,Windows 自带的 chm 查看器(hh.exe),其显示网页用的是 IE 内核。现在的问题是, IE 认不认这个 "cp1252" 呢?

经过试验,很遗憾,IE 并不识别它,换言之,跟你写 charset=somegarbage 的效果是一样的。那么,IE 内核就会用自己的算法来决定网页中用的是什么编码。经过粗略观察,其行为大致如下:

  • 对于一个新装的简体中文 Windows ,IE 直接认为是 GBK 编码。因此我们就看到了第一张图中“抯”这样的汉字。
  • 如果你右键明确要求编码为 “西欧(ISO)”,且不勾选“自动选择”。IE 对当前网页会采用 “西欧(ISO)” 。但,对于你之后打开的网页,则没有影响,之后还是用默认的 GBK 。意即:手动选择编码只在当前网页应用一次。
  • 如果你右键勾选“自动选择”,那么,它将影响当前与后来打开的网页(包括后来打开的 chm)。IE 会分析网页的原始字节流来判定应该用哪种编码。 然而,这种判断是很粗浅的,经我观察,有部分 Python 3.7 chm 页面会被判断为西欧编码,但另一部分仍旧判定为 GBK 。

IE 能识别的写法是 charset=iso-8859-1

有办法摆脱 Windows 默认 chm reader 的乱码困扰吗?倒是有,换用第三方的 chm 阅读器。 比如 Sumatra PDF, 这软件名字叫 PDF reader,实际上支持好几种电子书格式,包括 chm,由于它用的不是 IE 内核(也许是 WebKit 之类的开源内核),可以正确识别 charset=cp1252,显示就没有问题。但不妙的是,Sumatra 缺失了重要的 索引(N)搜索(E)

hh.exe 唯一让人牙痒痒的地方是,它的主菜单和右键菜单中竟然没有提供 “选择编码” 的菜单项。hh.exe 使用的 encoding 会受先前你在 IE 中右键菜单 encoding 设置的影响,但,经观察,这种影响是不可控的,意即,你之前可能设定了要求“自动选择”,关闭 chm 重开,chm 中貌似生效了,但也许睡眠或重启一下 Windows ,chm 中的 encoding 行为又变了。

Python 3.6 的 chm 为什么突然发生了这种改变呢?原因是 python.chm 是用一款叫 Sphinx 的软件生成的,也许是 Sphinx 某个版本的升级导致了 chm 内容的变化。如果 Sphinx 将 ”charset=cp1252" 改为 "charset=iso-8859-1", 这问题也许立马就解决了,也有人提了,但似乎并无进展。这期间只能我们自己想办法变通了。

【 Windows 的默认 系统字符集决定了 hh.exe 用的“默认编码” 】

(本节技术名词较多,你可以跳过,直接看下一节的解决方案)

问题的根源在于,IE 内核在看不到 html 中有合法的 <head> charset 值的情况下,总是倾向于用“系统字符集”来作为默认的网页编码。这个系统字符集,还有另外三个名字(名词一堆,含义其实是相同的):

  1. 系统代码页, system codepage 。
  2. 系统区域设置, system locale 。
  3. 非 Unicode 程序使用的语言(此处称“语言”显然是名词误用,应该叫“编码”才对), Language for non-Unicode programs 。

System locale 的一个具体取值,称为一个 codepage , 它是一个整数值。安装一份崭新的简体中文 Windows ,默认的 system locale 是 codepage 936 。

控制面板中可以设定这个 system locale ,修改后会被要求重启 Windows ,因为它将影响整个系统。


python thinker 中文文档 python文档chm中文版_python thinker 中文文档_08


举例:

  • “中文(简体)” 对应 codepage 936 。
  • “中文(繁体)” 对应 codepage 950 。
  • “日语” 对应 codepage 932 。
  • “英语” 对应 codepage 1252 。

你在中文版 Windows 上将 system locale 改为 codepage 1252 行不行呢?当然可以,修改后 python.chm 乱码问题立马解决了。然而,你用 notepad.exe 来查看很多 GBK 编码的 txt 时,将看到乱码,运行很多 non-Unicode 的中文界面软件,也将看到乱码,得不偿失。因此不建议这么做——因为 system locale 设定是 Windows 全局的。

Windows API GetACP()


#include <stdio.h>
#include <windows.h>

int WINAPI WinMain (HINSTANCE hInstance, HINSTANCE hPrevInstance,
                    PSTR szCmdLine, int iCmdShow)
{    
    UINT curcp = GetACP();
    char tbuf[40];
    sprintf(tbuf, "GetACP() = %u", curcp);
    MessageBox(NULL, tbuf, "Info", MB_OK);
    return 0;
}


Visual C++ 编译命令:


cl GetACP.cpp /link /subsystem:windows user32.lib


GetACP.exe 在简体中文 Windows 上运行,显示一个对话框:


python thinker 中文文档 python文档chm中文版_Python_09


至此,体会到麻烦的症结所在。 System locale 是 Windows 全局的,而不能够针对具体的进程来设定。微软当初的这个设计是很让人遗憾的,埋下了一个大大的不灵活性的种子。反之,如果微软当初给 CreateProcess 添加一个参数,用于指定目标进程的 system locale,同时,快捷方式的属性中添加一个选项来指定这个 locale ,该问题创建一个快捷方式就能解决掉。唉,可惜这不是现实。

【 找到一个较为完美的解决方法 】

如果我们有一种手段能够针对具体的进程来设定 system locale,问题是不是就解了呢?更具体地说,就是希望简体中文 Windows 上的 hh.exe 这个进程调用 GetACP() 时,得到的 codepage 是 1252 而非 936 。

事实上,微软 2003 年曾经还真的提供过这样的程序,叫 AppLocale ,就是起这个作用的,但该程序已经被微软撤掉了,很可能是因为它不支持 Windows 10,而且,微软也懒得再更新此程序让它支持 Windows 10 了。

不过还好,有一个开源项目叫 Locale Emulator ,简称 LE ,提供了跟 AppLocale 类似的功能,而且在 Windows 10 上是可用的。

执行 LEInstaller.exe 来将 LE 安装到系统上。然后执行 LEGUI.exe 来添加 application-specific locale 配置项。

比如,添加一个名曰 "西欧_cp1252" 的 locale (取名任意),locale setting(位置设定) 选为 English (United States) 。


python thinker 中文文档 python文档chm中文版_Python_10


添加配置项后的效果是,我们右击一个 EXE,在 Locale Emulator 子菜单中,我们能够要求以什么样的 application-specific locale 来启动该 EXE 。如下图:


python thinker 中文文档 python文档chm中文版_Python_11


现在试试看用 ”西欧_cp1252“ 来执行 GetACP.exe 的效果。嘿,GetACP() 果真返回 1252 了。


python thinker 中文文档 python文档chm中文版_ini文件中文乱码 python_12


好,现在用 LE 来对付 python.chm ,让它老老实实显示出正确的西欧字符。操作方法如下。

前提:假定 LE 程序放在 D:LocaleEmulator ,python370.chm 放在 D: ,而且你用的是 64-bit Windows。

我们在任意文件夹中创建一个快捷方式,目标填为:


D:LocaleEmulatorLEProc.exe -runas "03b54bd4-14d9-4349-be81-dc8725e47a1f" C:WindowsSysWOW64hh.exe "D:python370.chm"


此后,双击这个快捷方式来查看 python370.chm ,就不再看到乱码了。如下图:


python thinker 中文文档 python文档chm中文版_python_13


python thinker 中文文档 python文档chm中文版_python thinker 中文文档_14


解释:

  1. LE 安装后,右击 chm 文件并不像右击 EXE 那样会给出 LE locale 选择菜单,因此我们得创建自己的快捷方式。
  2. 自己的快捷方式应该启动什么程序呢?直接启动 hh.exe 是不行的,那样 LE 将没有机会介入。我们得启动 LEProc.exe ,它是 LE 为我们提供的 launcher ,可用于自动化脚本。 LEProc.exe 启动后,根据传给它的参数来启动我们真正要执行的 exe 。
  3. 我们真正的目标 exe 是 hh.exe 。
  1. 在 32-bit Windows 上,目标填 C:Windowshh.exe 即可。
  2. 但在 64-bit Windows 上,C:Windowshh.exe 是 64-bit exe 。LE 的局限性在于,他无法处理 64-bit exe ,因此,我们得改用 C:WindowsSysWOW64hh.exe ,这个是 32-bit exe 。
  1. "03b54bd4-14d9-4349-be81-dc8725e47a1f" 那截东西是什么? 是用来表示选用哪一个 locale 的,这是 LE 作者的设计。这个 GUID 值在不同的机器上是不同的,需要换成你自己机器上的正确值。找到该值的方法是:用文本编辑器打开 D:LocaleEmulatorLEConfig.xml ,找到 ”西欧_cp1252“ 小节,紧跟其后的 Guid 字串即是。


python thinker 中文文档 python文档chm中文版_Windows_15


【 LE 工作原理 】

粗略的猜测是,通过 LE 启动的 EXE ,被 LE 设定了 API 钩子。像 GetACP 这样的系统函数被勾住,不再返回系统原本的值、而是返回被 LE 篡改过的值。

32-bit EXE 的 API hooking 已经有成熟的方案了,因此 LE 可以轻松处理 32-bit 进程;而 64-bit EXE 的 API hooking 可能还是个难题,因此 LE 目前还无法处理。

END (初稿:2018.09.29)