ecnelises Bonvenon al la malpura mondo

UTF、Emoji和神奇的连字

Unicode是人所共知的标准,一般意义上的Unicode做了两件事:

  • 定义了一个集合,力图包括人类语言的大部分文字(学名叫Abstract Character Repertoire,ACR)
  • 为其中每个文字符号赋予一个惟一编号(学名叫Coded Character Set,CCS)

概念上,这已经足够解决「某些语言的文字不能被正确表示」的问题了。但我们无法轻易地就在计算机程序里用编号表示字符,因为:

  • 整数编号需要确定的长度,如果编号是2字节整数,那么它就无法表示第65537个字符
  • 这个系统最好要与已有编码(尤其是ASCII)兼容
  • 表示方法要考虑到空间,如果编号是4字节整数,那么一个ASCII文件换成Unicode体积就会膨胀到原来的4倍

UTF

为了落地解决方案,UTF(Unicode Transformation Format)应运而生,UTF做的事情也有个学名,叫Character Encoding Scheme(CES)。因为单元长度不同,UTF也有几个变种。

UTF-32

这是最直接的方案,每个字符都用32位(4字节)表示,由于32位无符号整数上限(4294967296)在可见的未来都足以包括所有Unicode字符,所以这个方案不需要考虑任何额外情况。实际上,截至2023年,Unicode包括的所有字符也没有超过150000个。

由于UTF-32是多字节编码,依据字节序的不同,UTF-32有little-endian和big-endian两个变种。如果你对两种字节序不了解,可以参看之前的文章

UTF-32编码有个非常显而易见的好处:字符串(String)的长度就是字符数组(Character Array)的长度,这和ASCII时代的经验一致。为什么强调这点,因为其他的Unicode编码方式未必满足这个要求。

至于为什么没有UTF-64、UTF-128,只能说可以,但没必要,仓颉从棺材里爬出来他也造不出那么多字。

UTF-16

使用16位(2字节)表示字符单元的方案自然就叫做UTF-16,2字节可表示的整数上限是65536。因为Unicode包括的字符总数比这个多,这就遇到问题了。解决方法是什么呢?如果一个双字节单元不够用,那用一对就行了。

注意,这种情况和UTF-32的区别是:UTF-32是对所有字符都用4字节表示,而UTF-16仅对超过某个范围的字符用4字节表示,其他情况依然使用2字节。为什么说「某个范围」而不是65536?因为我们需要一点额外信息,来区分普通的UTF-16单元,和一对UTF-16单元的其中一部分,这样普通的UTF-16单元就表示不了65536那么多个字符了。

这种「用一对双字节单元表示单个字符」的情况,叫做UTF-16代理对(surrogate pair)。它有一个具体的编码方案:

  • 如果一个字符的编码在[0, 0xD7FF][0xE000, 0xFFFF]中,则以单个双字节单元表示
  • 如果一个字符的编码在[0x10000, 0x10FFFF]
    • 编码减去0x10000,得到一个20位整数(最大值0xFFFFF
    • 把20位分成两半,前一半加上0xD800作为高部分,后一半加上0xDC00作为低部分
  • [0xD800, 0xDFFF]中单个元素不构成合法字符,仅作为代理对一部分存在

UTF-16的使用场景比想象的多。Apple平台的NSString内部用UTF-16表示;JavaScript的字符串也是UTF-16;很多要处理字符串的Windows API都分A和W结尾两种变体,A结尾的版本表示接受ASCII字符串,W结尾表示接受UTF-16字符串。

为什么没有UTF-24?因为3字节不方便。

UTF-8

UTF-8较前两者出现得更晚一些。因为用固定单字节表示字符的编码,已经被ASCII以及后续欧洲语言的扩展所占据,所以UTF-8必然是一个变长编码。UTF-32固定使用4字节,UTF-16在有代理对的情况下使用4字节,而它们都没有充分利用编码空间,类似地,UTF-8最多也可能占用4字节。

UTF-8具体的编码方式更麻烦一些,同样考虑一个在[0, 0x10FFFF]之间的字符:

  • 如果在[0, 0x7F]之间,则用1字节表示,这点保证了和ASCII的兼容
  • 如果在[0x80, 0x7FF]之间,用2字节表示,第一个字节以110开头,后续字节以10开头
    • 110abcde 10fghijk,表示abc_defg_hijk(每4个二进制位构成1十六进制位)
  • 如果在[0x800, 0xFFFF]之间,用3字节表示,第一个字节以1110开头,后续字节以10开头
    • 1110abcd 10efghij 10klmnop表示abcd_efgh_ijkl_mnop
  • 如果在[0x10000, 0x10FFFF]之间,用4字节表示,第一个字节以11110开头,后续字节以10开头
    • 11110abc 10defghi 10jklmno 10pqrstu表示a_bcde_fghi_jklm_nopq_rstu

UTF-8最早由Ken Thompson和Rob Pike提出,二位也同样是Go语言的发明者。

UCS

在某些地方可能还会出现UCS这个名词,它是Universal Coded Character Set的缩写。Unicode相关的各种组织、标准、概念的历史都相当复杂,UCS和Unicode起初也不是从属关系。但今天,大可以粗略地将UCS-4理解为UTF-32的同义词,而UCS-2代表「没有代理对的UTF-16」(也就是只能表示65536个字符)。

BOM

每个单元由2个或以上字节组成的编码方式,必然都要考虑字节序的问题。在UTF实践中,文件开头会有称作BOM (Byte-order mark)的标记以明确告知字节序。另一篇专门谈论字节序的文章也提到了BOM,简要说就是:文件开头的0xFE 0xFF表示big-endian,0xFF 0xFE表示little-endian。UTF-8因为最小单元为1字节,因此不需要BOM。

不是你想的那个字符

本文到此为止,明确区分了字节与字符的概念。但仍统一将整个文字体系中的多个概念称为字符,这依然是不精确的。所以再对几个都可以被称作字符的不同概念作出区分:

  • 编码单元 (code unit),是一种编码方式中的最小单元,比如UTF-8的编码单元就是1字节,UTF-16的编码单元是2字节
  • 编码点 (code point),是一个或多个编码单元组成的文本单位,也就是前文中介于[0, 0x10FFFF]之间,需要被编码的那个东西
  • 字位 (grapheme),是有意义的最小书写单位,一个字位可能由一个或多个编码点组成。字位和编码点的区别是:一个字位能对应一个可见的文本单元,而编码点则不一定
  • 字形 (glyph),是在文本中能独立显示的图形符号,一个字形可以由一个或多个字位构成。此概念已经超出字符编码的讨论范围,而更多和字体渲染技术相关

了解完UTF-16和UTF-8这样的编码以后,我们很容易意识到将编码单元理解为想象中的字符并不合适。那编码点和字位又有什么区别呢?

emoji

你一定很熟悉emoji了。emoji涵盖了基本表情、旗帜、生活用品还有家庭标记,而对出现人的部分还具备不同肤色的变体。而这些emoji的具体编码并不是逐个对应到一个独立编码点那么简单。

  • 在JavaScript中,😁这个字符串的长度为2,因为它的编码是0x1F601,在UTF-16里需要代理对表示
  • 但国旗🇨🇳的长度就不是2了,而是4
  • 🤵‍♂️的长度是5,带肤色的变体🤵🏻‍♂️则是7
  • 表示情侣的emoji 👩‍❤️‍👨长度为8,如果带上肤色👩🏻‍❤️‍👨🏻则变成了12
  • 也不是所有旗帜的长度都是4,比如彩虹旗🏳️‍🌈长度就是6

这到底是怎么回事!?


答案不难理解:JavaScript使用UTF-16,字符串长度代表UTF-16编码单元的数量,因此长度大于2的emoji都不是单个编码点,而是靠多个编码点组合形成的,这也就是字位和编码点的不同。来一一解释前面提到的emoji:

首先是国家和地区的旗帜,用两个特殊编码点表示,它们实际上是由两个特殊区域的字母拼出来的。在对emoji支持不佳的文本编辑器内,输入🇨🇳再按退格会留下字符🇨,被删掉的那个其实是🇳。这组字母位于[0x1F1E6, 0x1F1FF]的范围。其中所有的旗帜都按照ISO 3166-1中的二字符格式表示,比如中国为CN (🇨 🇳),美国为US (🇺 🇸),法国为FR (🇫 🇷),可以试着复制再删掉这几对特殊字符中的空格。Windows没有为国旗emoji设计内容,所以在Windows上这些字位会被实诚地显示为两个字母。

然后是穿礼服的男性🤵‍♂️,长度为5,试着把它拆成编码点:0x1F935 0x200D 0x2642 0xFE0F

  • 0x1F935自身代表一个无性别的形象🤵
  • 0x200D在Unicode里代表「零宽度连字符」(zero-width joiner, ZWJ),表示将前后字符连接在一起,在其他语言中也有用到
  • 0x2642单独看是字符♂,表示男性,在这里和零宽度连字符一起修饰emoji的性别
  • 0xFE0F是Unicode中的变体修饰符,[0xFE00, 0xFE0F]中16个编码点都是变体修饰符,该修饰符表示按emoji图像显示(有些字符如0️⃣既可以表示为emoji图像也可以表示为传统字符如0️︎,它们以末尾的修饰符作区分),但因为礼服男性的emoji只有图像风格,所以删去也不影响显示

带肤色的礼服男性🤵🏻‍♂️长度为7,在第一个码点后多了一个码点0x1F3FB,单独显示为🏻,在特定emoji后作为肤色修饰符。肤色修饰符位于[0x1F3FB, 0x1F3FF],共五种,不带肤色修饰符则以默认黄色显示。

彩虹旗🏳️‍🌈分解出来也不复杂,它在JavaScript里长度为7,由4个编码点构成,分别是表示白旗🏳️的0x1F3F3,修饰符0xFE0F,零宽度连字符0x200D,以及彩虹🌈的0x1F308

多人组合恐怕是emoji中最可怕的部分,因为要考虑多个性别、肤色的组合。拆开情侣emoji 👩‍❤️‍👨得到6个编码点:

  • 0x1f469表示女人的形象👩
  • 连字符0x200d
  • 桃心❤的0x2764
  • 修饰桃心为emoji的修饰符0xFE0F,两者组合构成❤️
  • 连字符0x200d
  • 0x1f468表示男人的形象👨

带肤色的情侣emoji会比默认版本多两个编码点,分别是在左侧形象和右侧形象后面跟上肤色修饰符。因为多出来的两个编码点都需要代理对,所以总长度为12(个UTF-16编码单元),也就是24字节。

这里的人-心-人的拼接并不是任意而为,而是Unicode为这个emoji特别设计的组合。但emoji中还有其他地方用到了类似的组合,比如男性技术人员👨‍💻实际上就是👨加上连字符再加上💻的组合。

emoji到今天已经数量繁多,这里没有囊括它的所有规则,但以上几个例子足以说明emoji如何被「拼接」而成,以及字位和编码点在实际环境的不同。

JavaScript的Unicode字符串常量"\u1234"仅支持小于0xFFFF的情况,编码点更高的字符需要手动拆成代理对,这里提供一段代码以方便实验emoji编码:

function concatUnicodeScalar(values) {
  return values.map(scalar => {
    if ((scalar >= 0 && scalar <= 0xd7ff) || (scalar >= 0xe000 && scalar <= 0xffff))
      return String.fromCharCode(scalar)
    else if (scalar >= 0x10000 && scalar <= 0x10ffff)
      return String.fromCharCode(((scalar - 0x10000) >> 10) + 0xd800) + String.fromCharCode(((scalar - 0x10000) & 0x3ff) + 0xdc00)
    else
      throw 'Bad unicode scalar'
  }).join('')
}
// Run as concatUnicodeScalar([0x1F935, 0x200D, 0x2642, 0xFE0F])

连接符

但并不是只有emoji当中才体现了字位和编码点的区别。天城文、马来文等文字也使用了零宽度连接符。

对于汉字,Unicode还有一组「表意文字描述字符」,可以通过描述结构和每个部分来造字。如0x2FFA表示左下的半包围结构,后跟「礼」与「分」即可组成「𥘶」这个汉字。从0x2FF00x2FFF,Unicode定义了16种描述字符,涵盖了不同的汉字结构。尽管编码层面支持了这套功能,但由于各种技术限制,目前的字体渲染并不支持显示出一个不存在的汉字。

连字

前面提到了,在字位之上还有个层次更高的概念叫做字形。这个概念通常和字体渲染相关。这两者的区别,就主要在连字 (ligature) 之上。

最典型的连字就是小写拉丁字母的fi和fl,i头上的点会被隐藏,然后和f连在一起。如今许多编程字体也利用了这一特性,将多字符操作符渲染为具有数学公司色彩的单个符号。

Unicode还专门定义了一个叫做零宽度非连字符 (zero-width non-joiner, ZWNJ) 的编码点,插入连字字符之中即可让它们不作为连字显示。

总结

  • Unicode定义了一组可显示的符号作为字位,每个字位由一个或多个编码点构成,Unicode为每个编码点赋予一个惟一编号
  • 不同的UTF使用不同方法将每个编码点对应到一组字节,其中UTF-16和UTF-8是变长编码,UTF-16的变长情况称作代理对
  • emoji有套复杂的规则将若干编码点组合成一个emoji字位,这套规则使emoji有了创造不同肤色、性别变体的能力
  • 在实际的字体渲染中,因为连字的存在,多个字位也可能被当作一个整体进行渲染

参考资料

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注