Unicode入门与剖析——从一个越南文的案例说起
写在前面
和大多数人一样,我本来对Unicode也是一知半解。由于从微软的VS2003开始(说起来竟然是8年以前了),Unicode已经是一个默认选项,熟悉的C++语言的char*变成了_TCHAR*,相信大多数人不会去深究其原理,只是将常用的几个字符串函数、基本类型做了相应变化而已。微软已经帮我们做了大量基础工作,大部分情况下并不需要深入研究。
在我不准备把Unicode搞明白的时候,遇到了一件事:我们的项目要移植到越南版本。没想到这个看似不是很难的工作,让我经历了一场有趣的Unicode之旅。
于是,作为总结,写文记录在这里。
一、前奏
我们的项目中用到的所有文本,都有专门的数据文件,按照ID、内容的方式存储起来。翻译时,用工具提取到一个数据库文件中,交给翻译人员逐条翻译。翻译人员直接在数据库中用编辑器进行编辑,完成后再用工具按原有格式写回数据文件,就OK了。这个工具经过了繁体中文版、英文版的检验,功能基本正常。
我们项目的数据文件,由于历史原因,只能是gbk编码,不是Unicode,而数据库里的内容是Unicode的。所以从数据文件到翻译数据库,再从数据库到数据文件,要经历两次转换。因为是Windows环境,首选必然是微软提供的两个函数:MultiByteToWideChar和WideCharToMultiByte。
对英文来说,转换相当的容易,Ascii、UTF8、UTF16之间,转换没有任何问题。中文有简繁体的问题,也就是GBK和BIG5两种编码。两种编码并非一一对应,所以要利用一张汉字对应表进行转换。由于这方面资料非常丰富,所以也没有带来太大的困难。
我们处理越南版时,将中文表格送给翻译人员,翻译人员在数据库中写好了越南语,交给我们。这时候数据库中的越南文字是UTF16编码的,我将它们转换成cp1258编码,也就是微软标准的越南文字符集。(也叫Windows-1258)
这时问题出现了——转换后的文字出现了很多问号“?”。
二、问号代表什么?
我的第一反应:微软提供的转换接口不应该有问题,问题可能出在字库上。询问了一个在专门做Linux服务器维护的同事,他说应该不是字库的问题。我用了多种编辑器:记事本、火狐、UltraEdit打开了数据文件,确定了那个问号确实是一个问号,Ascii编码0x3F。现在想起来,能联想到字库,也说明我完全没有了解Unicode和字库的关系。
幸运的是,我找对了了解编码的工具——记事本、火狐(或IE)、UltraEdit,编码三剑客,这三者配合,可以很方便的把各种怪异的编码搞清。具体如何使用,后面会说到。
那么问号是如何产生的呢?略去糊涂的中间过程不谈,我认为问题只能出在WideCharToMultiByte函数上!在从Unicode到cp1258的转换时,它并不能正确的工作!
通过连续不断的baidu和google,我确定了很多人遇到了和我类似的问题。当然很少有人是转换cp1258越南编码时出现问题,因为能搜到专门讨论越南编码问题的网页基本没有。大多数人是在开发Windows CE下的软件时发现问题的。摘录一段百度文库上的资料:
有的朋友可能已经发现,在标准的WinCE4.2或WinCE5.0 SDK模拟器下,这个函数都无法正常工作,其转换之后的字符全是乱码.及时更改MultiByteToWideChar()参数也依然如此.
不过这个不是代码问题,其结症在于所定制的操作系统.如果我们定制的操作系统默认语言不是中文,也会出现这种情况.由于标准的SDK默认语言为英文,所以肯定会出现这个问题.而这个问题的解决,不能在简单地更改控制面板的"区域选项"的"默认语言",而是要在系统定制的时候,选择默认语言为"中文".
系统定制时选择默认语言的位置于:
Platform -> Setting... -> locale -> default language ,选择"中文",然后编译即可.
这说明了什么?在我看来,微软并没有提供一个能在Unicode和本地字符集中任意转换的函数。曾以为输入的几个参数就可以让WideCharToMultiByte按我的意愿工作,这点可能真是一厢情愿了。微软不仅在MSDN的说明中有所保留,而且这个函数的运行结果还和你的系统设置有关。而且令人绝望的——我们用一台电脑装上了英文版Windows,而且还把越南文字有关的组件全部装上,病毒一般的question mark依然不愿离去。
三、怎么办?
从何入手呢?这个问题的好玩之处在于:1、事先谁也不知道这个问题很硬;2、它打破了我们对微软的信任;3、越南人民在编码方面的资料实在是不容易搜到。
作为一个代码民工或者说码农,遇到难题,最常做的事情莫过于:百度或者google,然后网页上说:“你应该用PeekMessage而不是GetMessage”,或者,“把1改成2试试”,或者复制一段别人的代码。让google代替我思考吧——问题搞定了——好爽——老板夸我搞的够快。
这次不知是幸运或者不幸,我们几乎没有google的机会。也许世界上有一个越南的专家写过非常完美的解决方案,但是我没法问他关键字是什么。
作为一个不满足于做码农的人,受老罗熏陶多次,我毅然决定把问题搞清楚。
用google搜索“Unicode”,然后打开第一个出现的简体中文网页——百度百科。好的,就从这里开始。
四、百度百科
打开这个网页,我来解说:
http://baike.baidu.com/view/40801.htm
Unicode是国际组织制定的可以容纳世界上所有文字和符号的字符编码方案。它的NB之处就是用较长的编码(21个bit),把世界上所有的文字统一编码,让本地字符集消失,也解决了同时显示多种语言的问题。(可以理解为实现了世界大同和宇宙和平。)
Unicode编码最终体现为UTF-16,UTF-8,UTF-32等等表现形式。这样做个类比:
计算机表示一个负数时,比如-33,有原码、反码、补码三种表示法。Unicode也有三种表示法UTF-xxx。
-33是原始的数字,是数学概念上的-33。Unicode编码,也是原始的Unicode数。原始的数不会直接使用。
在计算机里把-33写成0xFFDF就是一个计算机用来表示数字的补码了,相对的,UTF-16也是用来表示Unicode数的计算机内部编码。
“Unicode编码”英文简称UCS(Universal Character Set),经常有人半懂不懂或者居心叵测的人混淆UCS2、UCS4、UTF-16,希望看完这篇文章我们都能记住UCS和UTF的概念。
不知说清楚了没有。分析就按两步走,先不考虑具体的UTF-xxx,先看Unicode编码的21个bit怎么用。知道了UCS,再把它变成UTF-8、UTF-16就只是个按规则转换的机械性的工作了。
作为了解,先看看Unicode码位目前的划分情况。
1、Unicode有效值有0 ~ 0x10FFFF这么多,高位的0x10,表示第一刀切下去,切成16个“平面”(plane)。
2、现在用了:0、1、2、14、15、16。其中平面15和平面16上只是定义了两个各占65534个码位的专用区(Private Use Area),就是留给用户扩展的,还没具体作用。也就是说目前0、1、2、14四个平面已经解决了绝大多数世界语言,所以Unicode的容量还是相当令人满意的。
3、什么“保留区”的,有点一头雾水,其实不用管。就是转换UTF-16时用的。转换时候自然就明白了。
4、已定义码位分布在平面0、1、2和14上,它们对应着Unicode目前定义的99089个字符,其中包括71226个汉字。平面0、平面1、平面2和平面14上分别定义了52080、3419、43253和337个字符。平面2的43253个字符都是汉字。平面0上定义了27973个汉字。一句话:汉字威武。
作为中国人,特别是中国程序员,应该双手拥抱Unicode。
OK,Unicode编码了解的差不多了。下面把Unicode编码转换成实际需要的UTF-8、UTF-16、UTF-32。
①、UTF-8
Unicode编码(16进制) ║ UTF-8 字节流(二进制)
000000 - 00007F ║ 0xxxxxxx
000080 - 0007FF ║ 110xxxxx 10xxxxxx
000800 - 00FFFF ║ 1110xxxx 10xxxxxx 10xxxxxx
010000 - 10FFFF ║ 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
上面这个图怎么用呢?举个例子,汉字“北”,Unicode代码是0x5317,(我从字符映射表里随便挑的字),它的16进制表示不超过0xFFFF,所以它的UTF-16编码就是0x5317。“北”的二进制,写一下,有多少位呢?
答曰:15位,数一下UTF-8下面的叉叉,显然第二行的“110xxxxx 10xxxxxx”是不够用了,只能选三字节的“1110xxxx 10xxxxxx 10xxxxxx”,它提供了16个叉叉,可以放下我们的15个bit,就是它了。按顺序把数字填在叉叉上即可。
北:0101001100010111
填到1110xxxx 10xxxxxx 10xxxxxx里
11100101 10001100 10010111
ok,转成常用的形式就是0xE5,0x8C,0x97,可以看出,由于中间插了几个1和0,UCS和转换后的UTF-8差异巨大。用UTF-8的好处是什么呢?首先一点是保证了常用的数字、英文字母、换行符等,UTF-8和ASCII是兼容的,因为从0 ~ 0x7F这一段127个编码只需要一个字节表示,挺好的一件事。
在C++中,UTF-8用基本类型unsigned char来记录。
②、UTF-16
如果U<0x10000,U的UTF-16编码就是U对应的UCS,用无符号整数表示,也就是基本类型unsigned short。
如果U≥0x10000,我们先计算U'=U-0x10000,然后将U'写成二进制形式:yyyy yyyy yyxx xxxx xxxx,U的UTF-16编码(二进制)就是:110110yyyyyyyyyy 110111xxxxxxxxxx。这个和utf-8是类似的。
单独说一句,超全的字库“微软雅黑”,也没有超过0xFFFF的容量,最后一个字是0xFFEE:半空心圆圈。所以在windows界貌似UCS-2就够用了,也就是说基本不会遇到U≥0x10000的情况。
③、UTF-32
UTF-32有充足的码位,所以不用转换,和UCS可以一一对应。
上面一大堆乱七八糟的转换,可能有点晕。其实设计思路很简单:UCS有21个bit,所以在UTF-8、UTF-16里,必须空出21个bit来,才能把UCS完整的放下。UTF看起来就像是个网络封包,看UTF8的4字节表示,正好有21个x。有趣的是,看UTF-16,110110yyyyyyyyyy 110111xxxxxxxxxx,空位加起来只有20个,为啥呢?因为Unicode的最大码位是0x10ffff,减去0x10000后(也就是U<0x10000的那部分),最大值是0xfffff,所以刚好可以用20个二进制位表示。
这节太长太专业,看起来很累。休息一下该下一节。
五、百度百科——续集,字节序
字节序是个历史问题,它产生的根本原因是苹果、IBM、Intel当年设定最基本的硬件标准时,细节不大一样。一个十进制数5896,在Apple内存中,表示为0x17 0x08,在Intel架构下,表示为0x08 0x17。大家都知道,Intel是“小字节序”Little Endian,相对的,Apple就叫做“大字节序”,Big Endian。
不知道当年的人想到没有,这个设定给遥远的将来带来一个麻烦……UTF-8还好,对UTF-16和UTF-32来说,都有字节序的问题。大字节序和小字节序是不一样的。要么Intel一统江湖,否则必须清楚的标明是大还是小。
所以Unicode标准制定者定出一个绝招——在传输正文之前,先传几个字节,标明一下自己是大还是小。这个标记被称为BOM,Byte Order Mark。
UTF-16LE ║ FF FE
UTF-16BE ║ FE FF
UTF-32LE ║ FF FE 00 00
UTF-32BE ║ 00 00 FE FF
LE就是Little Endian,小字节序。BE就是Big Endian,大字节序。如果你收到一个FF FE,0xFFFE正好是一个不在UCS范围内的非法字符,所以应该是小字节序。标明完大小,这两个字节就可以扔了。当然你得先确定是UTF-32还是UTF-16。天哪,这个识别代码还不太好写,写文本编辑器的人要受累了。
Windows的编码问题,微软解决的比较完善,毕竟Windows环境比较纯洁和单一。Linux就不同了,有N多版本还有N多历史版本的Unix什么的,字符集得自己慢慢配,我就配置过samba、SFTP之类的几个软件,被折腾的够呛。
这说明:遇到编码问题,首选询问的对象就是搞Linux服务器维护的高手,他们一般经验比较丰富。
在这里,还要准备好几个武器:查看文件编码的工具。
1、火狐(或IE)——浏览器天生就是支持各种编码的能手,看菜单——编码里面长长的列表,就让人感到放心。如果拿到一个文件不确定它的编码,就在火狐里打开他,一个一个编码尝试即可。
2、记事本——记事本的作用是可以在保存文件时,选择编码。这在简单的编辑时非常有用,而且记事本在UTF-8和UTF-16时没有任何问题。记住记事本的底层肯定是调用微软自己的函数的,所以如果那个函数不能解决的问题,记事本也解决不了。
3、Ultra Edit,主要用他来一边编辑文字,一边查看具体的16进制数。对编码刨根问底的必备品。
六、开源软件拯救世界
终于从枯燥的百度百科中解放了。我已经武装好了自己的头脑,从一个Unicode盲变成了一个自信的Unicode菜鸟。
在越南人民渴望我们的软件的重要关头,微软给了我们一记闷棍。微软向来这么狠,比如Win7的无耻评分就大力推广了Intel等厂商的固态硬盘。
扯远了……总之,我们这个转换字符集的问题,肯定是能解决的,而且肯定被很多人解决过。通过一两天的摸索,不仅学习了Unicode标准,也对常用的字符转换方法什么的有了一些了解。尝试一下Linux下的方法:iconv。
在linux下试验,用一个简单的文本文件,用iconv从UTF-16转换成cp1258,没有问题。没有问号。
然后下载了一个iconv的windows版本,看了下参数的简要帮助。稍微一试,发现时好时坏。摸索之后,发现windows下加上-c 参数即可,-c参数意思是遇到小错误继续转换。转换后比较了一下,没有问题。
问题似乎解决了,好轻松啊。
接下来,就是把iconv和我们的翻译软件整合起来。经过和一位学者的讨论,还是用最土的办法:在C++里直接system调用,执行iconv工具就行。C++写字符串处理还真是不容易,不过折腾了半天还是把命令拼出来了。之后是漫长的转换、测试。
PS:iconv是一个现在仍然在维护的开源库,致力于字符集转换。看iconv的源码,发现维护一大堆转换表还是挺专业、挺需要长期建设的一件事情,微软就没打算把这件事做好。
之后还有个小插曲,转换完的数据文件,看起来都对,放在软件里可以运行,但时常出一些诡异的bug,还有经常崩溃什么的。查了半天才发现自己看文件也太不仔细了,转换后的文件,如果用火狐打开,每两行之间有一个空行,用VIM打开的话,可以看到每行结尾多了一个^M。纳闷之际,想起了Ultra Edit,UE有个非常重要的功能:查看16进制,如果没这玩意,我还得找别的专业软件去。其实VC也行,但是就没UE方便了。
一看吓一跳,每一行的结尾,都是0A 0A 0D 00。 windows标准换行,应该是0A 0D,也就是\r\n,用UTF-16LE表示,是0A 00 0D 00。理一下思路:使用iconv时,是把UTF-16转换成cp1258。这个地方,可能iconv不能正确的处理换行符。通过试验证明了的确如此。又尝试了linux下的一些小工具,包括dos2unix什么的,发现了……
Unix常用小工具大部分不支持UTF-16!!!
又是一个令人沮丧的消息,要知道,UTF-16是windows下标准,VC、Excel、记事本,都把UTF-16作为标准,而对UTF-8采取备选或者不支持的态度。这和Linux完全相反。除了一些常用工具,还有python、google新语言Go,都是以UTF-8为标准的,对UTF-16支持不好。
没想到Unicode快要普及的今天,8和16的区别依然埋下了一个隐患。好在UTF-8和UTF-16转换非常简单,可能能给人一点点安慰。不过我觉得,如果是私有文件格式之类的情况,再加上跨平台什么的,出麻烦是早晚的事。
解决方法:幸好为我们写基础库的高手,已经把UTF-16和UTF-8的转换封装好了(其实底层就是微软那个破函数),我只要先把UTF-16的文本转成UTF-8,再交给iconv拿去转换成cp1258。问题解决。
很多游戏里,大BOSS死了,只是假死,之后必须得复活、变身什么的,才是真正的最终BOSS。事实证明现实和游戏里基本是一样的。
七、真正的问题
由于一些历史原因,我们的软件采用双重标准。虽然我们的大部分文本资源是用gbk、cp1258之类非Unicode编码保存的,但是我们的UI部分,一律把它们转换成了widechar(大部分情况下,宽字符就是UTF-16,也就是VC默认的Unicode编码)。而且最终和文字有关的模块,只有UI。只要UI显示没有错误,那就没有错误了。
也就是说,我准备好的cp1258编码的文件,在程序运行时会再做一次转换(你猜对了,就是用MultyByteToWideChar这个函数),变成widechar。界面渲染时,从字库中提取widechar编码对应的文字,最终显示在屏幕上。
另外,有一小部分文本是UI模块自己管理的,它们是用utf-8或者utf-16格式存储的,运行时转换过程会简单一些。为方便起见,简称它们为“UI文本”。
我将转换好的字符文件放入程序运行,发现依然有很多地方有问号出现,经过困难的洗礼,我已经不把他当回事了。之前的一番折腾,我只是得到了正确的cp1258文本,至于能否正确的转换成UTF-16再正确的显示出来,恐怕还有一番工作要做。
但是仔细观察,发现“UI文本”问题比较少——没有问号,但是局部有乱码。UI文本转换的环节比较少,分析起来比较容易。现在忘记cp1258,先集中全力搞清UI文本乱码产生的原因。
一路走来,按照我们现有的知识来看,UI文本乱码是没有道理的,UI文本本身就是绝对正确的UTF-8编码(UTF-16和UTF-8之间的转换,理论和试验证明不会出问题)。通过调试代码,我发现出问题的文字,一般都是0x301。也就是只要有0x301的地方,都有乱码。
正确的编码产生乱码,应该是显示问题,也就是字库问题。在做底层的高手的指导下,我把字库替换为了一个超大字库——Arial Unicode。它是一个很大的Unicode字库,对Unicode编码的支持要比“宋体”全一些。替换之后,非常惊喜——乱码不见了。
禅宗公案里,师傅把一小支燃尽的柴火交给弟子,问他,能弄出火来么。弟子说,能。然后弟子轻轻吹了吹柴火,有火星浮现。师傅很赞赏,说:大审须细。
我把没有乱码的截图给高手看,高手看了说:你没发现,有些小圈小点不见了么。
八、深入越南文
乱码不见了,但是字母也缺了点边边角角,真是神奇的事情。
出问题的文字,编码是0x301,0x301到底是什么东西?
看来,非要彻底搞清越南文字编码不可了。带着一肚子的问题,我驾着度娘一路狂奔。
《综合越南语的输入、文件编辑、计算机处理》
http://www.beihai365.com/bbs/read.php?tid=985793
功夫不负有心人,在这篇文章里,我看到了下面一段话
越语 Unicode 的两种字母组合形式
precomposed character 和 composite character 是 Unicode输入越南语字母的两种组合形式。
以下两个越南语 字母,红色的是composite character,黑色的是precomposed character
ả ả
表面上两个字母没啥不同,但你用十六进制编辑器(Notepad++ 和 UltraEdit 中都有的)看一下( UTF-16 编码),
红色的是00 61 03 09
黑色的是1e a3
也就是说 红色的 字母 实际上 由 字母 a(00 61)和 表音符号̉( 03 09)组成,
黑色的字母就和我在一楼列表中的 Unicode 一样
许多输入法默认的输出选项是 precomposed character,但选项中也可能有 composite character
以UniKey 为例,它的默认Unicode输出选项是precomposed character,
UniKey的输出选项中还有一项:Composite Unicode( Unicode to hop),这项将输出composite character
在平常的文件录入和网页制作中,以上的两种组合并不会造成很大的麻烦
但在计算机处理时问题就大了
最明显的例子是许多越语在线词典不会认composite character
另外一些辅助翻译软件也会碰上同样的问题
测试:大家可把以下两个 越文词COPY 用来测试一下哪个越语词典不支持 composite character
đã đã
所以大家在使用输入法时 最好还是使用默认的precomposed character
别以为只要看上去一样就万无一失哦
学习就是一个撇开迷雾,树立正见的过程。我们总认为中文是最复杂的语言,而对其他语言不屑一顾,认为它们不会比中文复杂。但实际上,中文有个非常好的性质:一个汉字仅对应一个Unicode编码。而在越南语中,这个性质就不一定成立了。
一个越南文字,可以表示为一个Unicode编码,也可以表示为一个编码加一个修饰码。也就是说,有可能是一个,也有可能是两个编码。
把所有字母都整合成标准的一个UCS,被称为Precomposed。使用修饰码的方式,被称为Composite。
Precompose的和Composite都是针对Unicode来说的。cp1258等非Unicode字符集,根本上就不支持整合,这点和gbk是类似的。
Composite在显示时如何处理呢?有两种方式:
①、遇到0x301或者0x309之类的修饰符时,把光标向左移动一位,将修饰符重叠在原来的字母上。这样做的一个小问题是显示效果不太好,但用火狐查看cp1258编码的文件时,有时会看到这种明显是叠加上去的情况。
②、每次读入一个字母时,先不渲染,先看下一个字符是否是修饰符,如果是,就查表将它们转换成一个Unicode字符,再查字库显示出来。要求字库必须支持Unicode。
九、解决方案
在我们的软件里,字符渲染是上面的那个高手自己做的,而且渲染的代码还挺复杂。所以就不考虑改动渲染逻辑了,只需要把所有文本在渲染前全部替换成Precomposed即可。
这种关键时刻,依靠谁呢?不用说,必然是靠谱的——
iconv!!!
阅读一下iconv的源码,可以看到lib文件夹下有个cp1258.h的文件。稍微看一下它的调用过程,可以看出,它是利用一张转换表,在遇到0x301之类的修饰符时,用二分查找还原出Precomposed字符的。工作做的挺彻底。
相比之下,看看微软的MultiByteToWideChar,虽然它有一个控制转换方式的参数,但是经过我实验,在把cp1258转换成wchar时,只有两种结果:
第一种,没有正确转换成Precomposed,结果是标准的Composite。
第二个很绝——没有正确转换成Precomposed,而且把所有能拆的修饰符全部拆开,一个字符可以有2~3个wchar这么长。
那么,只剩两种做法可以选择了,一是调用iconv的标准接口做转换。二是把iconv的部分源码移植到项目中。我根据个人偏好选择了第二种。
十、后记
到写这篇文章的时候,我的问题还没有完全解决。好像是因为有人在不恰当的地方调用了转换函数,导致仍然有几个问号没有去掉。但大部分界面,都已经正常工作了。
简而言之我的忠告是:
首先,Unicode是得救之道。如果你使用Unicode,依然会有N个问题需要你解决。如果不使用Unicode,会有N*N个问题需要你解决。
其次,如果项目需要向全世界发行,那么,请把底层的MultiByteToWideChar和WideCharToMultiByte全部替换成iconv。
Good Luck!
——————————
fix:
2011-6-9
经验证,在越南版windows里,MultiByteToWideChar和WideCharToMultiByte可以正常工作。