Unicode 规范化工具

Unicode 有四种规范化形式,它们不能互相替代。NFC 与 NFD 把同一个字符在「一个码位」与「基字符加组合记号」之间来回摆放;NFKC 与 NFKD 走得更远,会改写那些只是长得像的字符 —— fi 连字、全角 A、带圈 ①、罗马数字 Ⅻ。本页把同一段文本跑遍四种形式,为每个字符打印一行说明它变了什么、为什么变,把字素簇与码位、UTF-8 字节一起计数,并按你选择的形式比较两个字符串。下半部分讲的是那些根本不显示的字符:零宽空格、连接符、双向控制符、变体选择符、C0 与 C1 控制字符、私用码位和残缺的代理项半边,每个都给出位置和名称,还可以把它们从文本副本里剔除。
全部处理都在你本地的浏览器里完成 —— 你的数据绝不会上传到任何服务器。

不确定什么是规范化? 用大白话解释 Unicode 规范化 —— 为什么 é 可以是一个字符也可以是两个,以及为什么这会让登录失败。
要报告并用于比较的形式:
在以下处理之后比较两个输入框:
NFC (暂无内容)
NFD (暂无内容)
NFKC (暂无内容)
NFKD (暂无内容)
还没有比较。粘贴两个字符串,然后按比较这两个字符串。
第一个字符串,已规范化 (暂无内容)
第二个字符串,已规范化 (暂无内容)
还没有扫描。按扫描不可见与危险字符,如果想要去掉这些字符的文本,再按剔除。
去掉所选类别后的文本 (暂无内容)
在第一个输入框里按 Ctrl+Enter 会执行当前模式。
选一个模式并点击它的执行按钮。规范化会写出文本的四种形式,并为每个字符写一行;比较判断两个字符串在规范化之后是否相同;扫描列出那些不显示的字符,并可以把它们剔除。自检按钮会重跑本页数据文件里的每一个记录案例、不可见与控制字符表点名的每一个字符,并把计数报在这里。
每个字符一行:这个字符是什么、四种形式对它做了什么,以及为什么。灰底行表示至少有一种形式改动了它;NFD 列显示该字符在会分解时分解成了什么。
这段文本到底算多少个字符?同一段文本用四种方式计数:字素簇(读者数出来的个数)、码位(Unicode 分配的个数)、UTF-16 单元(JavaScript 索引用的个数)和 UTF-8 字节(实际传输的个数)。规范化只会在第二列与第三列之间挪动数字,不会改动第一列。
按出现顺序,找到了什么:位置按码位计数,类别是该字符所属的类别,说明则告诉你这个字符会对接下来读它的程序做什么。
本页视为可疑的类别:这张表是从扫描器本身打印出来的,所以本页上的说法与按钮的行为不会互相跑偏。严重程度说的是对比较结果的影响,不是说这个字符有多可能是无辜的。
私用字符被列为可疑,原因和其余类别不同:它们确实会显示出来,只是没有公认的含义,所以同一个码位在每一个定义了它的字体里都是不同的图案。
本页能接受多大的输入,以及为什么:规范化是对整个字符串的一次重建,本页还要做好几次重建,外加一次扫描和一次字素计数,所以开销随输入长度直线增长。下面的上限之所以存在,是因为逐字符表是画在文档里的,而一张有一百万行的表才是真能拖死浏览器的东西。
什么是 Unicode 规范化?

Unicode 给某些字符提供了不止一种写法。字母 é 可以是单个码位 U+00E9,也可以是普通的 e 加一个组合尖音符,即 U+0065 U+0301。两者在屏幕上看起来一样,打印出来也一样,但作为字符串它们是不同的:长度不同,UTF-8 字节不同,而且 'é' === 'e\u0301' 的结果是 false。

这不是什么冷知识,而是一整类 bug。你会拿到哪一种形式,取决于键盘布局、操作系统、输入法,以及这段文字有没有被拷进过文字处理软件。按字节比较字符串的密码校验、数据库唯一索引、黑名单或缓存键,会把这两种写法当成两个不同的值 —— 于是用户在一台设备上设了密码,却从另一台设备登不进去;同一个词也会在「已被占用」的名字列表里出现两次。

30 秒版本: NFC 与 NFD 是同一段文本的两种写法 —— NFC 做组合(e 加尖音符变成一个字符),NFD 做分解(一个字符变成 e 加尖音符)。它们被设计成无损的,也是比较和存储该用的两种形式。NFKC 与 NFKD 会做完前两种形式的一切,然后再替换兼容字符:连字 fi 变成两个字母,½ 变成 1⁄2,罗马数字 Ⅻ 变成 XII,全角 A 变成普通的 A。这正是搜索索引或关键词过滤想要的效果,也正是绝对不能用在密码上的操作。
为什么是四种形式而不是一种

之所以分成四种,是因为要服务两个不同的目标。组合与分解文本是一种规范等价的改动:结果含义相同,对前两种形式来说没有任何信息丢失。把连字、上标、带圈字母和全角形式折叠掉,则是一种兼容等价的改动:它是为了匹配方便而做的,并且确实会抹掉 Unicode 认为不同的两个字符串之间的区别。四种形式就是这两个决定的全部组合。

上面的表来自本页数据文件里的案例清单,并与平台自带的 String.prototype.normalize 核对过,自检还会在你面前把每一行重跑一遍。请连着读前两行:它们是同一段文本的两种写法,规范化之后就成了同一个字符串。

可用的示例

这些是「示例」按钮会依次载入的字符串,由本页实时规范化得出,而不是预先写好答案。每一个都是因为某种形式会对它做出可见的改动才被选进来的。

兼容字符:用 NFKC 要付出什么代价

NFKC 与 NFKD 是有损的,而且这种损失是故意的。它们回答的是「这是不是同一个词」,而不是「这是不是同一个字符串」。由此产生三个后果,而这三个在生产代码里都很容易搞错:

  • 绝不要用兼容形式存储密码、令牌或签名。两个不同的输入会塌缩成同一个存储值,于是长度检查或字符检查就变弱了。密码请用 NFC 规范化(或者干脆不规范化),在两侧做完完全相同的处理之后再按字节比较。
  • 一次兼容折叠可以把一个字符变成好几个。连字 fi 是一个码位,会变成两个字母,所以在规范化之前做过长度校验的字段,规范化之后可能变长。先规范化,再校验,再存储。
  • NFKC 并不能去掉所有不可见字符。变体选择符在 NFKC 和 NFKD 下都会存活,零宽连接符也是。这正是本页要有扫描模式的原因:规范化回答的是关于字符形状与身份的问题,而扫描器回答的是「到底有没有东西在那里」。
字素、码位与字节

规范化会改动这三者中的第二个,通常不会动第一个。这三个数字都是「这段文本的长度」,却又各不相同,所以一个限制 20 个字符的表单字段,可能对应一个 20 个码位、30 个 UTF-16 单元、47 个字节的字符串。

字素那一列的数字是人眼数出来的个数。它与码位计数之间的差距,正是扫描器存在的意义:一个由 12 个码位构成的名字,可以显示成 12 个字母,也可以显示成 3 个字母夹着 9 个不可见记号,还可以显示成一个表情符号。

那些你看不见的字符

零宽空格不占宽度、不改变字形,却是一个有真实码位的真实字符。两个在屏幕上看起来一模一样的标识符,可能就差了其中一个,于是一个列表里的名字就可能是冒名顶替者 —— 它的排序、搜索和认证结果都与旁边那个名字不同。双向控制符也是同理,它们能让字符串的显示顺序与书写顺序不同 —— 这是一个被用来伪装文件名的老把戏。

第三个模式里的扫描器会把它们连同位置一起全部列出:零宽与连接字符、双向控制符、变体选择符、不常见的空格、C0 与 C1 控制字符、私用码位,以及不属于任何合法代理对的代理项 —— 那根本不是一个字符。本页靠上的类别表说明了每一类会做什么,剔除按钮则会去掉这些字符的一份副本,而不会动你的输入。

本页要抓的那些错误
  • 不规范化就直接比较两个字符串。 'é' === 'e\u0301' 是 false。比较之前请先用同一种形式规范化两侧,并在你要比较的索引里也使用同一种形式。
  • 只规范化一侧。 一个只规范化查询条件、却不管存储值的查询会什么都查不到,而且看起来像是数据出了问题,而不是规范化的问题。
  • 用兼容形式给表单做「整理」,还声称它是 NFC 安全的。 对密码套用 NFKC 会缩小可能密码的空间,并把本该区分开的输入合并到一起。
  • 以为规范化形式能修好仿冒问题。 它去不掉零宽连接符、变体选择符或同形字 —— 西里尔字母的 а 与拉丁字母的 a 是不同的码位,NFC 对它们不做任何处理。
  • 轻信字符计数。 用户看到的个数、数据库列强制的个数、字节上限强制的个数,是三个不同的数字,上面几行已经展示了它们能差多远。
  • 以为你拿到的一定是 NFC。 从输入法、文件或别的系统送来的文本,是那个系统产生什么形式就是什么形式。本页默认停在 NFC,是因为这是 Web 应该使用的形式,而不是因为你的输入本来就是它。
快速问答

“为什么这两个字符串看起来一样却不相等?”

因为其中一个用了组合字符,另一个用了基字母加组合记号;或者其中一个含有一个根本不画出任何东西的字符。切到「比较」模式,两个框各放一个字符串:本页会报告规范化后第一个不同的位置,以及各自在该位置上是什么。

“在应用里我应该用哪一种形式?”

凡是给人读、显示、存储和搜索的内容都用 NFC,这也是 W3C 为 Web 推荐的形式。当你背后的代码想要基字母与独立记号时用 NFD,这在文本处理以及 macOS 上的某些文件系统里很常见。只有在进行匹配而不是存储时才用 NFKC 或 NFKD:搜索索引、关键词过滤、重复检测。

“已经规范化的字符串,拼接之后还规范吗?”

不一定。规范化不会被拼接操作保持:把两段 NFC 字符串接起来,可能得到一段 NFC 本来会写得不一样的序列,因为第二个字符串开头的组合记号会挂到第一个字符串末尾的字符上。请在组装之后规范化,而不是只在组装之前。

“规范化会去掉不可见字符吗?”

不会,只有一个半例外:NFKC 与 NFKD 会把不换行空格换成普通空格,这是一次兼容折叠带来的可见结果。其余的 —— 零宽空格、连接符、双向控制符、变体选择符 —— 在四种形式下都会原样通过。这正是第三个模式存在的意义。

“为什么字符数没变,字节数却变了?”

因为两种形式由不同的码位构成,而这些码位在 UTF-8 里占用的字节数不同。预组合的 é 是两个字节;分解后的那一对是三个字节。上面的计数表把输入和每种形式的四个数字并排打印出来,所以差别是看得见的,而不是靠描述。

“我怎么知道本页的结果是对的?”

按自检。它会规范化本页数据文件里的每一个案例,按文档中记载的关系重新核对四种形式之间的关系(NFC 是幂等的、某个 NFC 字符串的 NFD 与原始字符串的 NFD 相同,等等),把不可见与控制字符表里点名的每个字符都过一遍扫描器,并把字素案例与 Intl.Segmenter 对照计数。

标准与致谢

四种形式及其定义来自 Unicode 标准附件 15(Unicode 规范化形式)。用作分解示例的码位是标准所指定的那些;本页并不自己实现分解表 —— 它调用浏览器自带的 String.prototype.normalize,这与平台在文本输入、排序和比较时使用的是同一份实现,所以这里报告的结果,就是系统其他部分对同一字符串会做的事。字素簇在浏览器支持时来自 Intl.Segmenter,不支持时来自一个文档化的近似算法。

本页正文由本站撰写。页面上的每一个示例都由本页自己的代码生成,并与数据文件记录的案例清单核对过,而该清单又针对相同字符与 Unicode NormalizationTest 数据核对过。不可见与危险字符表是为人类可读的警告而精心挑选的子集,并不是完整的 Unicode 安全概要(UTS #39),本页也明确说明了这一点,而不会暗示自己具备并不具备的完整性。