读懂Unicode

1,108 阅读15分钟

1. 什么是Unicode?

Unicode 出现的背景:把所有语言都统一到一套编码里,且兼容 ASCII。至于为什么要统一看前一篇文章。

什么是Unicode,官方给出的是:Unicode 给每个字符提供了一个唯一的数字。这个数字在Unicode 中称为码点(Code Point)。Unicode 的核心就是为每个字符提供一个唯一的数字编号。

Unicode 与 UTF-X的关系:Unicode是字符集,UTF-32/ UTF-16/ UTF-8是三种字符编码方案。

2. Unicode 码点

码点:(Code Point)就相当于 ASCII 码中的 ASCII 值,它就是 Unicode 字符集中唯一表示某个字符的标识。

与码点相对应的还有一个概念叫码元。

码元:(Code Unit)代码单元是把码点存放到计算机后出现的概念。对于UTF-8来说,代码单元是8比特长;对于UTF-16来说,代码单元是16比特长。稍后将具体介绍码元。

码点的表示形式: U+[XX]XXXX

X 代表一个十六制数字,可以有 4-6 位,不足 4 位前补 0 补足 4 位,超过则按是几位就是几位。具体范围是 U+0000 ~ U+10FFFF。理论大小为 10FFFF+1=0x110000。即 17 * 2^16=17 * 65536。这是一个百万级别的数。

按照 Unicode 官方的说法,码点范围就这些了,以后也不会再扩充了。

为了更好分类管理如此庞大的码点数,把每 65536 个码点作为一个平面,总共 17 个平面(Plane)。

3. 平面、BMP、SP

平面:即码点的集合(空间)。

image-20210531220801711.png

image-20210531220852075.png

具体哪些平面包含哪些字符,可以参看这个网站

3.1 BMP:

17个平面中的第一个平面即:BMP(Basic Multilingual Plane 基本多语言平面)。也叫 Plane 0,它的码点范围是 U+0000 ~ U+FFFF。这也是我们最常用的平面,日常用到的字符绝大多数都落在这个平面内。UTF-16 只需要用两字节编码此平面内的字符。没包括进来的字符,放在后面的平面。

再来看下BMP中的具体码点:

CJK 中日韩统一表意文字

范围: 4E00—9FFF;字符总数: 20992。其中,汉字的范围是:4E00 — 9FD5。

所以经常能见到使用正则表达式 [\u4E00-\u9FD5] 来匹配中文。在要求不那么严格的应用中,按以上范围去判断基本也 OK,但也不是完全对。如果想精确匹配中文,可以用: /\p{sc=Han}/gu

image-20210531225953745.png

代理区(Surrogate Area)

范围:D800 — DFFF;其中:D800–DBFF 属于高代理区(High Surrogate Area), DC00–DFFF 属于低代理区(Low Surrogate Area),各自的大小均为 4×256=1024。这两个区组成一个二维的表格,共有1024×1024=210×210=24×216=16×65536,所以它恰好可以表示增补的 16 个平面中的所有字符。

image-20210531230715031.png

代理对

一个高代理区(Lead)加一个低代理区(Trail)的码点组成一对即是一个代理对(Surrogate Pair),必须是这种先高后低的顺序,如果出现两个高,两个低,或者先低后高,都是非法的。

3.2 SP:

后面的16个平面称为 SP(Supplementary Planes 增补平面)。这些平面中的码点已经超过了 16 位空间的理论上限,所以对于这些平面内的字符,UTF-16 采用了四字节编码。

注:其中很多平面还是空的,还没有分配任何字符,只是先规划了这么多。

辞海中收录的汉字就达2400万左右。

另:有些还属于私有的,如上图中的最后两个 Private Use Planes,在此可自定义字符。

4. Unicode 编码

码点(code)仅仅是一个抽象的概念,是把字符数字化的一个过程,仅仅是一种抽象的编码。

整个编码可分为两个过程。首先,将程序中的字符根据字符集中的编号数字化为某个特定的数值,然后根据编号以特定的方式存储到计算机中。

4.1 Unicode 编码的两个层面

1. 抽象编码层面

把一个字符编码到一个数字。不涉及每个数字用几个字节表示,是用定长还是变长表示等具体细节。

2. 具体编码层面

码点到最终编码的转换即 UTF (Unicode Transformation Format:Unicode 转换格式)。

这一层是要把抽象编码层面的数字(码点)编码成最终的存储形式(UTF-X)。码点(code)转换成各种编码(encode),涉及到编码过程中定长变长两种实现方式,定长的话定几个字节;用变长的话有哪几种字节长度,相互间如何区分等等。

UTF-32 就属于定长编码,即永远用 4 字节存储码点,而 UTF-8、UTF-16 就属于变长存储,UTF-8 根据不同的情况使用 1-4 字节,而 UTF-16 使用 2 或 4 字节来存储码点。

注:在上一层面,字符与数字已经实现一一对应,对数字编码实质就是对字符编码。

image-20210531212719661.png

4.2 大、小端与BOM

在存储数据时,数据从低位到高位可以按从左到右排列,也可以按从右到左排列。现代计算机基本_上都采用字节编址,即每个地址编号中存放1字节。假设变量i的地址为80 00H,字节01H、23H、45H、67H应该各有一个内存地址,那么地址0800H对应4字节中哪字节的地址呢?这就是字节排列顺序问题。

多字节数据都存放在连续的字节序列中,根据数据中各字节在连续字节序列中的排列顺序不同,可以采用两种排列方式:大端方式(big endian)和小端方式(little endian),如图所示。

大小端.png

大端方式按从最高有效字节到最低有效字节的顺序存储数据,即最高有效字节存放在前面;

小端方式按从最低有效字节到最高有效字节的顺序存储数据,即最低有效字节存放在前面

BOM

BOM=Byte Order Mark,即“字节顺序标识”。它用来标识使用哪种端法。

BytesEncoding Form
00 00 FE FFUTF-32, big-endian
FF FE 00 00UTF-32, little-endian
FE FFUTF-16, big-endian
FF FEUTF-16,little-endian
EF BB BFUTF-8

为了方便说明问题,以下内容都是小端方式。

image-20210601111102504.png

4.3 UTF-32

在 UTF-32 这种定长的编码方式下就表示每 4 个字节一个断句,比如:字符 A 的码点 U+0041(二进制为 1000001)被 UTF-32 编码后就会变成如下形式存储在计算机中:

00000000 00000000 00000000 01000001

  • 优点:查找效率高,时间复杂度O(1)
  • 缺点:空间浪费

它会将 4 个字节中空出的高位全部填充为 0。这种表示的最大缺点是占用空间太大,因为不管都大的码点都需要四个字节来存储,非常的占空间,那么如何突破这个瓶颈呢?变长方案应运而生。

4.4 UTF-8

UTF-8 属于变长的编码方式,它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。使用的是高位保留的方式来区别不同变长,具体方式如下:

  1. 对于单字节的符号,字节的第一位设为0,后面7位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的。

  2. 对于n字节的符号(n > 1),第一个字节的前n位都设为1,第n + 1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。

image-20210601111125111.png

解码 UTF-8 编码也很简单了,如果一个字节的第一位是 0,则这个字节单独就是一个字符;如果第一位是1,则连续有多少个 1,就表示当前字符占用多少个字节。

码点与UTF-8相互转换:

用汉字“一”(U+0041)来举例:

image-20210601112253165.png

4.5 UTF-16

UTF-16 是一种变长的 2 或 4 字节编码模式。对于 BMP 内的字符使用 2 字节编码,其它的则使用 4 字节组成的代理对来编码。

码点与UTF-16相互转换:

  1. 对于在BMP平面内的字符,无需转换,直接对应;

  2. 对于SP增补平面内的字符,需要做相应的计算。

    Lead = (码点 - 0x10000) ÷ 0x400 + 0xD800
    Trail= (码点 - 0x10000) % 0x400 + 0xDC00
    

    拿😀(U+1F600)举例:

    Lead = (1F600 - 10000) ÷ 400 + D800 = F600 ÷ 400 + D800 = 3D + D800 = D83D
    Trail= (1F600 - 10000) % 400 + DC00 = F600 % 400 + DC00 = 200 + DC00 = DE00
    

    所以,😀(U+1F600)对应的代理对即是 D83D DE00。

5 双向文本

双向文本是指一个字符串中同时包含书写方向从左到右和从右到左的两类文字。

大多数文字都是从左到右的书写习惯,比如拉丁文字(英文字母)和汉字,少数文字是从右到左的书写方式比如阿拉伯文(ar)跟希伯来文(he)。在排版和布局中,需要考虑双向文本带来的问题。

5.1 逻辑顺序与显示顺序

逻辑顺序在Unicode标准内规定为文本在内存中表示的顺序,而显示顺序就是最终显示在我们面前所看到的文本的顺序,文本的逻辑顺序和显示顺序并不一定会一致,比如对于从右向左显示的文本,显示顺序应是从右向左的,而逻辑顺序则可能是从左向右的。

在所有主流的Web浏览器中,内存中的字符顺序(逻辑)与它们显示的顺序(可视)是不同的。Unicode 定义了它其中每个字符的方向属性,浏览器在渲染文本时使用一种规则来自动判断文本Unicode的方向,从而产生正确的顺序。这一规则由Unicode双向算法进行描述,也可以简称为“bidi算法”。

5.2 Unicode 字符方向属性

Unicode 方向属性包含三种类型:强字符、弱字符和中性字符。在这三种主要类型下面还有很多细小的属性分类。

  • 强字符的方向属性是确定的,与上下文的bidi属性无关,而且强字符在bidi算法中可能会对其前后的中性字符产生影响。大部分的字符都属于强字符,比如拉丁字符、汉字、阿拉伯字符。
  • 中性字符的方向性并不确定,受其上下文的bidi属性影响(前后的强字符)。比如大部分的标点符号(“-”,“[]”,"()"等)跟空格。
  • 弱字符的方向性是确定的,但不会对其上下文的bidi属性产生影响。比如数字以及跟数字相关的符号。
方向性相关字符效果
Left-to-Right (LTR)强字符从左至右(英文字母、汉字以及世界上大部分左->右书写的文字)方向性确定,LTR 或 RTL,和上下文无关.并且可能会影响其前后字符的方向性。
Right-to-Left (RTL)强字符从右至左(阿拉伯文字、希伯来文字以及右->左书写的文字)方向性确定,LTR 或 RTL,和上下文无关。并且可能会影响其前后字符的方向性。
Left-to-Right (LTR) / Right-to-Left (RTL)弱字符(数字和数字相关的符号)和强字符一样方向性也是确定的,但是不会影响前后字符的方向性。
Neutral中性字符(大部分标点符号和空格)方向性不确定,由上下文环境决定其方向。

5.3 方向

全局方向

全局也称为基础方向。全局方向是一个文本中的总体方向,文本在页面上显示方向运行的顺序取决于主要的全局方向,确定一个文本的全局方向主要靠以下几点:

  • 默认从文档(HTML)的左->右继承。
  • 如果有相关的dir属性或者direction样式,则根据相应的值指定方向。

浏览器会根据你的默认语言来设置默认的基础方向,如英语、汉语的基础方向为从左到右,阿拉伯语的基础方向为从右到左。

<p>腾讯<bdo dir="rtl">Tencent Docs</bdo>文档</p>

image-20210601143522615.png

方向串

方向串是指在一段文字中具有相同方向性的连续字符,并且其前后没有相同方向性的其它方向串。

举个🌰:输入完一串数字 (123)-456-789 后,输入阿拉伯文字 ولدت ,文本自动会变成从右从左的方向。

(123)-456-789ولدت 变成了 下图的样子。如果复制粘贴到不支持双向文本的编辑器中,还是(123)-456-789ولدت

弱类型的数字保持了自己本来的方向,而中性类型的字符()-+跟随了全局方向(首个强类型文字方向给出的。

image-20210601144800535.png

google.gif

上面的文本被分为了7个方向串,由于中性符号被全局方向影响,使得原本号码被拆分成不同方向串,被重新排序。

为此Unicode 标准中定义了一系列方向性控制字符,这些字符在界面上并不会显示。比如U+202E,可以强制文本右->左:

image-20210601155448890.png

5.4 控制字符

Unicode 双向算法能根据字符属性和全局方向等信息运算并正确地显示双向文字,在这种模式下,双向文字的显示方式基本上由算法完成,不需要人为的干预。但是,隐性模式的算法在处理复杂情况的双向文字时会显得不足,这时就可以使用显性模式来进行补充。在显性模式的算法中,除了隐性算法的运算外,可以在双向文字中加入关于方向的 Unicode 控制字符来控制文字的显示。这些被加入文字中的 Unicode 控制字符在显示界面上是不可见的,也不占用任何显示空间。它们只是在默默地影响着双向文字的显示。

6. 相关API

chatAt()

功能:返回给定索引位置的字符。具体来说,这个方法查找指定索引位置的 16 位码元,并返回该码元对应的字符:

console.log('字符串abc'.charAt(1)); // 符
console.log('😁'.charAt(1)); // �

charCodeAt()

功能:返回给定索引位置的码元,如果 Unicode 码点不能用一个 UTF-16 编码单元表示(因为它的值大于0xFFFF),则所返回的编码单元会是这个码点代理对的第一个编码单元) 。如果你想要整个码点的值,使用 codePointAt()

console.log('abc'.charCodeAt(0)); // 97
console.log('😁'.charCodeAt(0)); // 55357

console.log('abc'.codePointAt(0)); // 97
console.log('😁'.codePointAt(0)); // 128513

codePointAt()

功能:返回给定索引位置的码元。如果在索引处开始没有UTF-16 代理对,将直接返回在那个索引处的编码单元。如果传入的码元索引并非代理对的开头,就会返回错误的码点。

console.log('abc'.codePointAt(0)); // 97
console.log('😁'.codePointAt(0)); // 128513
console.log('😁'.codePointAt(1)); // 56833

fromCharCode()

功能: 根据给定的 UTF-16 码元创建字符串中的字符。这个方法可以接受任意多个数值,并返回将所有数值对应的字符拼接起来的字符串。

// Unicode "Latin small letter A"的编码是 U+0061 0x0061 === 97

console.log(String.fromCharCode(97)); // a
console.log(String.fromCharCode(0x61)); // a

对于 U+0000~U+FFFF 范围内的字符,lengthcharAt()charCodeAt()fromCharCode() 返回的结果都跟预期是一样的。

7. 排版断行

断行规则

在word排版中,对于某一行的排版需要考虑行与行之间是如何断行的。比如:中文文本,不能将 放在行末;对于英文文本,要保持一个单词的完整性;对于emoji 同样如此。

image-20210601163655341.png

断行算法:

断行算法基于:Box-Glue-Penalty 模型。

image-20210601164051413.png

  • Box:不可分割的块
  • Glue:可拉伸或收缩
  • Penalty:断行惩罚因子

排版最优 = 最少拉伸与收缩,最少断行惩罚值(如连字符越少越好)

First-Fit算法

按照单词和空格的原始长度依次排列,直到下一个单词会超出版心时结束当前行排版

缺点:空格只能拉伸不能压缩

优点:O(n)复杂度,实现简单

Best-Fit算法

设定空格的最大拉伸长度和最小压缩长度,通过贪心算法保证当前行排版是最优的

缺点:贪婪算法,非全局最优解

优点: O(n) 时间复杂度

Total-Fit算法( Knuth-Plass )

在 Box-Glue-Penalty 模型下求最优断行的一种算法(动态规划)

优点:排版效果最好

缺点:非线性复杂度,速度慢。对于 CJK 文种效果不明显。不适用于行高不同且有环绕的情况。