Emoji的奥秘
涛叔最近读到一篇写 Emoji 的文章,叫 Emoji under the hood1,写得非常通透。我联系了作者 Nikita,拿到了翻译许可。周末翻译了一把,分享给大家。
过去几周,我一直忙于给 Skija 项目添加 Emoji 支持。自从发明字母🅰️之后,Emoji可以说是人类通信史上最大的发明了。我很乐意跟大家分享 Emoji 的核心奥秘。
注意:有些 Emoji 可能没法在你的设备正常显示。
Unicode 简介
大家可能已经知道,在计算机内部,所有文字都用数字表示。一个字符对应一个数字(一组完整的对应规则称为一种编码)。最常用的编码就是所谓的Unicode。 Unicode有两个变种:UTF-8 和 UTF-16。
Unicode 一共分配了 2²¹(约200万)个字符,人们称之为码点(codepoint)。程序员们对不住了,21不能被8整除🤷。这200多万的码点中实际只使用了大约15万字符。
这15万字符包含了地球🌍上用过的所有文符。包括:好多已经没用人使用的语言的文字,好多奇奇怪怪的的字母,像是 𝔣𝔲𝔫𝔫𝔶 𝕝𝕖𝕥𝕥𝕖𝕣𝕤,sɹǝʇʇǝl uʍop-ǝpᴉsdn,㎓(GHz的单字符版本), ⤘(右向双箭头配箭尾和双竖线符号), ꙮ(七眼怪兽),甚至还有一只鸭子𐦖:
另外,大家可以查看Unicode的埃及象形文字区(U+13000–U+1342F),这里面还有更奇怪的字符:
基本 Emoji
最简单的 Emoji 就是 Unicode 表中的一个字符。多数简单的 Emoji 都被划到 U+1F300–1F6FF 和 U+1F900–1FAFF 两个区域。
所以 Emoji 跟其他字母没什么两样:你可以在文本框输入、复制、粘贴 Emoji。 Emoji 也可以在纯文本文档里面正常展示,你也可以在微博中加入 Emoji。如果你输入字母“A”,计算机看到的是 U+0041。如果你输入的是“🌵”,计算机看到的是 U+1F335。没有太大区别。
Emoji 字体
但为什么 Emoji 会显示成图片呢?或者位图字体。一般字体都是黑白色的矢量图形,没啥意思。你也可以给字符创建 jpg位图字体。
操作系统一般都会内置一种 Emoji 字体。macOS/iOS 内置的是 Apple Color Emoji 字体。 Windows 内置的是 Segoe UI Emoji 字体。Android 内置的是 Noto Color Emoji 字体。
我看了一下,Apple Color Emoji是一种 160x160 的点阵字体,Noto是一种 128x128 的点阵字体,而 Segoe 是一种矢量彩色字体🆒。
这也是为啥同一个 Emoji 在不同的设备上长得不一样。这个跟同一个字符在不同字体下显示效果不同是一个意思。除此之外,很多应用也会自带 Emoji 字体。 WhatsApp、Twitter 和 Facebook 带用自己的 Emoji 字体。
字体回退
现在讨论一下字体的渲染。我们不可能用 Apple Color Emoji 或者 Segoe UI Emoji 字体显示文本(除非你很小,不识字)。所以,如何在文本(假设是Helvetica字体)中显示 Emoji 呢?
答案是字体回退。这也是西里尔字母在 Clubhouse 或 Medium 上很难看的罪魁祸首。
当你输入字符的时候,比如说输入U+1F419,计算机首先在当前字体中查找。假设当前字体是 San Francisco。San Francisco 没有 U+1F419 对应的字形(glyph),所以操作系统开始查询其他安装的字体,看有什么字体包含 U+1F419 的字形。
只有 Apple Color Emoji 字体含有 U+1F419,所以系统使用它渲染 U+1F419 (剩下的字母还是使用当前字体渲染)。最后,你看到了🐙。所以,不管你选用哪种字体, Emoji 的显示效果都是一样的:
变体选择符-16
有些 Emoji 表情并不在 Emoji 编码区。实际上,支持象形文字的字体最早可以追溯到 1993年。我们可以看一下杂项符号区(Miscellaneous Symbols)U+2600-26FF 和装饰符号(Dingbats)区U+2700-27FF:
这些符号跟我们用的其他字母没什么两样:它们都占一个码点,黑白色,而且很多字体都支持显示这些符号。例如,下图是在我电脑中所有支持黑色剪刀✂ (U+2702)字体的显示效果:
你猜怎么着,Apple Color Emoji 字体在设计之初就支持显示剪刀符号 U+2702,显示效果如下:
现在问题来了。如果✂和✂️共用一个码点(U+2702),而且除 Apple Color Emoji 字体之外其他高优先字体也支持显示这个码点,操作系统怎么知道什么时候显示✂什么时候显示✂️呢?
这就需要用到变体选择符(U+FE0F,变体选择符一共有十六种,彩色 Emoji 用 VS-16)。它的作用就是告诉系统当前字符需用 Emoji 字体渲染。
- U+2702 — ✂︎
- U+2702 U+FE0F — ✂️
- U+2697 – ⚗︎
- U+2697 U+FE0F – ⚗️
- U+26A0 – ⚛︎
- U+26A0 U+FE0F – ⚛️
- U+2618 – ☘︎
- U+2618 U+FE0F – ☘️
简单而优雅。毕竟像☠和☠️的含义相同,只是显示上略有区别,没有必要分配新的码点。
字素集(Grapheme clusters)
现在我们得解决另一个问题:有此 Emoji 需要使用两个码点。也就说,我们需要一种方法来指定字符的边界。
这就需要用到字素集了。字素集是一组连续的码点,但被一个字形展示和识别。
字素集并非 Emoji 专享,普通字符在展示的时候也会用到。比如“Ü”是一个字素集,是由两个码点组成:大写字母U(U+0055),后面跟一个连音符号(U+0308)。
在用程序处理字素集可不容易。如果想截取字符串的前10个字符,你不能简单的使用 substring(0,10),因为这可能会截断 Emoji 字符(可能会让你痛苦万分,所以不要这样做。译者:这一句的意思是说可能产生意想不到的问题。)。
反转字符串也非常困难。U+263A U+FE0F 有意义,反转之后 U+FE0F U+263A却没有意义。
最后,你也不能简单地通过 .length 读取字符串的长度。其实也可以,只是结果可能出乎你的预料。如果你是程序员,可以在浏览器的控制台查看”🤦🏼♂️“.length (结果是7,译者注)。
程序员小贴士:如果需要处理文本,请务必使用支持字素集的库。如果是 C、C++和 JVM程序员,可以用ICU库, Swift 自带字素集支持。如果是其他语言,只能靠自己研究了。
字素集意识训练月,有人要参加吗?禁止截断字素集。我是在跟谁开玩笑吗?
另外,我有提过Ų̷̡̡̨̫͍̟̯̣͎͓̘̱̖̱̣͈͍̫͖̮̫̹̟̣͉̦̬̬͈͈͔͙͕̩̬̐̏̌̉́̾͑̒͌͊͗́̾̈̈́̆̅̉͌̋̇͆̚̚̚͠ͅ这个字符吗?这是一个字素群,它的长度是65,是不分割的整体。睡觉🛌 :)
肤色修饰符
大多数人形相关的 Emoji 都是黄色的。人们在2015年为 Emoji 引入肤色支持。当时没有为每种肤色的 Emoji 组合分配新的码点,而是引入了五个新码点 U+1F3FB-U+1F3FF。
肤色修饰符一般不单独使用,而是追加到现有的 Emoji 后面。它们一起组成一个合字 (ligature):👋(U+1F44B挥手)跟 🏽(U+1F3FD灰色)组合就变成了👋🏽。
👋🏽没有自己的码点(它的编码是U+1F44B U+1F3FD)但有自己的显示效果。通过这五个修饰符,~280 个人形 Emoji 就产生了 1680 种肤色变种。这是不肤色的舞蹈演员:🕺🕺🏻🕺🏼🕺🏽🕺🏾🕺🏿。
零宽度连接符
假设你的朋友发了一张苹果照片,是她在自家花园种的。你该如何回复呢?你可能会回复👩🌾。
但是,如果你在这两个 Emoji 之间加一个 U+200D 字符,它们就会变成👩🌾(女性农民)。
U+200D就是所谓的零宽度连接符(ZERO-WIDTH JOINER,简写为ZWJ)。它跟肤色修饰符有点类似,但是这次我们可以将两个单独的 Emoji 合成新的 Emoji。并非所有的 Emoji 组合会产生新 Emoji。但多数都可以,只是有些组合方式比较怪异。
下面是一些例子:
- 👩 + ✈️ → 👩✈️
- 👨 + 💻 → 👨💻
- 👰 + ♂️ → 👰♂️
- 🐻 + ❄️ → 🐻❄️
- 🏴 + ☠️ → 🏴☠️
- 🏳️ + 🌈 → 🏳️🌈
我还发现一处奇葩的地方:头发的颜色是用ZWJ实现的,但皮肤的颜色却不用ZWJ。求真相。
- 👨 + 🏿 U+1F3FF → 👨🏿
- 👨 + ZWJ + 🦰 → 👨🦰
可惜,有些 Emoji 不是通过 ZWJ 组全 Emoji 实现的。我猜它们是没赶上好时候:
- 👨 + 🦷 ≠ 🧛
- 👨 + 💀 ≠ 🧟
- 👩 + 🔍 ≠ 🕵️♀️
- 👁 + 👁 ≠ 👀
- 💄 + 👄 ≠ 💋
- 🌂 + 🌧 ≠ ☔️
- 🐴 + 🌈 ≠ 🦄
- 🍚 + 🐟 ≠ 🍣
- 🐈 + 🦓 ≠ 🐅
- 🦵 + 🦵 + 💪 + 💪 + 👂 + 👂 + 👃 + 👅 + 👀 + 🧠 ≠ 🧍
怎么输入ZWT呢?你不需要直接输入。你可以直接复制这个字符串“”。注意,它很特别,看不见,摸不着,但它就在那儿。
ZWJ的另一个用武之地是构造家庭和人际关系相关的 Emoji,下面是不完整的示例列表:
- 👨🏻 + 🤝 + 👨🏼 → 👨🏻🤝👨🏼
- 👨 + ❤️ + 👨 → 👨❤️👨
- 👨 + ❤️ + 💋 + 👨 → 👨❤️💋👨
- 👨 + 👨 + 👧 → 👨👨👧
- 👨 + 👨 + 👧 + 👧 → 👨👨👧👧
旗帜
Unicode 标准包含国旗标志。但 Windows 平台因为某些原因不支持显示。如果你是用 Windows 平台的浏览器阅读本文,我只能说抱歉了。
国旗也没有独立的码点。而是由双字符连字(ligature)来表示。
- 🇺 + 🇳 = 🇺🇳
- 🇷 + 🇺 = 🇷🇺
- 🇮 + 🇸 = 🇮🇸
- 🇿 + 🇦 = 🇿🇦
- 🇯 + 🇵 = 🇯🇵
这不是真正的字母,而是一组定义在U+1F1E6到U+1F1FF范围的地区标识符号。这些符号专门用于构造国旗标识。
如果你随机组合两个字符会怎样呢?什么也不会发生:🇽🇾(除非你的文本编辑功能有问题)。
这是完整的字母表:🇦 🇧 🇨 🇩 🇪 🇫 🇬 🇭 🇮 🇯 🇰 🇱 🇲 🇳 🇴 🇵 🇶 🇷 🇸 🇹 🇺 🇻 🇼 🇽 🇾 🇿。如果你想自己试试,可以随意复制并组合它们。一共有258种合法的组合。你能都找出来吗?
双字符连字有一个很有意思的副作用:’‘.join(reversed(’🇺🇦‘)) =>’🇦🇺’
标签序列
双字母连字确实很酷,但要不要更酷一些?来一个32字母连字怎么样?这个时候就需要标签序列了。
标签序列由三部分构成:开头是一个普通的 Emoji,之后是一串拉丁字母(U+E0020..E007E),最后是结束符(U+E0020..E007E CANCEL TAG)。
当前只有英格兰、苏格兰和威尔士的旗帜使用标签序列:
- 🏴 + gbeng + E007F = 🏴
- 🏴 + gbsct + E007F = 🏴
- 🏴 + gbwls + E007F = 🏴
键位符
键位符很简单,但为了内容完整,我们也提一下。键位符j使用另一套序列规则。
规则是这样的:数字、星号和井号,加 U+FE0F 变成 Emoji,再加上 U+20E3 变成带方框的键位符。
* + FE0F + 20E3 = *️⃣
总共只有12个:
#️⃣ *️⃣ 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣
Unicode 更新
Unicode 每年都会更新。每次更新的主要内容就是 Emoji。比如,2020年3月发布的 Unicode 13 就添加了55个 Emoji。
在我写作本文的时候,最新的 macOS(11.2.3)和 iOS(14.4.1)都还不支持 Unicode 13 的 Emoji,比如:
😮💨, ❤️🔥, 🧔♀ or 👨🏻❤️💋👨🏼
后来的读者可以比较一下,这是我在2021年3月看到的效果:
感谢ZWJ,我大概可以猜到这些 Emoji 的显示效果。
结论
总结一下,一共有七种 Emoji 造字法:
- 单个码点 🧛(U+1F9DB)
- 码点变体 ☹︎ (U+2639) + (U+FE0F) = ☹️
- 皮肤符号 🤵(U+1F935) + 🏽(U+1F3FD) = 🤵🏽
- 连接符号 👨 + ZWJ + 🏭 = 👨🏭
- 旗帜符号 🇦 + 🇱 = 🇦🇱
- 标签序列 🏴 + gbsct + (U+E007F) = 🏴
- 键位序列 * + (U+FE0F) + (U+20E3) = *️⃣
前四种方法也可以组合使用来构造非常复杂的 Emoji:
U+1F6B5 🚵 个人山地骑行
+ U+1F3FB 浅色皮肤
+ U+200D ZWJ
+ U+2640 ♀️女性标志
+ U+FE0F 变体标志
= 🚵🏻♀️ 浅色皮肤的女性山地骑行
如果你是程序员,牢记使用ICU库处理以下操作:
- 截取字符串
- 计算字符串长度
- 反转字符串
谷歌关键词是“Grapheme Cluster”。注意了,它适用于 Emoji、西方语言的变音字符,以及印度和朝鲜的文字。
我就知道这些了。希望理解 Emoji 原理能对你的工作带来帮助……哈哈,开玩笑了。希望 Emoji 能给你带来欢乐,这就够了✌️