字符编码——每个字节、每个字簇、每条规则都写在纸面上

把任何文本粘进来,把它按码位、UTF-8 与 UTF-16 字节、HTML 字符引用、百分号编码、一个 CSS 标识符和一个 JavaScript 字符串字面量读回来——再加上它怎么切成你浏览器当成一个字符画出来的那些字簇、切它时用的确切规则、哪些宁可拒绝回答,以及底下拿你自己的浏览器的编码器、切分器和解析器跑的一遍对账。你粘进来的任何东西都不离开这台设备。

一个字符的三种意思

「一个字符」有三种意思

你粘进来的一句话同时是三样东西,而这三个数除了碰巧,从来对不上。

  • UTF-16 单元 - String.length 回答的数,因为 JavaScript 的字符串本来就是 16 位单元的序列。 前 65,536 个码位之外的一切都要占两个单元,所以一个笑脸对 length 是 2,对你的眼睛是 1。
  • 码位 - Unicode 真正编了号的数。👨‍👩‍👧 是五个:三个图形,两个连接符。
  • 字簇 - 浏览器当成一个字画出来的东西。还是那五个码位,合成一个字簇,所以家庭表情在输入框里 只占一格,方向键按一下就能跨过它。

把行表切到「单元」,一对代理项就显出它本来的两行。一对里的单元没有自己的字符,所以它那一行的 字形位和字节位都印一条横线——只有整对才有这两样,给半个代理项编一个码位就是瞎造。 旁边没有配对另一半的那种是另一回事,它有自己的行,说清它是哪一半、编码器拿它怎么办。

三样都在同一次读取里从同一个字符串上读出来,算术也印在纸上。「一行一个」选择器决定你 看哪一样;顶上那四个数永远三样都给,所以它们之间一旦出人意料,不用切换任何开关就看得见。

四张表

  • 你粘进来的所有东西,一行一个 - 这个字符本身、它的码位、它落在哪个平面哪个区块、按你选的 记法写出来的 UTF-8 字节、带进来的那些范围能对它说些什么,以及你自己的浏览器自带的 Unicode 表对它说些什么。后两列是故意不一致的:一列是对上面写出来的六十来段记号范围、 八十余段区块范围做算术,另一列是你浏览器随引擎附带的那两千页数据文件。区块名这一列两个语言都 保留 Unicode 自己的英文原名——那是你去查表、去搜资料时必须照打的名字,翻成中文就搜不着了。
  • 文本是怎么被切成字簇的 - 走查切出的每一簇、簇里的每个码位,以及把它们扣在一起用的那条规则。 顶上那个数和底下浏览器给的对不上的时候,就读这张表。
  • 同一串文本的各种记法 - UTF-8 字节、UTF-16 大端和小端、HTML 的十进制与十六进制字符引用、 百分号编码、一个 CSS 标识符、一个 JavaScript 字符串字面量。每一行都先写出来,然后交给 你浏览器里读那种记法的那台引擎,「原样回来」那一列说的是不是同一串文本回来了。没回来的那几行 正是有意思的地方:编码在那里讲的是一条规则,不是一条公式。
  • 拒绝回答的 - 四条对任何字符串都成立的常设拒绝,加上你粘的这段具体撞上的那些。 常设的四条是字符的名字(那是一份三万四千行的数据文件,所以改给区块)、完整的规范 分解(这里只有谚文那套算术)、bidi 类、东亚宽度、折行类(又三张表,那是终端或浏览器的事, 不是范围表的事),还有 © 这类有名字的 HTML 引用——名字表在 /zh/tools/html-entities/ 那一页,它边走边从你的浏览器里读出来。

切字簇那趟走查用的规则,一条不漏

说一遍,好让你拿它去对上面那张表:

先给每个码位起一个短名单里的名字——行结束符、控制符、记号或变体选择符或肤色符或 tag、连接符、 区域指示符、谚文五类之一,或者平常字。然后在相邻两个之间放一道边界,除非撞上下面这些:紧跟在 CR 后面的 LF 与它合成一个结束符;记号、变体选择符、肤色符、tag 一律贴在它前面那个上;连接符也 贴前面,而且还会把后面那个也拉过来,但只在这两边都在那张图形名单里的时候;谚文那五类 拼成一个音节;区域指示符两个一对地配。剩下的一切各自另起一簇,落单的控制符也一样。

这趟走查永远只看它正在决定的那个字符紧前面那一个,这就是它两个盲区的来源。照公开切分规则走的 浏览器会把梵文或马来拉雅文的辅音对——两个辅音中间夹一个元音号——当成一个字簇,因为那条规则得 知道每个字符属于哪个文字体系,而文字体系是没带进来的一张表。还有几个写在数字前面的记号——阿拉伯 数字号,藏文和高棉文区间里的少数几个——对浏览器和它后面的数字是一簇,对那趟走查是两簇。粘哪一段 进去,对账表的那一行就会说出来。

怎么读那张对账表

最后一张表把同一个字符串同时过一遍带进来的算术和你浏览器自己的实现——UTF-8 用 TextEncoder, 字簇用 Intl.Segmenter,谚文用 String.prototype.normalize,再加上 encodeURIComponent、 CSS.escape、\p{M} 这类属性转义、两种 UTF-16 字节序用 TextDecoder、字符引用交给一台 HTML 解析器——两边的答案都印出来。

浏览器没有那个 API 的行不消失,而是印「没对成」,因为一张表如果随你恰好跑的是哪个引擎而换形状, 就没人能把它读第二遍。还有三样东西也让一行对不成,而且三样都是语言本身的事,不是那几张范围表的事。串里 有半个代理项,就没有百分号编码可用,因为 encodeURIComponent 碰到它会抛异常而不是给答案; 而给零、给半个代理项、给 80-9F 那一段里的数写下的字符引用,读回来根本不是它编号的那个字符 ——HTML 对前两个答 U+FFFD,对那一段窗口答的是它留着的老 Windows-1252 字符——所以那一行会印出 解析器把你的文本改成了哪一样,拒绝表把它们数出来。还有,编码器不肯做百分号编码的那半个代理项, 解码器同样不肯把它原样交回来:UTF-16 那两行写的是你这串文本自己的码元,半个代理项也在里面,因为 那些字节本来就是它们;而 TextDecoder 对每一个这样的一半答一个 U+FFFD,而不是把它退回来。 那一行会数出你自己的解码器这样处理了几个。连算术都解释不了的差异,照样是一行标着不一致的差异。

一行印出不一致,不是带进来的范围有 bug,也不是你的浏览器有 bug;它是那三样里的一样,上面的正文 每一条都交代过。要么带进来的范围比公布的属性窄(某个没列出来的区块里的记号,0030-0039 之外的 数字,那样的大约有六百个),要么那条切分规则需要一张表(上一节的两种情形),要么你浏览器的数据 文件跟这里写下来的范围不是同一个 Unicode 版本。最后这一条对那张图形名单尤其要紧:它是把一个公布过的 码位列表抄了下来,不是一条规则,所以它会放老;「抄下来的那张图形名单」这一行就是在做这个 检查——如果你的浏览器的表和带进来的名单把不同的码位算成图形,那一行会印出个数,并点名头几个。

还有一种差异是已经有定论的,不属于上面任何一条,所以把它说出口,而不是留在表里: encodeURIComponent 让 !、'、(、)、* 原样过去,因为 RFC 3986 出于向后兼容把这五个 留在非保留集里;而百分号编码器除了字母、数字、-、.、_、~,其余一律按字节写出来。粘一个进去, 那一行就把两种拼法都摆出来,另有一条拒绝说你那段文本里有几个这样的。

上限,以及为什么是这个数

输入 4,000 个 UTF-16 单元,每张表 300 行,每个格子 96 个字符,超出就截断并留一句话。输入那个上限 是因为一串文本要一个字符一个字符地读,并为每个字符印一行;过了几千个,那几张表就是一份要打开的文件 而不是一页能读的东西了,而它们顶上那些数本来就是对整个输入算的。存文件那个按钮把那份文件交给你。

所有事都跑在敲下这串文本的那台机器上。没有任何请求带着你的文本出去,不取字体,不在服务器上调用解析器:粘进来的 那串永远不离开这台设备。唯一一处把字符串交给解析器的地方是 HTML 那次读回,而那里只往一个 属于它自己的、分离的文档里写数字字符引用——从不写你粘进来的标记——所以那里面没有任何东西能加载 或渲染什么。

微信
微信
支付宝
支付宝