问题初现
前几天和朋友聊到蔚来汽车,想打开蔚来的官网 https://www.nio.cn/ 看看,可是在我电脑上访问后,网页上却显示出了非常可爱的,与整体风格不搭的字体。

我们知道,汽车、科技类企业的官网通常会使用无衬线字体与简洁的网页布局搭配,体现科技感与设计感。但为什么蔚来官网会用这样一款可爱的字体呢?
最开始以为是电脑访问的网页被劫持了,于是换成手机使用流量访问,果然,显示成了无衬线字体。
那是手机缺少字体,才导致没变成可爱字体吗,这是说这是一种 design 或是网页设计的失误?
于是,通过电脑浏览器的 F12 开发人员工具,查看显示成可爱字体部分具体是什么字体:
font-family: BlueSkyStandard,Helvetica Neue,Helvetica,Arial,PingFang SC,Hiragino Sans GB,Heiti SC,Microsoft YaHei,WenQuanYi Micro Hei,sans-serif
font-family 中指定了很多字体,但这些字体看起来都很正常,不是什么可爱风格的,可以进一步使用“已计算” tab 来查看浏览器具体选中了哪一个字体来显示:

从 Family name: Heiti SC 可以看出,这里使用的是名为“Heiti SC”的字体,确实是 font-family 里指定的一款字体。
从名字来看,这是一款简体中文的黑体字体,网上搜索后也印证了这一点。这个普通的黑体字,到了蔚来官网上,为什么就变成了可爱风格的字体呢?
于是我在 Windows 的 C:\Windows\Fonts 中,以及“设置 - 个性化 - 字体”中搜索 Heiti SC 和 Heiti,均没有收获。
事情越来越诡异了,电脑里没有这款字体,浏览器却能加载,并且显示成和名字不匹配的字体?
误打误撞:Local Font Access API
回想起我们在 css 中设置 font-family 时,通常会使用字体的“英文名称”,例如 Microsoft YaHei,但实际上设置成中文 微软雅黑,浏览器也是可以正常渲染字体的:

我意识到,可能浏览器能够从某处获取到某种映射关系,把 Microsoft YaHei 和 微软雅黑 映射到同一个字体上。那在 Windows 字体中搜索 Microsoft YaHei 能搜到吗:

答案是不能,输入 Microsoft YaHei 搜索到的是 “Microsoft YaHei UI”字体,而输入微软雅黑 才能搜索到“微软雅黑”字体。
情况越发扑朔迷离,我就想,JavaScript 中有没有什么 API 可以获取到系统中的字体呢?
果然,目前 Chromium 内核的浏览器支持一个实验性的 API Local Font Access API,通过调用 window.queryLocalFonts() 方法,在经过授权后,可以访问到本地安装的字体!浏览器对于 JavaScript 访问本地的内容一直都是非常保守的,没想到提供了这样的 API。
在 MDN 文档中指出,调用 window.queryLocalFonts() 后,会返回一个 FontData 的集合,其中包含 family 属性,那我就用它来过滤一下,在 F12 控制台中执行:
const heitiScFont = (await window.queryLocalFonts()).find(f => f.family === 'Heiti SC')
然后进行授权,点击“允许”即可:

然后查看 heitiScFont 的值,果然有这么一个字体:

{
"family": "Heiti SC",
"fullName": "萝莉体 第二版",
"postscriptName": "STHeitiSC-Light",
"style": "Light"
}
它的 fullName 出现了一个中文的名称 “萝莉体 第二版”,但我毫无安装过这个字体的印象,用 萝莉体 到 Windows 字体中搜索,没想到真能搜到:

回想了一下,可能是以前安装了某些字幕组提供的字体包导致这么一个字体混进来了。删除字体后,重启浏览器,蔚来官网终于恢复了正常。
Note
这里演示的 fullName 使用方式,并不适用于所有情况,后文会继续探讨。
接近真相:Naming Table
事情到这里已经结束了,但我的疑问没有得到解决:操作系统或者浏览器中,真的有这样一个映射关系,把 Microsoft YaHei 和 微软雅黑(对应上面的例子则是 Heiti SC 和 萝莉体 第二版)映射到同一个字体上吗,在哪里能够查看到它?
然后经过一番与 ChatGPT 和 DeepSeek 的交流,得知 Open Type 字体中包含了一个 Naming Table,其中定义了 Font Family、Preferred Family 等与字体名称相关的字段,并且 Naming Table 是支持多语言的,每个字段都可以包含不同语言的版本。
以“微软雅黑”字体为例,在 Naming Table 中定义了英文的 Font Family 为 Microsoft YaHei,以及中文的 Font Family 为 微软雅黑,因此在 CSS 中,使用 font-family: 'Microsoft YaHei' 和 font-family: '微软雅黑' 都可以正确显示字体。
我们可以通过一款开源的字体编辑工具 FontForge 来查看到这些关系。
为了进行试验,首先需要把“微软雅黑”字体复制出来,访问 C:\Windows\Fonts 并在右上角的搜索框中输入“微软雅黑”(如果系统显示语言不是简体中文,可能需要用 Microsoft YaHei 才能搜到),选中并复制:
将其粘贴到一个临时文件夹,例如 C:\Temp 中,你会发现复制出来的字体 msyh.ttc 并不是常规的 ttf 格式,而是 ttc 格式,这是因为 ttc 是一个字体集合格式,这个文件里包含了“微软雅黑"和“Microsoft YaHei UI” 两个字体。通过 FontForge 打开 msyh.ttc,它会询问具体打开哪个字体:

选择“Microsoft YaHei”后,依次点击 “Element - Font Info - TTF Names” 就能够查看 Naming Table 的内容:

第一列是 Language 语言;第二列 String ID 对应 Naming Table 的 Name IDs,只不过 FontForge 显示的不是数字 id,而是它具体的含义;第三列就是它的值。
例如我们在这里要关注的是 Family(即 Name Id 为 1 的 “Font Family”),很明显 Chinese (PRC) 时,它的 family 是 微软雅黑,而到了 English (US) 中,family 就成了 Microsoft YaHei。
这样说明了为什么在中文 Windows 下,搜索 Microsoft YaHei 会没有结果;想必是微软直接是按照操作系统的显示语言来匹配 Naming Table 的。将系统切换到英文进行搜索,确实印证了这一点:

然后看看我遇到问题的这个“萝莉体”的 TTF Names。将其复制出来后发现,它也是个字体集合,包含了多种字体(但这几种字体我看不出什么区别):

那么我遇到的问题,估计是“萝莉体”的作者,是直接在真正的 “Heiti SC” 基础上修改的,但是 Naming Table 没有改全,漏改了英文的字体名称。
因为从 Copyright 来看,这个 Changzhou SinoType Technology Co., Ltd. 实际上是有名的 “华文字库”,另外 Heiti SC 也是“华文黑体”的名称。
还有高手:Preferred Family 和 Fullname
在得知 Naming Table 的设计后,我第一反应是去找一个开源字体搜索一下,看看有没有对应的东西。虽然开源字体有很多,但脑海中跳出来的第一个是 得意黑 字体。果然,在 src/SmileySans.ttx 中找到了 Naming Table 的定义。
用 FontForge 打开得意黑字体后,我注意到,除了常规的 Family 外,还出现了一个名为 Preferred Family 的字段:

在 Name IDs 中找到 Preferred Family 是 Id 为 16 的 “Typographic Family name” 的别称;并且还有一个额外的规则:只有在没有 Preferred Family 字段时,才使用 Font Family。
为了进行测试,我在本地修改了得意黑字体,新增了日语的 Family:得意黑JP 和 Preferred Family:得意黑JP PF,安装字体并重启浏览器进行测试:

看来确实如此,使用 Preferred Family 作为 font-family 的值时,浏览器能够正确渲染字体。
得意黑字体还内置了一个 Fullname 字段,它对应的 Name Id 是 4,这让 window.queryLocalFonts() 返回的数据又产生了变化:
{
"family": "Smiley Sans",
"fullName": "得意黑 斜体",
"postscriptName": "SmileySans-Oblique",
"style": "Oblique"
}
根据文档的解释,Fullname 通常是 Id 1(Font family) 和 Id 2(Font Subfamily,即 Font style 字体样式,如“粗体”、“斜体”等) 的组合,或 Id 16(Preferred Family) 和 Id 17(Preferred Subfamily) 的组合,而得意黑的情况属于后者。
因此如果 window.queryLocalFonts() 返回的内容无法在 Windows 字体中搜索到,可以考虑截取第一个空格之前的内容进行搜索,因为它有可能是 Preferred Family 或 Font family。
自己动手:DirectWrite API
我没有接触过 Chromium 的代码,也不知道它是怎么工作的,其实一开始我是希望 ChatGPT 和 DeepSeek 能帮我找到 Chromium 中,负责处理 font-family 部分的代码。不过 AI 们领着我在 Chromium 和 Skia (Chromium 使用的 2D 图形绘制引擎)代码中一顿游走,最后只能勉强了解到 Skia (在 Windows 中)使用的是 Win32 DirectWrite API。
看着 C++ 代码头皮发麻,虽然无法真正确认或调试 Chromium,但我可以用 C# 来调用一下 DirectWrite API,模拟 css 中使用 font-family 的过程,核心是通过 idWriteFactory::GetSystemFontCollection 方法获取所有已安装的字体,和 IDWriteFontCollection::FindFamilyName 根据 family name 获取字体。
不过 C# 没法直接访问 DirectWrite API,可以通过安装 Vortice.Direct2D1 nuget 包轻松实现:
using Vortice.DirectWrite;
using var factory = DWrite.DWriteCreateFactory<IDWriteFactory>();
var fontCollection = factory.GetSystemFontCollection(false);
foreach (var fontName in new string[] {"Microsoft YaHei", "微软雅黑", "Heiti SC", "萝莉体 第二版", "得意黑", "得意黑JP", "得意黑JP PF"})
{
var isExists = fontCollection.FindFamilyName(fontName, out var index);
Console.WriteLine($"[Name: {fontName}, is exists: {isExists}]");
if (isExists)
{
var font = fontCollection.GetFontFamily(index);
var names = font.FamilyNames;
for (uint nameIndex = 0; nameIndex < names.Count; nameIndex++)
{
Console.WriteLine($"{names.GetLocaleName(nameIndex)} | {names.GetString(nameIndex)}");
}
}
Console.WriteLine(new string('-', Console.WindowWidth));
}
输出的结果如下,确实比较贴近 font-family 的效果了:
[Name: Microsoft YaHei, is exists: True]
en-us | Microsoft YaHei
zh-cn | 微软雅黑
------------------------------------------------
[Name: 微软雅黑, is exists: True]
en-us | Microsoft YaHei
zh-cn | 微软雅黑
------------------------------------------------
[Name: Heiti SC, is exists: True]
en-us | Heiti SC
zh-cn | 萝莉体 第二版
------------------------------------------------
[Name: 萝莉体 第二版, is exists: True]
en-us | Heiti SC
zh-cn | 萝莉体 第二版
------------------------------------------------
[Name: 得意黑, is exists: True]
en-us | Smiley Sans
ja-jp | 得意黑JP PF
zh-cn | 得意黑
------------------------------------------------
[Name: 得意黑JP, is exists: False]
------------------------------------------------
[Name: 得意黑JP PF, is exists: True]
en-us | Smiley Sans
ja-jp | 得意黑JP PF
zh-cn | 得意黑
------------------------------------------------
总结
没想到一个无意间乱装的字体,无意间访问的网页,引发了这么多的思考和学习,也解开了我多年以来的一个疑问:正因为有 Naming Table 的机制,font-family 才能做到不论写中文还是英文都能正确的找到字体。
目前整理出来遇到类似问题的处理方案:
- 找一个基于 Chromium 的浏览器(Microsoft Edge、Chrome、Opera 等),通过 Local Font Access API
window.queryLocalFonts()找到对应字体的fullName - 如果
fullName能够在 Windows 字体中找到,那就可以对其进行删除 - 如果找不到,并且
fullName包含空格,截取第一个空格前的内容查找 - 如果还是找不到,用浏览器 F12 找到“已计算” tab 中的 family name
- 非编程的方案:将 Windows 切换成英文版,在 Windows 字体中查找 family name
- 编程方案:不用切换系统显示语言,使用代码调用 Win32 API
GetSystemFontCollection查找 family name
