一点Unicode冷知识

Wan Xiao
17 min readJul 12, 2022

--

最近因为业务需求,看了一下Unicode字符集里的东西,有一些小的知识点在这里记录一下。

在正式阅读之前,不妨先思考几个问题:

  1. 一个 emoji 一定是一个字符吗?
  2. 一个 emoji 的不同肤色版本是怎么编码的?
  3. 过滤用户输入的文本时,能否过滤掉所有的格式字符(format character)?
  4. 格式字符是否都不可见?
  5. 如果用户名里含有带样式的字符,比如𝔸,如何不使用简单的映射的情况下将它们转换成A?

读完之后,应该就都有答案了。

建议先阅读我之前写的博客:

(受限于你的设备的字体,本文内容中的某些字符可能在你的设备上无法正常展示。实际发现 Mac Chrome、 Mac Safari、iPhone Safari 看到的都略有不同。由于 Medium 对表格的支持不好,本文中的表格均为 Mac Chrome 中浏览语雀中表格的截图)

变体形式 / Variant form

一个字符的不同字形被称为变体形式,Unicode 中通过变体序列(variation squences)来编码一个字符的变体形式。变体序列由基本字符(base character)和一个变体选择符(variation selector)组成。

emoji变体

emoji样式的变体
我们最常见到的变体形式就是 emoji。

部分emoji拥有两种表现形式:文本样式(text presentation)和 emoji 样式(emoji presentation)。

指定表现形式的方式是在基础 emoji 字符的后面跟一个变体选择符,如果后面跟的是 U+FE0E (VARIATION SELECTOR-15, 后面简称VS-15),则指定为文本样式;如果后面跟的是 U+FE0F(VARIATION SELECTOR-16, 后面简称VS-16),则指定为emoji样式。

基础 emoji 字符渲染出来的样式可能是文本样式,也可能是 emoji 样式,取决于你的设备。如果其没有对应的变体形式,则依旧展示为基础字符的样式。下表列出了几个 emoji 作为示例。

注意不同的设备看到的结果可能不同。

一般比较新的 emoji 都没有文本样式,但你的设备有可能会单独支持它的文本样式。

emoji肤色的变体
还有一种常见的变体体现在不同肤色的 emoji 上。

比如 U+1F385(🎅)圣诞老人除了基础的黄色版本,还有其他五种不同肤色的版本:🎅🏻🎅🏼🎅🏽🎅🏾🎅🏿。

人类肤色,可以根据菲茨帕特里克度量(Fitzpatrick scale)分为从浅色到深色的六类。Unicode 中定义了与之相关的五个变体选择符,正式名称叫 EMOJI MODIFIER FITZPATRICK TYPE ,如下表所示:

所以不同肤色的 emoji ,其实就是基础 emoji 字符后面跟一个 EMOJI MODIFIER FITZPATRICK TYPE 字符。

由于不同设备的字体支持不同,可能你看到的表格里,会展示成一个 base emoji 加一个 肤色色块 的形式,这是正常现象,代表你的字体不支持该 emoji 的不同肤色的变体。

全角字符变体

全角(fullwidth)字符中的东亚标点符号有两种形式的变体:角对齐(corner-justified)与居中(centered)。当基础字符后面跟一个 U+FE00(VARIATION SELECTOR-1,简称VS-1)时,代表角对齐;当基础字符后面跟一个 U+FE01(VARIATION SELECTOR-2,简称VS-2)时,代表角居中。

这里说的是全角符号中符号在字符方块中的位置,之所以叫角对齐,是因为当文字横向排版时,符号居左;当文字纵向排版时,符号居右。

由于我的电脑没办法很好的渲染这种变体,这里就不举例子了。

蒙古语变体

蒙古语一个字符有多种变体形式,每个变体在词首、词中、词尾以及独立时又有特殊的外观。

Unicode 中定义了四个自由变体选择符来选择不同的变体。

U+180B(MONGOLIAN FREE VARIATION SELECOTR ONE,简称FVS1)

U+180C(MONGOLIAN FREE VARIATION SELECOTR TWO,简称FVS2)

U+180D(MONGOLIAN FREE VARIATION SELECOTR THREE,简称FVS3)

U+180F(MONGOLIAN FREE VARIATION SELECOTR FOUR,简称FVS4)

FVS 格式符对蒙古语来说是必须的,如果要过滤用户的蒙古语输入,不能过滤掉 FVS。

Emoji序列 / Emoji Sequences

前面介绍的 emoji 变体其实也是 emoji 序列的一种,这里再介绍两种。

键帽 / keycaps

0到9、#、* 这些中任意一个字符,后面跟一个 U+20E3(Combining Enclosing Keycap)就可以组成一个 emoji,比如 U+0039(9)后面跟 U+20E3 可以得到 9️⃣ 。

旗帜 / flags

emoji 中有许多国家和地区的旗帜,比如新加坡国旗 🇸🇬,这个 emoji 其实是 Unicode 中 Enclosed Alphanumeric Supplement 区中的区域指示符号(Regional indicator symbols)组合成的。这些符号和26个英文字母是一一对应的,比如 U+1F1E6(🇦)对应的是 U+0041(A)。

根据 ISO 3166–1 alpha-2 可知新加坡的国家码为 SG,将区域指示符号中的 U+1F1F8(🇸,对应S)和 U+1F1EC(🇬,对应G)连在一起,就会变成 🇸🇬。

零宽连字 / Zero-width joiner

U+200D(Zero-width joiner,ZWJ)是一个不可打印的格式字符(format character),当 ZWJ 被放置在两个原本不会连接在一起的字符中间时,这两个字符会被以连接的形式渲染出来。

ZWJ 具体的表现取决于被连接的字符。某些语言下,可能是将字符渲染成连词(conjunct consonant)或连字(ligature),当 ZWJ 被放置在某些 emoji 符号之间时,会将这些 emoji 合成一个 emoji 展示(需要序列合法且字体支持)。

Emoji ZWJ序列 / Emoji ZWJ Sequences

Emoji ZWJ 序列也是 Emoji 序列的一种,通过 ZWJ 将两个或多个 emoji 连接起来,最终展示成一个合并了它们各自特点的 emoji。

比如这个序列:

👩(U+1F469)

ZWJ(U+200D)

❤(U+2764)

VS-16(U+FE0F)注意这个是变体选择符,和前面的❤组合在一起,为❤️,不需要ZWJ连接

ZWJ(U+200D)

💋(U+1F48B)

ZWJ(U+200D)

👨(U+1F468)

最终展示出来是 👩‍❤️‍💋‍👨 (不同设备看到的可能略微有点不同,比如男女左右位置互换)(最近发现这个emoji在Chrome上渲染有点奇怪)

再举个例子:

👩(U+1F469)

🏿(U+1F3FF)注意这个是肤色的变体选择符,与前面的👩组合在一起,为👩🏿,不需要ZWJ连接

ZWJ(U+200D)

🦳(U+1F9B3)

最终展示出来是 👩🏿‍🦳

需要注意的是,这种序列并不是可以随意组合的,序列是固定的。

等价性问题

我在Unicode等价性与正规化中介绍过 Unicode 等价性,上文提到的很多也是一串 Unicode 码点组合成一个字形,即变体序列,那变体序列是否存在兼容等价呢?比如 emoji 变体和 emoji 是兼容等价的吗?并不是。

emoji 变体其实只是字形上的改变,并没有一个新的码点与其对应,所以也就不存在等价性。

等价性主要是处理搜索和文本处理的问题。变体序列的搜索,最简单的就是忽略变体选择符。

零宽不连字 / Zero-width non-joiner

U+200C(Zero-width non-joiner,ZWNJ)也是不可打印的格式符,和 ZWJ 相反,它的作用就是插到字符中间防止字符连在一块,避免连字的出现。

对于波斯语、马来语、德语、尼泊尔语等语言,ZWNJ 是必要的,如果漏掉 ZWNJ,可能导致含义不同、或违背语法。

比如尼泊尔语 श्रीमान्‌को 中有一个 ZWNJ (श्रीमान् [ZWNJ] को),如果去掉它,则文本会变成 श्रीमान्को,注意看两个文本长得不一样,如果让 Google 翻译,意思也完全不同。

蒙古语元音分隔符 / Mongolian vowel separator

前面说到蒙古语的时候提到,蒙古语一个字符有多种变体形式,每个变体在词首、词中、词尾以及独立时又有特殊的外观,为了避免歧义,Unicode 中还有一个蒙古语元音分隔符 U+180E(MONGOLIAN VOWEL SEPARATOR,简称MVS)用于分隔元音。

MVS 是对蒙古语的正确显示非常重要的格式符,如果需要对用户的蒙古语内容做过滤,不能过滤掉这个格式符。

可见的格式符

前面看到的格式符本身都是不可见的,只是影响其他字符的字形,其实格式符也有可见的,比如叙利亚语缩写标记 U+070F(Syriac Abbreviation Mark,SAM)。在使用时,SAM 会在文本上方显示出一条横线。

私人使用区 / Private Use Areas

Unicode 中私人使用区域(Private Use Areas, PUA)一共有三处:

Private Use Area(U+E000..U+F8FF)

Supplementary Private Use Area-A(U+F0000..U+FFFFF)

Supplementary Private Use Area-B(U+100000..U+10FFFF)

由于 Supplementary Private Use Area-A 和 Supplementary Private Use Area-B 的存在,根据 Unicode 的代理区的规则, High Private Use Surrogates(U+DB80..U+DBFF)区域也属于 PUA。(参考基础字符编码知识

Unicode 并不会在这些区域分配字符,但第三方可以自行在这片区域分配字符。尽管名字叫私人使用区,但第三方可以公开自己分配的方案。

由于是第三方私自分配的,所以如果你使用这片区域里的码点编码的字符,这些字符一般只在你当前使用的平台上可见,如果你需要其他人也能看到和你一样的文本,其他人需要和你使用相同的字体。

比如 Apple 将 U+F8FF 分配给了 Apple 的 logo(),只有使用 Apple 设备访问这个页面的时候,才能看到这个字符,非 Apple 设备一般无法渲染这个字符,或者渲染出来是其他私有字符。

双向文本 / Bidirectional text

(本节内容主要为翻译)

一般语言的排版方向都是固定的,比如中文、英文都是从左向右排版(LTR),但阿拉伯语、希伯来语则是从右向左排版(RTL),如果文本里所有的字符都是一个排版方向的,那没什么问题,如果存在排版方向不同的文本混排,则称之为双向文本。

Unicode 标准中有一个非常繁琐的 Unicode 双向算法(Unicode Bidirectional Algorithm,UBA)来规范这种双向文本的排版。

但 UBA 也有犯错误的时候,偶尔也需要开发者给 UBA 提供一些指引,让其正确排版。

Unicode 标准要求字符必须以逻辑顺序存储,逻辑顺序即解析文本的顺序,以区别视觉顺序。UBA 即描述了如何将逻辑顺序转换为视觉顺序。为此 Unicode 将字符分成了几种类型。

强字符 / Strong characters

带有明确排版方向的文本,要么是LTR,要么是RTL。比如 大部分的字母字符,音节字符,汉表意文字,非欧洲或非阿拉伯数字,以及专属于这些文字的标点符号。

弱字符 / Weak characters

弱字符的排版方向不明。包括欧洲数字、阿拉伯数字、算术符号和货币符号。

中性字符 / Neutral characters

中性字符的方向必须根据上下文确定。 包括段落分隔符、制表符和大多数其他空白字符。 许多脚本中常见的标点符号,例如冒号、逗号、句号和不换行空格(non-breaking space)。

定向格式符 / Directional formatting characters

特殊的 Unicode 序列,可指导 UBA 修改其默认行为。 这些字符被细分为“marks”、“embeddings”、“isolates”和“overrides”。 它们的效果一直持续到出现段落分隔符或“pop”字符。

Marks
如果一个弱字符和另一个弱字符连在一起,UBA 会查看第一个相邻的强字符。有时这会导致无意的显示错误,使用“伪强”字符可以纠正或者避免这些错误,这些 Unicode 控制符被称为 marks。U+200E (LEFT-TO-RIGHT MARK, LRM) 或 U+200F (RIGHT-TO-LEFT MARK, RLM) 可以被插入到某些位置,使其封闭的弱字符继承其排版方向。

例如,在阿拉伯语(RTL)文章中,需要正确显示英文品牌名称(LTR)旁边的商标符号 U+2122 (™),如果商标符号后面没有跟一个 LTR 文本(比如我想要展示成这样 “ قرأ Wikipedia™‎ طوال اليوم. “),由于 ™ 前面是 LTR 强字符,后面是 RTL 强字符,但它在阿拉伯语文章中,所以将变为 RTL 排版,因此一个 LRM 需要被插在商标符号后面,保证它被两个 LTR 强字符包裹,采用 LTR 排版。

如果没有插入的话,最终显示出来是这样的 “قرأ ™Wikipedia طوال اليوم.”(这里故意写错,以模拟阿拉伯语环境下查看的样子,没插入 LRM 的写法在英文/中文的环境下展示的是正确的,只在阿拉伯语环境下展示错误)

+-----+--------------------+--------+
| LRM | LEFT-TO-RIGHT MARK | U+200E |
+-----+--------------------+--------+
| RLM | RIGHT-TO-LEFT MARK | U+200F |
+-----+--------------------+--------+

Embeddings
embedding定向格式符是Unicode以前使用的显式格式符,从Unicode 6.3开始,由于有了 isolates,不再鼓励使用 embeddings 。一个 embedding 表示一段文本的排版方向是不同的。emebding 格式符内部的文本并不独立于周围的文本存在。此外,emebdding 中的字符会影响到外部字符的顺序,反之亦然,整体表现类似一个强字符。Unicode 6.3 意识到 embedding 定向格式符对周围文本的影响太大,没必要且难以使用。

+-----+-------------------------+--------+
| LRE | LEFT-TO-RIGHT EMBEDDING | U+202A |
+-----+-------------------------+--------+
| RLE | RIGHT-TO-LEFT EMBEDDING | U+202B |
+-----+-------------------------+--------+

Isolates
isolate 方向格式符表示一段文本将被视为与其周围文本在排版方向上隔离。从 Unicode 6.3 开始,如果平台支持,则更鼓励使用它们。与早先的 emebdding 方向格式符不同,isolate 格式符对周围文本的顺序没有影响,isolates 可以嵌套,可以出现在 embeddings 和 overrides 里面。

+-----+-----------------------+--------+
| LRI | LEFT-TO-RIGHT ISOLATE | U+2066 |
+-----+-----------------------+--------+
| RLI | RIGHT-TO-LEFT ISOLATE | U+2067 |
| FSI | FIRST-STRONG ISOLATE | U+2068 |
+-----+-----------------------+--------+

Overrides
override 方向格式符是为特殊情况准备的,比如产品型号(比如,强制混合了英语、数字、希伯来字符的产品型号从右往左书写),建议尽可能避免使用它。和其他方向格式符一样,overrides 可以嵌套,可以出现在 embeddings 和 isolates 中。

+-----+------------------------+--------+
| LRO | LEFT-TO-RIGHT OVERRIDE | U+202D |
+-----+------------------------+--------+
| RLO | RIGHT-TO-LEFT OVERRIDE | U+202E |
+-----+------------------------+--------+

Pops
用于停止embeding、override 或 isolate 的作用范围。

Runs

在 UBA 中,一串连续的strong字符被称为一个run,一个处于两个strong字符间的weak字符,会继承他们的方向,如果他们方向不同,就会继承主上下文的书写方向。(LTR文档中时LTR,RTL文档中是RTL)

过滤用户输入

上面的定向格式符可以影响到外部文本,所以我们需要特别小心的处理用户输入的文本,比如用户名。

用户一般可以自定义自己的用户名,如果用户名会参与排版,特别是插入到一段文本中,则用户可以通过插入 RLO 之类的控制符,以使得带有他名字的文本内容排版异常。

如果要过滤定向控制符,我个人认为,除了marks,其他的都应该过滤掉,避免其干预外部排版。

当然如果面向的市场语言大家都熟悉,比如中文、英文市场,则使用白名单的方式过滤字符是最安全的。如果面向的市场语言很多,则只能用黑名单模式,发现一例,禁掉一例。毕竟我们不是 Unicode 专家,不知道哪些格式符对哪些语言是必须的。

如何将带样式文本的样式去掉

这里的样式特指 Mathematical Alphanumeric Symbols 区中的字符,比如 U+1D538(𝔸)。以及 Halfwidth and Fullwidth Forms 区中的部分全角字符,比如 U+FF21(A)。如果我们需要将用户输入的 U+1D538(𝔸) 或 U+FF21(A)转换成 U+0041(A),以此类推,如果我不想代码里手动写一堆映射,如何转换?

首先我们要明确范围,只转换 Mathematical Alphanumeric Symbols 以及我们了解的全角字符。

参考 Unicode等价性与正规化,这些字符基本上都是和对应的基础字符是兼容等价的,所以对这些文本做一次 NFKD ,仅获取基础字符,用基础字符替换原字符即可。

注意不能对全部文本做这种操作,因为像 U+00C3(Ã)如果被这样操作之后,会变成U+0041(A),这和我们说的将带样式的文本的样式去掉,是不符的。

--

--