跳到主要内容

字体与文本一致性

做设计编辑器最容易被吐槽的问题之一:"我在自己电脑上看好好的,怎么到别人那边 / 服务端导出一张图出来,字位就全乱了?"

字体是一个深坑。本文梳理 Ydesign 在处理文字 / 字体时容易遇到的一致性问题、我们推荐的正确姿势,以及出了问题之后的排查顺序。


问题长什么样?

典型症状:

  • 🍎 Mac 上行高看着正常,在 Windows 上每一行多出 2 ~ 4 像素,段落整体下移
  • 🅱️ "粗体"在 Chrome 上很粗,到 Safari 上细一半
  • 🌐 浏览器里字位正确,走 云渲染 API 导出的 PNG 里字却下沉了几像素换行位置不一样
  • 🔤 用户上传了一个字体文件,加载后文字渲染出来的粗细与用户预期完全不同

绝大多数时候,根因有三类:

  1. 字体文件内部的元数据不规范usWeightClass 不对、升部降部不对齐)
  2. 同一字体族的多个字重 / 字形被错误地声明成了不同的 fontFamily(比如 FiraSans-BoldFiraSans-Regular 被当作两个字体)
  3. 浏览器在找不到字体时偷偷合成了一个"伪粗体/伪斜体",各浏览器合成算法不一样

这三件事,无论是客户端 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 打开文件看 hheaOS/2 表里的这三个字段,同一族内必须统一。

③ 命名表(name table)要对齐

  • fontFamily 名字要与你代码里 addGlobalFont({ fontFamily }) 声明的完全一致(区分大小写、下划线、空格)
  • 同一族的不同 style 文件,Family Name 应相同、Subfamily Name 区分(Regular / Bold / Italic / Bold Italic)

④ 格式建议

  • 现代浏览器 + 云渲染服务端:推荐 .woff2(压缩率高)
  • 兼容性兜底.ttf / .otf
  • 避免使用 EOT(仅 IE)、SVG 字体(已废弃)

什么时候需要"字体归一化"?

如果遇到以下任一情况,说明字体文件本身有问题,需要一次性修复

  1. Mac 与 Windows 下同一段文字行高不同
  2. 字重切换时"跳"一下
  3. 云渲染 API 产出的图和客户端预览对不上
  4. 第三方 / 用户上传的字体表现怪异

常用的修复工具链:

  • fonttools + gftools —— Python 生态,适合自动化批处理
  • FontForge —— GUI + 脚本,适合一次性修复
  • Transfonter —— 在线工具,快速测试 / 格式转换

一次"归一化"通常做这几件事:

  1. 把同一族所有 style 的 hhea.ascender / hhea.descender / hhea.lineGap 统一
  2. 修正 OS/2.usWeightClass / usWidthClass
  3. 规范化 name table 里的 Family Name / Subfamily Name
  4. 转成 .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 };
}

服务端归一化(推荐)

客户端校验会漏掉很多细节(同族一致性、跨浏览器差异),服务端在入库时再跑一遍归一化是更稳的做法:

  1. 用户把文件传到你的后端(参考 Upload 面板 自定义 setUploadFunc
  2. 后端用 fonttools 把文件"洗一遍"(统一度量、修正 weight class、转 woff2)
  3. 归一化后的文件存到 OSS / CDN
  4. { 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% 的问题:

  1. 打开浏览器 DevTools → Network → Font,看字体文件是否 200 正常加载 + 没有 CORS 错误
  2. isFontLoaded('MyFont') 确认字体真的加载成功了(@ydesign/react-editor/utils/fonts
  3. 把字体文件拖到 Wakamai Fondue 检查 usWeightClass 与你声明的是否一致
  4. 对比同族不同字重的 ascender / descender 是否一致(用 ttx 或 FontForge)
  5. 如果是云渲染问题,检查请求体 fonts 数组是否包含了设计稿用到的所有字重
  6. 最后才考虑:是不是写错了 fontFamily 名字 / 浏览器合成了伪粗体

延伸阅读