PreFound:10A - 文本之外,还有(绘)文字

/ dousha99

人类发明这种东西真的不是为了折磨自己么?

第 10 篇中,我们实现了一个打字机效果。但是其中有一个部分我只能很匆匆地略过,就是 Emoji 那一部分。这是因为 Emoji 这玩意真的是一种绝对的痛苦。

怎么把 Emoji 还原成可键入的内容?

当时给出的答案是使用 GitHub 短码。我的实际实现也确实是使用 GitHub 提供的短码。GitHub 自己维护了一份它所支持的全部短码对应 Emoji 的列表文件:

https://api.github.com/emojis

当然,下载下来它是这个样式的:

{
  "+1": "https://github.githubassets.com/images/icons/emoji/unicode/1f44d.png?v8",
  "-1": "https://github.githubassets.com/images/icons/emoji/unicode/1f44e.png?v8",
  "100": "https://github.githubassets.com/images/icons/emoji/unicode/1f4af.png?v8",
  "1234": "https://github.githubassets.com/images/icons/emoji/unicode/1f522.png?v8",
  /* ... */
}

好吧,虽然但是,我们还是可以从 URL 里面提取出位点信息的,对么?毕竟一个 Emoji 就对应一个位点嘛。

不,一个 Emoji 可以是好几个位点

首先就是"💩".length === 2。之前已经受过文本编码毒打的读者可能会意识到:因为 Emoji 大多都是 U+10000 以上的位点,而许多系统默认内部是采用 UTF-16 编码的,所以这些字符会被拆成两个 Surrogate Pair.

没关系,我们可以写自己的 UTF-8 解析工具直接上 UCS-4. 对于我们来说,每个字符都是 32 位的,所以就不会用到(希望不会用到)UTF-16 中会出现的扩展支持。这样就可以做到 {"💩"}.size() == 1.

但是有些 Emoji 它确实就不是单一位点的,比如 ❤️ 这玩意,它实际上的定义是 U+2764 U+FE0F. 更痛苦的是:U+2764 可以是一个独立字符,这个时候它是 . 取决于你在什么设备上来看这个玩意以及你的浏览器的默认设置是什么,你可能看到一个黑色的实心心形字符;或者一颗红心 Emoji! 之所以有这个问题,是因为有些位点是从 Dingbat 位点借调过来的,而后面追加的 VS15 表示「明确使用 Emoji 版本」。

当然,还有各种旗子。国旗这种玩意起手就是两个位点。Unicode 委员会当初为了不给自己惹太多麻烦,特意没有规定所有的旗子,而是决定划分一块位点 U+1F1E6 -- U+1F1FF 编码了字母 A-Z 然后说:按 CLDR 里的两字编码可以表示任何国家或者地区,无论它过去现在未来是否存在。至于任意两字组合渲染出来应该是啥样,这事由字体决定。

那,一个 Emoji 可以是一个或者两个位点。没关系,最多浪费一点 RAM 嘛,视频渲染不缺这几个字节的。

不不不,一个 Emoji 可以是好几好几个位点

Emoji 是可以通过 ZWJ 字符进行扩展的,而且能扩展多少其实并不一定。比如 👪 这个玩意,它的内部表示是 U+1F9D1 U+200D U+1F9D1 U+200D U+1F9D2. 以及,ZWJ 后面还能跟带 Variant Selector 的,比如 🏃‍➡️ 就是 U+1F3C3 U+200D U+27A1 U+FE0F.

但是又不是所有的 Emoji 都是通过 ZWJ 扩展的。比如当你打出 👍🏻 的时候,这个玩意是可能携带一个肤色信息的,但肤色信息和 Emoji 之间没有任何字符。然后这个东西还能和 ZWJ 混到一起,比如 🏃🏿‍➡️ 就是 U+1F3C3 U+1F3FF U+200D U+27A1 U+FE0F.

不不不不不,我上面举的例子都是「一个」 Emoji

如果你在不支持指定 Emoji 方向的设备上读上面这段文字的话,你可能会纳闷为啥我在对着好几个字符的 Emoji 大呼小叫。这些不都是单独的 Emoji 么?比如 🏃➡️ 不应该是两个 Emoji 么?

答案是:Emoji 的 ZWJ 序列是可以出现退化情况的。退化的 ZWJ 序列看起来就像是多个 Emoji 依次打出来。这种退化受到字体和平台的影响,在 macOS 下看起来是一个字符的玩意,在 Windows 下看起来就是另一个样子。

以及不仅仅是 ZWJ 序列可以退化,所有多 Emoji 的序列都可以退化,比如肤色选择就可以退化成 😀​🏼. 在一个完全不支持多 Emoji 序列的环境下,上面的 U+1F3C3 U+1F3FF U+200D U+27A1 U+FE0F 会被渲染成三个独立的 Emoji: 🏃​🏿➡️. 而且这个行为是符合要求的。

在 SDL 中渲染 Emoji 更是重量级:很多字体是没法完整地渲染所有 Emoji 的,要正常使用 Emoji 的话,需要设置 Noto Color Emoji主字体以便 SDL_ttf 能够优选 Noto Color Emoji 中的字形,然后把正文字体设置为备用字体才行。

怎么界定一个 Emoji 的边界?

Unicode 联盟发表了《UTS #51:Unicode Emoji》来指导我们如何处理这种怪问题。

好在 Unicode 定义了完整的 Emoji EBNF 表达式,而且它看起来是正则的,这至少可以让我们在有限时间内界定一坨位点中怎么划分出若干个 Emoji. 它能够识别完整的 ZWJ 序列以及各种夹杂着怪东西的序列,而且也并不关心平台是否会单独把每个小部件拆开渲染。

但是 GayHub 不在意

如果仔细观察 GitHub 提供的结果的话,会发现它的多位点 Emoji 里面省略了 ZWJ 和 VS15 两个字符;比如 :heart: 对应的 URL 就是 2764.png 而不是 2764-fe0f。所以,当我们去查表的时候,我们还得把这俩字符给滤掉才能查到正确的短码。

处理非法序列

坠痛苦的,可能还得是这件事了。和其他语言文字不同,Emoji 是可以整出字面意义上的非法序列的。毕竟 ZWJ 序列还是太超模了。我能想到的比这玩意更超模的东西是 IDS 序列,但赖好那玩意不需要上屏不是 (!).

非法序列本身自然是没有对应的 GitHub 短码可用的,而且就算是那些合法的序列也不见得有对应的 GitHub 短码。所以我们就得自己想办法。目前想的办法是不处理:无法被转换为合法的字形会直接作为单独的无需提交的 KeystrokeSequence 打出。当然,更正经的处理方案是为每个存在的 Unicode 位点都设计一个短码,但这个事情实在是太痛苦了(而且按现在的实现还会很低效),目前暂时没有太好的动力去实现。

结论:别在你的视频里用 Emoji

说实话一开始我觉得中文的那坨子处理应该是挺难的,相对而言 Emoji 应该是很容易处理的。结果发现 Emoji 这玩意的水真的很深。

为了所有人的身心健康,还是远离这玩意吧。

后日谈:这篇文章还炸掉了我的 11ty 渲染管线

打开 RSS 流自我陶醉的时候,发现所有包裹在内联代码段里的 Emoji 全变成了 U+FFFD. 麻中麻啊!

先怀疑是 minify 过程把 HTML 实体重写了,关掉 minify 不管用;然后是怀疑内联高亮是不是有什么问题,但是 RSS 流里面是不跑内联高亮脚本的,一样有问题。

最后发现死还是得自己作。之前为了处理内联代码中可能会出现 HTML 字符的问题,以及为了给内联代码套上高亮,写了一个简单的变换函数:

md.renderer.rules.code_inline = (tokens, idx) => {
  const token = tokens[idx]
  const text = token.content.replace(
    /[^0-9A-Za-z ]/g,
    (c) => '&#' + c.charCodeAt(0) + ';'
  )

  return `<code class="prettyprint">${text}</code>`
}

但是正如同我们刚开头所说,经历过文本编码毒打的人都会意识到 NodeJS 里面,Emoji 可不能 charCodeAt(0) ——这会拿到一个 Surrogate Pair, 相当于是把字符拦腰斩断了。这就导致了编码出的实体是不合法的。不合法的 HTML 实体在 minify 的过程中会被替换成 U+FFFD, 最终渲染出来就是带问号的菱形框。

修的话,还是引入一个外部包来处理 HTML 实体编码的问题吧。导入 html-entities 包,简单改一下:

import { encode } from 'html-entities'

md.renderer.rules.code_inline = (tokens, idx) => {
  const token = tokens[idx]
  const text = encode(token.content)
  return `<code class="prettyprint">${text}</code>`
}

只能说,有条件的尽量远离 Emoji, 这个东西是真的难对付。

正在加载评论……

发表评论

您的评论将由管理员审核后方可公开显示。

Your comments will be submitted to a human moderator and will only be shown publicly after approval.