从一个 bug ,引出 UTF-8 编码的讨论。

去年 8 月份,我尝试自己写代码解析 Linux Man-Pages 的页面。

Man-Pages 使用的是 groff 文本标记语言,其最大的特色就是格式标记宏置于行首,例如 man(7) 第 35~48 的内容为:

1
2
3
4
5
6
7
8
9
10
11
12
.SH NAME
man \- macros to format man pages
.SH SYNOPSIS
.B groff \-Tascii \-man
.I file
\&...
.LP
.B groff \-Tps \-man
.I file
\&...
.LP
.B man

每一行开头出现的字段就是标记宏,依次的含义为:

说明
.SH 标题
.B 加粗
.I 斜体
.LP 开始段落

经过解析后,这段文本在 man 程序里看起来会是这个样子1 1当然,.B.I 的效果在代码框里无法显示。

1
2
3
4
5
6
7
NAME
man [section] title
SYNOPSIS
groff -Tascii -man file ...
groff -Tps -man file ...

既然是 *nix 的产物,我在写代码解析这些文件的时候,第一反应就是使用 UTF-8 编码。 Unicode Logo图 1 Unicode Logo

什么是 UTF-8 编码?这得从计算机如何表达文本说起。当人们说起“文本”,他们通常指显示在屏幕上的字符或者其他的记号;但是计算机不能直接处理这些字符和标记;它们只认识位(bit)和字节(byte)。

实际上,从屏幕上的每一块文本都是以某种字符编码(character encoding)的方式保存的。粗略地说就是,字符编码提供一种映射,使屏幕上显示的内容和内存、磁盘内存储的内容对应起来。有许多种不同的字符编码,有一些是为特定的语言,比如俄语、中文或者英语,设计、优化的,另外一些则可以用于多种语言的编码。

早在 1963 年,ANSI 发布了基于电报码的 ASCII 字符编码,它用 7 位 (bit) 表示了 27 = 128 个字符,只能勉强覆盖英文字符。后来随着电脑技术的传播,人们呼吁把字符编码扩充到 8 位也就是一个字节 (byte) ,可以涵盖 28 = 256 个字符。于是 ISO 在 1980 年代中期推出了 ISO 8859,256 个字符显然也不能满足需要,所以 8859 被分为十几个部分:从 8859-1 (西欧语言) 、8859-2 (中欧语言) ,直到 8859-16 (东南欧语言) ,覆盖了大部分使用拉丁字母的语言文字。

但人类的语言文字种类实在复杂,即使是 ISO 8859-16 也无法表达很多地区的文字。在这前后还出现了很多编码,例如 IBM 的 OEM 代码页、微软的 ANSI 代码页,还有中国大陆的 GB2312 和 GBK ,等等。人们也逐渐认识到编码不统一带来的种种不便。于是 ISO 和 统一码联盟合作推出了 Unicode 编码,Unicode 编码系统为表达任意语言的任意字符而设计。Unicode 编码一共提出了三种版本:

  • 最早的版本是 UTF-32,它使用 4 字节的数字来表达每个字母、符号,或者表意文字(ideograph)。虽然这种编码方式可以在常数时间内定位字符串里的第 N 个字符,但每个字符都要用 4 个字节来表示,实在有些浪费;
  • 之后提出了 UTF-16 ,将最常用的 65535 个字符用 2 个字节表示,其他 Unicode 字符则使用另外一种映射方式。UTF-16 编码最明显的优点是它在空间效率上比 UTF-32 高两倍。不过,最常用的字符用 2 个字节来表示,还是有些浪费,特别是要处理许多 ASCII 字符时2 2如果仔细想想,一个中文网页也会包含许多的 ASCII 字符。
  • 于是人们又提出了 UTF-8 。UTF-8 是一种为 Unicode 设计的变长(variable-length)编码系统。即,不同的字符可使用不同数量的字节编码。对于 ASCII 字符,utf-8 仅使用 1 个字节来编码 3 3事实上,UTF-8 中前 128 个字符(0–127)使用的是跟 ASCII 一样的编码方式。 。像 ñ 和 ö 这样的扩展拉丁字符则使用 2 个字节来编码。中文字符比如“中”则占用了 3 个字节。很少使用的“星芒层字符”则占用 4 个字节。

IETF 要求所有网络协议都支持 UTF-8,互联网电子邮件联盟 (Internet Mail Consortium,IMC) 也建议所有电子邮件软件都支持 UTF-8,所以它已成为互联网上的事实标准。*nix 系统中的默认文件编码也是 UTF-8。这也是为什么我第一反应是使用 UTF-8 编码来解析所有 Man-Pages 文件。

然而,出乎我意料的是,我的代码在对这些文件的解析过程中会间歇性遇到 UnicodeDecodeError 错误。很快我就确定这些导致出错的文件一定是使用了其他的编码方式。导致这个情况的原因也可想而知 —— 这些文档的编写者遍布世界各地,可能使用了不同的本地化编码方案,从而编码方式也参差不齐。然而,这种情况并不是一件好事。文档编码的混乱会给文档解析带来不便。

发现了这个问题后,我访问了 Linux Man-Pages 项目的 bugzilla 页面。通过搜索,并没有人提出过类似的问题。于是我决定给该项目提交 bug 。

 Linux Man Pages图 2 Linux Man Pages

我写了个 Python 脚本,用于找出所有不是使用 UTF-8 编码的文档:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/python3
import os
import os.path
import sys
def mdata(folder):
file_list = os.listdir(folder)
for a_file in file_list:
a_file = os.path.join(folder, a_file)
if os.path.isdir(a_file):
mdata(a_file) # 递归遍历所有目录
continue
with open(a_file, encoding="utf-8") as fread:
try:
while True:
line = fread.readline()
if not line:
break
except UnicodeDecodeError:
print("{0}\n".format(a_file))
mdata('man-pages')

我的方法是直接使用 UTF-8 编码来递归遍历所有文档,如果遇到无法使用 Unicode 解析的字符,则将该文件打印出来。

得到这类文件的列表后,我就将这个列表连同说明 提交了上去

I found that not all the pages are encoded using utf-8. It may cause problems once we try to parse them.

These files are:

  • man3/fflush.3
  • man3/toupper.3
  • man3/updwtmp.3
  • man3/encrypt.3
  • man3/lockf.3
  • man3/rand.3
  • man3/fclose.3
  • man3/strtok.3
  • man2/close.2
  • man2/getdomainname.2
  • man2/madvise.2
  • man2/umask.2
  • man2/sysinfo.2
  • man2/getrlimit.2
  • man5/utmp.5
  • man7/cp1251.7
  • man7/iso_8859-2.7
  • man7/armscii-8.7
  • man7/suffixes.7
  • man7/iso_8859-4.7
  • man7/iso_8859-8.7
  • man7/iso_8859-16.7
  • man7/hier.7
  • man7/iso_8859-13.7
  • man7/koi8-u.7
  • man7/environ.7
  • man7/iso_8859-15.7
  • man7/iso_8859-9.7
  • man7/iso_8859-11.7
  • man7/iso_8859-14.7
  • man7/iso_8859-10.7
  • man7/iso_8859-6.7
  • man7/iso_8859-1.7
  • man7/iso_8859-7.7
  • man7/koi8-r.7
  • man7/iso_8859-5.7
  • man7/iso_8859-3.7
Weizhou Pan

大概过了三个月,Man-Pages 的一位名为 Peter Schiffer 的 Contributor 写了两个脚本作为回复:

  1. print_encoding.sh - 一个可以打印出所有 Man-Pages 文件的编码的 bash 脚本;
  2. convert_to_utf_8.sh - 一个可以将所有非 ASCII 编码的 Man Pages 文件转成 UTF-8 的 bash 脚本。

Peter 的第一个脚本只有寥寥几行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if [[ $# -lt 1 ]]; then
echo "Usage: ${0} man?/*" 1>&2
exit 1
fi
printf "\n %-23s%-19s%s\n\n" "Man Page" "Encoding by file" \
"Encoding by first line"
for f in "$@"; do
if [[ ! -f "$f" ]]; then
continue
fi
enc=$(file -bi "$f" | cut -d = -f 2)
if [[ $enc != "us-ascii" ]]; then
lenc=$(head -n 1 "$f" | sed -n "s/.*coding: \([^ ]*\).*/\1/p")
printf " * %-23s%-19s%s\n" "$f" "$enc" "$lenc"
fi
done
exit 0

其中,获取文件编码用到了 file 命令:

1
enc=$(file -bi <文件名> | cut -d = -f 2) # 获取文件的编码类型,并赋给 enc 变量

如果 enc 变量不为 ASCII 的话,那么将文件的编码格式和首行的编码信息打印出来(如果有的话):

1
2
3
4
# 获取文件首行的编码信息(如果有的话)
lenc=$(head -n 1 "$f" | sed -n "s/.*coding: \([^ ]*\).*/\1/p")
# 打印这两个信息
printf " * %-23s%-19s%s\n" "$f" "$enc" "$lenc"

Peter 的脚本可以得到更加丰富的信息,由于检测的是非 ASCII 文件,得到的文件数量也比我的结果多一些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
$ ./print_encoding.sh man?/*
Man Page Encoding by file Encoding by first line
* man2/close.2 iso-8859-1
* man2/getdomainname.2 iso-8859-1
* man2/getrlimit.2 iso-8859-1
* man2/madvise.2 iso-8859-1
* man2/mount.2 utf-8
* man2/sysinfo.2 iso-8859-1
* man2/umask.2 iso-8859-1
* man3/encrypt.3 iso-8859-1
* man3/fclose.3 iso-8859-1
* man3/fflush.3 iso-8859-1
* man3/lockf.3 iso-8859-1
* man3/rand.3 iso-8859-1
* man3/strtok.3 iso-8859-1
* man3/toupper.3 iso-8859-1
* man3/updwtmp.3 iso-8859-1
* man4/st.4 utf-8
* man5/utmp.5 iso-8859-1
* man7/armscii-8.7 iso-8859-1 ARMSCII-8
* man7/cp1251.7 unknown-8bit CP1251
* man7/environ.7 iso-8859-1
* man7/hier.7 iso-8859-1
* man7/iso_8859-10.7 iso-8859-1 ISO-8859-10
* man7/iso_8859-11.7 iso-8859-1 ISO-8859-11
* man7/iso_8859-13.7 iso-8859-1 ISO-8859-7
* man7/iso_8859-14.7 iso-8859-1 ISO-8859-14
* man7/iso_8859-15.7 iso-8859-1 ISO-8859-15
* man7/iso_8859-16.7 iso-8859-1 ISO-8859-16
* man7/iso_8859-1.7 iso-8859-1
* man7/iso_8859-2.7 iso-8859-1 ISO-8859-2
* man7/iso_8859-3.7 iso-8859-1 ISO-8859-3
* man7/iso_8859-4.7 iso-8859-1 ISO-8859-4
* man7/iso_8859-5.7 iso-8859-1 ISO-8859-5
* man7/iso_8859-6.7 iso-8859-1 ISO-8859-6
* man7/iso_8859-7.7 iso-8859-1 ISO-8859-7
* man7/iso_8859-8.7 iso-8859-1 ISO-8859-8
* man7/iso_8859-9.7 iso-8859-1 ISO-8859-9
* man7/koi8-r.7 unknown-8bit KOI8-R
* man7/koi8-u.7 unknown-8bit
* man7/suffixes.7 iso-8859-1

Peter 的第二个脚本也很好理解,找出所有非 ASCII 编码文件,使用 iconv 命令转换到 UTF-8 编码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
if [[ $# -lt 2 ]]; then
echo "Usage: ${0} <output_dir> man?/*" 1>&2
exit 1
fi
out_dir="$1"
shift
enc_line="'\\\" t -*- coding: UTF-8 -*-"
for f in "$@"; do
enc=$(file -bi "$f" | cut -d = -f 2)
if [[ $enc != "us-ascii" ]]; then
dirn=$(dirname "$f")
basen=$(basename "$f")
new_dir="${out_dir}/${dirn}"
if [[ ! -e "$new_dir" ]]; then
mkdir -p "$new_dir"
fi
case "$basen" in
iso_8859-11.7 | iso_8859-13.7)
from_enc=$enc
;;
armscii-8.7 | cp1251.7 | iso_8859-*.7 | koi8-?.7)
from_enc="${basen%.7}"
;;
*)
from_enc=$enc
;;
esac
printf "Converting %-23s from %s\n" "$f" "$from_enc"
echo "$enc_line" > "${new_dir}/${basen}"
iconv -f "$from_enc" -t utf-8 "$f" \
| sed "/.*-\*- coding:.*/d;/.\\\" t$/d" >> "${new_dir}/${basen}"
fi
done
exit 0

Peter 的两个脚本看起来已经足以解决编码问题了。不过这个 bug 却一直拖到了今年的情人节&元宵节 4 4也有人说是缘消节?>_< 才有了进一步进展。Man-Pages 项目的主要维护者 Michael Kerrisk 现身提出了两个疑问:

Peter,

Sorry to be slow following up on this. Thanks for the scripts.

As some background, I’ll just note that the current encoding markers in the iso_8859* pages were added in response to this 2009 bug report: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=519209

It seems a reasonable idea to convert everything to UTF-8, but I have some concerns/questions.

  1. Is the encoding line: '\" t -*- coding: UTF-8 -*- really needed, or does modern groff just work this out?

  2. I’m concerned about backward compatibility issues. As in: what if someone loads the man pages onto a system with old groff. Now, as far as I can work out, groff added input unicode support in v1.20, 2009 ( http://lists.gnu.org/archive/html/groff/2009-01/msg00011.html ). So, perhaps that’s long enough ago that we don’t need to worry too much about these issues.

Any thoughts?

Michael Kerrisk

Michael 的主要疑问有两点:

  1. Peter 在每个转换后的文件开头都加入了一行编码说明行 '\" t -*- coding: UTF-8 -*- ,这一行是否真的有必要,还是 groff 本身就可以自己判断编码?
  2. 对于老版本的 groff ,是否存在向下兼容问题。

Michael 的疑虑不无道理。转换整个项目的编码不是一件太 trivial 的事情,小心使得万年船。他于是又在 linux-man 邮件列表里发帖征求其他维护者的意见。

经过大家的讨论,后来大家达成了以下意见:

  1. 编码说明行只添加进存在 UTF-8 字符的文件;
  2. 由于各主流操作系统都早已经更新了支持 UTF-8 编码的 groff ,因此出现兼容性问题的情况应该比较少见。

两天后我收到了 Michael 发来的邮件,这个 bug 随着新版本的推出,在 2014 年 2 月 16 日被成功关闭。最新的 3.59 和 3.60 两个版本就是对针对这个 bug 的解决和后期完善。这也是我作为一个多年 Linux 用户,对这个社区所做的一个小贡献。这个过程中,我看到了这些 contributors 的严谨和负责。今后会继续努力,向他们看齐!:-p