问题背景

昨天在写为什么这么设计系列文章时,心血来潮在 <h1>, <h2>, <h3> 等标题标签中加上了色彩鲜明的 Emoji,增强标题区的对比度。

写作时——开两个窗口,边撰写边实时预览——一切都正常显示,但是一经发布,在电脑端的 Chrome 浏览器中就发现了个诡异现象,正文和目录中的表情显示正常,但是在标题中渲染成了一副傻大黑粗的模样🌑,五颜六色,惨遭剥离,独留黑白,茕茕孑立,形影相吊,不忍直视。


emoji_display_bug_in_heading_tags.jpg
图 1:标题标签中的 Emoji 表情显示异常

问题分析

优先排除数据库问题

首先,先排除存储的问题,既然预览时和在移动端正常显示,那么说明表情符号编码和落库存储没有问题,MySQL 在设计数据库时,在 database, table, column 即库、表、列的三个数据存储的颗粒度上,都已经设定了 UTF8MB4 编码。utf8mb4mb4 就是 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 就会渲染异常,实验过程用视频记录如下:



另外附上不同字重横向差异对比图,一目了然:


css_font_weight_diff_horizontal_s.jpg
图 2:不同字重下的横向直观对比


字体渲染规则

根据我们以上的实验,可以发现 600 (包含)以上的值,和 bold 是一样的,不禁让人好奇两者的对应关系是怎样的?

CSS3 字体参考标准

带着深挖背后字体渲染规则的好奇心,作者查询了 W3C 关于 CSS3 字体模块的规范标准,从中可窥知:


w3c_font_weight_definition_en.jpg
图 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 的匹配规则描述如下:


w3c_font_weight_matching_algorithm_en.jpg
图 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 以千万行计的源代码,才能从问题的根源上将其解决。

question_it_works_but_why_s.jpg
图 5: It works but WHY

本问题,仍有疑问,留待闲暇之余解决。

🔗 参考链接

  1. 在 dev.to,开发者关于 Emoji Bug 的讨论
  2. 京东零售前端博客,深入了解 font-weight 的文章

添加新评论