字体与文本一致性
做设计编辑器最容易被吐槽的问题之一:"我在自己电脑上看好好的,怎么到别人那边 / 服务端导出一张图出来,字位就全乱了?"
字体是一个深坑。本文梳理 Ydesign 在处理文字 / 字体时容易遇到的一致性问题、我们推荐的正确姿势,以及出了问题之后的排查顺序。
问题长什么样?
典型症状:
- 🍎 Mac 上行高看着正常,在 Windows 上每一行多出 2 ~ 4 像素,段落整体下移
- 🅱️ "粗体"在 Chrome 上很粗,到 Safari 上细一半
- 🌐 浏览器里字位正确,走 云渲染 API 导出的 PNG 里字却下沉了几像素或换行位置不一样
- 🔤 用户上传了一个字体文件,加载后文字渲染出来的粗细与用户预期完全不同
绝大多数时候,根因有三类:
- 字体文件内部的元数据不规范(
usWeightClass不对、升部降部不对齐) - 同一字体族的多个字重 / 字形被错误地声明成了不同的 fontFamily(比如
FiraSans-Bold和FiraSans-Regular被当作两个字体) - 浏览器在找不到字体时偷偷合成了一个"伪粗体/伪斜体",各浏览器合成算法不一样
这三件事,无论是客户端 Fabric 渲染还是 Node 端 Fabric 云渲染,都会踩到。下面按"声明 → 质量 → 归一化 → 上传校验"顺序讲解。
正确声明字体:一个 fontFamily + 多个 variant
金科玉律:同一字体族的不同字重(Regular / Bold / Black)、不同字形(Normal / Italic)必须挂在同一个 fontFamily 下作为 styles,而不是用 FiraSans-Regular / FiraSans-Bold 这种"一款字重算一个字体族"的方式声明。
Ydesign 的 addGlobalFont API 原生支持这件事:
✅ 推荐:多文件合并到一个 fontFamily
import { addGlobalFont } from '@ydesign/react-editor';
addGlobalFont({
fontFamily: 'FiraSans',
styles: [
{
src: 'url(/fonts/FiraSans-Regular.ttf)',
fontStyle: 'normal',
fontWeight: 'normal', // 400
},
{
src: 'url(/fonts/FiraSans-Italic.ttf)',
fontStyle: 'italic',
fontWeight: 'normal',
},
{
src: 'url(/fonts/FiraSans-Bold.ttf)',
fontStyle: 'normal',
fontWeight: 'bold', // 700
},
{
src: 'url(/fonts/FiraSans-BoldItalic.ttf)',
fontStyle: 'italic',
fontWeight: 'bold',
},
],
});
这样做之后:
- 用户在工具栏里只看到一个 "FiraSans"
- 点"加粗"按钮 → 浏览器自动加载 Bold 文件,不是合成伪粗体
store.toJSON()中fontFamily: 'FiraSans'+fontWeight: 'bold',任何端(含云渲染)都能准确匹配到正确的 Bold 文件
❌ 错误:把每个字重当成独立字体族
// 不要这么做 —— 会引起一致性问题!
addGlobalFont({ fontFamily: 'FiraSans-Regular', url: '/fonts/FiraSans-Regular.ttf' });
addGlobalFont({ fontFamily: 'FiraSans-Bold', url: '/fonts/FiraSans-Bold.ttf' });
为什么错?
- 用户选了 "FiraSans-Regular" 后点"加粗",浏览器发现
FiraSans-Regular并没有 bold 变体 → 会合成一个伪粗体,跟真实的 Bold 文件效果不同 - 服务端云渲染拿到 JSON 后,看到
fontFamily: 'FiraSans-Regular'+fontWeight: 'bold',结果也是合成伪粗体,和客户端效果会对不上 - 如果用户从 Regular 切到 Bold,
store.toJSON()里字体族名会变,模板互相引用时容易丢失
可变字体(Variable Font)单文件
如果你用的是 .ttf / .woff2 可变字体(内含整个家族的所有字重),一个 addGlobalFont 声明 + 一个 url 字段就够:
addGlobalFont({
fontFamily: 'FiraSans',
url: '/fonts/FiraSans-VariableFont_wght.ttf',
});
浏览器会自行从文件中挑选合适的字重。
用户侧字体(会进入设计稿 JSON)
对于"只给某个用户"的字体,用 store.addFont:
store.addFont({
fontFamily: 'MyCustomSans',
url: 'https://your-cdn.com/fonts/my-custom-sans.woff2',
});
同样支持 styles 数组(签名与 addGlobalFont 一致)。这种字体会随 store.toJSON() 一起序列化,打开同一份设计稿的用户会自动加载相同字体。
字体文件自身的质量
即使声明方式完全正确,字体文件内部的元数据不过关,依然会让不同平台的渲染出现差异。检查三件事:
① usWeightClass 与声明的 fontWeight 要对上
字体文件在 OS/2 表里会写一个 usWeightClass(100 / 200 / … / 700 / … / 900)。CSS 的 fontWeight: bold 等价于 700。如果你的 "Bold" 文件 usWeightClass 写的是 500,浏览器就可能不认为它是 bold、转而合成一个伪粗。
怎么检查:
- 在线工具:Wakamai Fondue —— 拖进字体文件立刻看到所有元数据
- 命令行:
ttx MyFont.ttf→ 看OS_2.usWeightClass
② 垂直度量(升部 / 降部 / 行距)要在同一字体族内保持一致
Regular、Bold、Italic 如果升部(ascender)、降部(descender)、行距(line-gap)不一致,切换字重时行高会"跳"、多行文本的基线会偏移。这是 macOS / Windows 行高表现差异的最常见原因。
可以用 FontForge / fonttools 打开文件看 hhea 和 OS/2 表里的这三个字段,同一族内必须统一。
③ 命名表(name table)要对齐
fontFamily名字要与你代码里addGlobalFont({ fontFamily })声明的完全一致(区分大小写、下划线、空格)- 同一族的不同 style 文件,
Family Name应相同、Subfamily Name区分(Regular / Bold / Italic / Bold Italic)
④ 格式建议
- 现代浏览器 + 云渲染服务端:推荐
.woff2(压缩率高) - 兼容性兜底:
.ttf/.otf - 避免使用 EOT(仅 IE)、SVG 字体(已废弃)
什么时候需要"字体归一化"?
如果遇到以下任一情况,说明字体文件本身有问题,需要一次性修复:
- Mac 与 Windows 下同一段文字行高不同
- 字重切换时"跳"一下
- 云渲染 API 产出的图和客户端预览对不上
- 第三方 / 用户上传的字体表现怪异
常用的修复工具链:
- fonttools + gftools —— Python 生态,适合自动化批处理
- FontForge —— GUI + 脚本,适合一次性修复
- Transfonter —— 在线工具,快速测试 / 格式转换
一次"归一化"通常做这几件事:
- 把同一族所有 style 的
hhea.ascender/hhea.descender/hhea.lineGap统一 - 修正
OS/2.usWeightClass/usWidthClass - 规范化
nametable 里的 Family Name / Subfamily Name - 转成
.woff2精简体积
生产环境建议做一条字体上传归一化流水线,所有字体过一遍再入库 —— 比运行时一个个排查要省心。
用户上传字体时的校验
如果你给用户开放了"上传自己的字体"能力(内置文字面板里有"上传字体"入口),上传时就做校验,比用户设计一半发现字体挂了要友好得多。
客户端快速校验
// 伪代码:用 opentype.js 提前解析字体元数据
import opentype from 'opentype.js';
async function validateFontFile(file: File) {
const buffer = await file.arrayBuffer();
const font = opentype.parse(buffer);
const familyName = font.names.fontFamily.en;
const weight = font.tables.os2.usWeightClass;
const ascender = font.tables.hhea.ascender;
const descender = font.tables.hhea.descender;
// 校验 1:fontFamily 不能和已有的全局字体撞名
if (globalFonts.some(f => f.fontFamily === familyName)) {
return { ok: false, reason: `已存在同名字体 ${familyName}` };
}
// 校验 2:weight 合法
if (weight < 100 || weight > 900) {
return { ok: false, reason: `异常的字重值 ${weight}` };
}
// 校验 3:升部降部合理
if (ascender <= 0 || descender >= 0) {
return { ok: false, reason: '字体垂直度量异常' };
}
return { ok: true, familyName, weight };
}
服务端归一化(推荐)
客户端校验会漏掉很多细节(同族一致性、跨浏览器差异),服务端在入库时再跑一遍归一化是更稳的做法:
- 用户把文件传到你的后端(参考 Upload 面板 自定义
setUploadFunc) - 后端用
fonttools把文件"洗一遍"(统一度量、修正 weight class、转 woff2) - 归一化后的文件存到 OSS / CDN
- 把
{ fontFamily, url }写回业务库
处理加载失败
即便校验都过了,线上仍会偶发"字体加载超时"。通过 setFontLoadTimeoutCallback 给用户提示:
import {
setFontLoadTimeout,
setFontLoadTimeoutCallback,
} from '@ydesign/react-editor';
import { message } from 'antd';
setFontLoadTimeout(15_000); // 15s 超时
setFontLoadTimeoutCallback(msg => {
message.warning(`字体加载超时:${msg}`);
});
云端渲染场景的特别提醒
云渲染 API 的 Node 端无法访问用户本地字体,所以 JSON 里用到的所有非系统字体都必须通过请求体的 fonts 数组显式声明 URL:
fetch('https://api.ydesign.com/api/render/image', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer YOUR_API_KEY' },
body: JSON.stringify({
json: store.toJSON(),
format: 'jpeg',
fonts: [
// 每一个用到的 fontFamily + 实际的字体文件 URL
{
fontFamily: 'FiraSans',
url: 'https://your-cdn.com/fonts/FiraSans-Regular.ttf',
},
// ⚠️ 如果设计稿里用到了 Bold,也要把 Bold 文件单独列出来
{
fontFamily: 'FiraSans',
url: 'https://your-cdn.com/fonts/FiraSans-Bold.ttf',
},
],
}),
});
方便做法是直接把 store.fonts 序列化过去(前提是它们声明时用了 styles 数组,每一项都有自己的 URL):
fonts: store.fonts.flatMap(f =>
(f.styles || [{ src: f.url }]).map(s => ({
fontFamily: f.fontFamily,
url: s.src.replace(/^url\(|\)$/g, '').replace(/['"]/g, ''), // 从 "url('...')" 提取 URL
}))
)
排查顺序(遇到问题时按这个清单走)
当用户反馈"字体显示怪"时,按这个顺序排查,能定位 90% 的问题:
- 打开浏览器 DevTools → Network → Font,看字体文件是否 200 正常加载 + 没有 CORS 错误
- 用
isFontLoaded('MyFont')确认字体真的加载成功了(@ydesign/react-editor/utils/fonts) - 把字体文件拖到 Wakamai Fondue 检查
usWeightClass与你声明的是否一致 - 对比同族不同字重的
ascender/descender是否一致(用ttx或 FontForge) - 如果是云渲染问题,检查请求体
fonts数组是否包含了设计稿用到的所有字重 - 最后才考虑:是不是写错了
fontFamily名字 / 浏览器合成了伪粗体
延伸阅读
- 👉 编辑器配置 · 字体管理 ——
addGlobalFont/removeGlobalFont/replaceGlobalFonts等完整 API - 👉 Store API · 字体 ——
store.addFont/store.loadFont - 👉 工具函数 · 字体 ——
isFontLoaded/loadFont/injectCustomFont - 👉 云渲染 API —— 服务端渲染时的字体声明
- 👉 CORS 跨域 —— 字体文件需要的 CORS 头