Chrome 中 Emoji 表情颜色缺失的渲染问题
问题背景
昨天在写为什么这么设计系列文章时,心血来潮在 <h1>
, <h2>
, <h3>
等标题标签中加上了色彩鲜明的 Emoji,增强标题区的对比度。
写作时——开两个窗口,边撰写边实时预览——一切都正常显示,但是一经发布,在电脑端的 Chrome 浏览器中就发现了个诡异现象,正文和目录中的表情显示正常,但是在标题中渲染成了一副傻大黑粗的模样🌑,五颜六色,惨遭剥离,独留黑白,茕茕孑立,形影相吊,不忍直视。
图 1:标题标签中的 Emoji 表情显示异常
问题分析
优先排除数据库问题
首先,先排除存储的问题,既然预览时和在移动端正常显示,那么说明表情符号编码和落库存储没有问题,MySQL 在设计数据库时,在 database
, table
, column
即库、表、列的三个数据存储的颗粒度上,都已经设定了 UTF8MB4
编码。utf8mb4
中 mb4
就是 most bytes 4
的意思,可以用来兼容四字节的 Unicode。
这里简要说下,为什么不用默认的
UTF8
。由于历史包袱原因,MySQL 中一开始设计的所谓UTF8
编码,徒有其名,其实每个字符最大只支持存储 3 个字节。而 emoji 表情符号编码后的字节范围在 3 - 4 字节之间,一旦超出 3 个字节,数据库就会报错,代号为 #1366。以蛋糕 shortcake 为例 (🍰):
Warning: #1300 Invalid utf8 character string: 'F09F8D'
Warning: #1366 Incorrect string value: '\xF0\x9F\x8D\xB0' for column 'Text' at row 1
DBMS 编码
SHOW VARIABLES
WHERE Variable_name LIKE 'character_set_%' OR Variable_name LIKE 'collation%';
+--------------------------+--------------------------------+
| Variable_name | Value |
+--------------------------+--------------------------------+
| character_set_client | utf8mb4 |
| character_set_connection | utf8mb4 |
| character_set_database | utf8mb4 |
| character_set_filesystem | binary |
| character_set_results | utf8mb4 |
| character_set_server | utf8mb4 |
| character_set_system | utf8mb3 |
| character_sets_dir | /usr/share/mysql-8.0/charsets/ |
| collation_connection | utf8mb4_0900_ai_ci |
| collation_database | utf8mb4_0900_ai_ci |
| collation_server | utf8mb4_0900_ai_ci |
+--------------------------+--------------------------------+
11 rows in set (0.04 sec)
Schema 编码
SELECT default_character_set_name, default_collation_name FROM information_schema.SCHEMATA
WHERE schema_name = "YOUR_SCHEMA";
+----------------------------+------------------------+
| DEFAULT_CHARACTER_SET_NAME | DEFAULT_COLLATION_NAME |
+----------------------------+------------------------+
| utf8mb4 | utf8mb4_general_ci |
+----------------------------+------------------------+
1 row in set (0.00 sec)
Table 编码
SELECT table_name, CCSA.character_set_name FROM information_schema.`TABLES` T,
information_schema.`COLLATION_CHARACTER_SET_APPLICABILITY` CCSA
WHERE CCSA.collation_name = T.table_collation
AND T.table_schema = "YOUR_SCHEMA";
+-----------------------+--------------------+
| TABLE_NAME | CHARACTER_SET_NAME |
+-----------------------+--------------------+
| blogger_comments | utf8mb4 |
| blogger_contents | utf8mb4 |
| blogger_fields | utf8mb4 |
| blogger_metas | utf8mb4 |
| blogger_options | utf8mb4 |
| blogger_relationships | utf8mb4 |
| blogger_users | utf8mb4 |
| blogger_plugins | utf8mb4 |
+-----------------------+--------------------+
8 rows in set (0.00 sec)
Column 编码
SELECT column_name, character_set_name FROM information_schema.`COLUMNS`
WHERE table_schema = "YOUR_SCHEMA"
AND table_name = "YOUR_TABLE";
+--------------+--------------------+
| COLUMN_NAME | CHARACTER_SET_NAME |
+--------------+--------------------+
| id | NULL |
| title | utf8mb4 |
| link | utf8mb4 |
| createdAt | NULL |
| modifiedAt | NULL |
| text | utf8mb4 |
| order | NULL |
| authorId | NULL |
| template | utf8mb4 |
| type | utf8mb4 |
| status | utf8mb4 |
| passwd | utf8mb4 |
| commentsNum | NULL |
| allowComment | utf8mb4 |
| allowView | utf8mb4 |
| allowFeed | utf8mb4 |
| parent | NULL |
| views | NULL |
+--------------+--------------------+
18 rows in set (0.00 sec)
排除系统原因
- [x] iOS: 作者手头上 iPhone 和 iPad 新旧版各有两台,浏览页面均没有显示问题
- [x] Android: 借用女朋友的小米手机自带浏览器浏览,无异常
- [v] Windows: 只有 Chrome 出问题,Firefox 没有渲染问题
- [x] Linux: Chrome, Firefox 均无此问题,可显示表情图标
排除字体原因
排除系统后,考虑到不同系统自带的不一样,开始怀疑是不是 H1, h2 这些标题标签所用字体的问题,作者尝试修改 font-family
,改为 Windows 自带的 microsoft yahei,sans-serif
, 未起作用。再和正文使用的字体对比,发现它的两者是一样的。
定位实际问题
在查看标题所用的字体时,顺带排查 CSS 继承的关系,有了意外收获,标题和正文的字体虽然是相同的,但是它们的字重(字体粗细度)和字大小(字体尺寸)不一样,会不会是这两个字段影响到了 Chrome 渲染步骤呢?
调整字重实验
采取控制变量法做实验,发现与字大小无关,当字重 font-weight
>= 600 就会渲染异常,实验过程用视频记录如下:
另外附上不同字重横向差异对比图,一目了然:
图 2:不同字重下的横向直观对比
字体渲染规则
根据我们以上的实验,可以发现 600 (包含)以上的值,和 bold
是一样的,不禁让人好奇两者的对应关系是怎样的?
CSS3 字体参考标准
带着深挖背后字体渲染规则的好奇心,作者查询了 W3C 关于 CSS3 字体模块的规范标准,从中可窥知:
图 3:字体模块的规范标准
font-weight
的有效取值范围,分为两类表示法
- 字符串:normal(初始值)、bold、bolder、lighter。
- 正整数:[100, 900]
数值 <==> 文本
常见的字重数值和字重描述文本的大致对应关系如下:
数值 | 文本 |
---|---|
100 | Thin |
200 | Extra Light (Ultra Light) |
300 | Light |
400 | Regular (Normal、Book、Roman) |
500 | Medium |
600 | Semi Bold (Demi Bold) |
700 | Bold |
800 | Extra Bold (Ultra Bold) |
900 | Black (Heavy) |
为什么说是大致对应呢,这些表示在具体实现中并非完全参照标准,
在不同字库下是有差异的,比如在 Adobe Fonts 字库文档中,字重描述部分的对应值列表,它列出 Heavy 指的是 800 而不是 900。
此外,在我们日常使用的设计工具如 Photoshop 和 Sketch 里面,Ultra Light 是 100,而 Thin 是 200。
事实上,字体所拥有的字重的数量通常很少,基本没有刚好能与 100 ~ 900 的 9 个 CSS 字重一一对应的情况。一般字体拥有的字重数量为 4 至 6 个。 保底的,每种字体至少有 400 和 700 对应的字重,譬如常见的 Arial、Helvetica、Georgia 等等,仅有 400(normal) 和 700(bold) 两个字重。
字体字重匹配算法
在上面我们已经提到,在很多情况下,字体并不是以九段数值来划分的,并且其含有的字重数量是不一。
此时,便会出现样式指定的字重数值在字体中找不到直接对应的字重,那浏览器是如何解决的呢?
噹噹噹,那就要靠字体匹配算法来解决。其中 font-weight 的匹配规则描述如下:
图 4: font-weight 的匹配规则
翻译过来就是说:
- 直接匹配:如果所需的字重是可用的,那么该字体就会被匹配;否则,就用下面的规则选择一个字重。
- 近似匹配:
- 如果期望的字重小于 400,则先按降序检查低于期望字重的字重;如未满足,再按升序检查高于期望字重的字重,直到找到一个匹配的字重。
- 如果期望的字重大于 500,则先按升序检查高于期望字重的字重;如未满足,再按降序检查低于期望字重的字重,直到找到一个匹配的字重。
- 如果期望的字重是 400,首先检查 500;如未满足,再使用期望字重小于 400 的规则。
- 如果期望的字重是 500,首先检查 400;如未满足,再使用期望字重小于 400 的规则。
总之,如果指定的font-weight数值,即所需的字重,能够在字体中找到对应的字重,那么就匹配为该对应的字重。否则,使用上面的近似规则来查找所需的字重并渲染。
总结
我们从奇怪的 Emoji 显示问题出发,从字符编码,到数据库存储层面,再到字体的字重数值和文本表示,最后介绍了字体的字重匹配算法。结合我们的实验可知,400 / 500 都匹配到了 400(normal),550 / 600 则是向上找匹配的字重,找到了 700(bold)。若所指定的字重不存在,无法直接匹配,则会通过字体匹配算法规则去匹配邻近的可用字重。这就是为什么我们有时候使用特定字重时,看起来跟其它字重差不多的原因所在,这在之前给我们一种设置没有『生效』的错觉,今天终于把来龙去脉梳理清楚了。
但是 Chrome 在处理数值和文本所表示的相同字重,为何呈现出截然不同的差异,截至作者落笔时,还未调查明白。现在的方案虽然可行,但是却不知道它为什么可行。或许我们需要构建并调试 Chromium Project 以千万行计的源代码,才能从问题的根源上将其解决。
图 5: It works but WHY
本问题,仍有疑问,留待闲暇之余解决。