Fonts & text consistency
One of the most common complaints with a design editor: "It looks fine on my laptop — why does the text layout break on someone else's browser or the server-side export?"
Fonts are a deep pit. This page walks through the consistency problems Ydesign commonly runs into with text and fonts, the recommended patterns, and a checklist to debug issues when they come up.
What does "the problem" look like?
Typical symptoms:
- 🍎 Line height looks fine on macOS, but on Windows the same paragraph is pushed down by 2–4 pixels per line
- 🅱️ "Bold" looks thick in Chrome but thin in Safari
- 🌐 The browser preview looks right, but the PNG exported by Cloud Render API has its text shifted down or wrapping at a different position
- 🔤 A user uploads a custom font and the rendered weight is completely different from what they expected
In almost every case, the root cause is one of three:
- Bad / inconsistent metadata inside the font file (wrong
usWeightClass, mismatched vertical metrics) - Different weights / styles of the same family incorrectly registered as separate font families (e.g.
FiraSans-BoldvsFiraSans-Regulartreated as two families) - Browsers silently synthesize a fake bold/italic when the real variant can't be found — the synthesis algorithm differs per browser
All three apply whether you render client-side with Fabric or server-side through Fabric on Node. Let's break them down.
Declare fonts correctly: one fontFamily, many variants
The golden rule: different weights (Regular / Bold / Black) and styles (Normal / Italic) of the same family must be declared under the same fontFamily as multiple styles, not as separate font families.
Ydesign's addGlobalFont supports this natively:
✅ Recommended: merge multiple files under one 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',
},
],
});
With this setup:
- The user sees one "FiraSans" in the toolbar
- Clicking "Bold" makes the browser load the Bold file — no synthetic fake-bold
store.toJSON()emitsfontFamily: 'FiraSans'+fontWeight: 'bold', which any consumer (including Cloud Render) can match to the correct Bold file
❌ Wrong: treat each weight as a separate family
// DON'T — causes consistency problems
addGlobalFont({ fontFamily: 'FiraSans-Regular', url: '/fonts/FiraSans-Regular.ttf' });
addGlobalFont({ fontFamily: 'FiraSans-Bold', url: '/fonts/FiraSans-Bold.ttf' });
Why is this wrong?
- The user picks "FiraSans-Regular" and clicks Bold → the browser sees no bold variant under that family and synthesizes a fake bold, which looks different from the real Bold file
- The cloud renderer sees
fontFamily: 'FiraSans-Regular'+fontWeight: 'bold', also synthesizes a fake bold, and the result diverges from the client - When switching between regular and bold, the fontFamily name changes in
store.toJSON(), making templates brittle
Variable fonts (single file)
If you're using a variable font (one .ttf / .woff2 containing the whole family), one addGlobalFont call with a url is enough:
addGlobalFont({
fontFamily: 'FiraSans',
url: '/fonts/FiraSans-VariableFont_wght.ttf',
});
The browser picks the right weight from the file on its own.
Per-user fonts (end up in the design JSON)
For fonts that belong to a specific user, use store.addFont:
store.addFont({
fontFamily: 'MyCustomSans',
url: 'https://your-cdn.com/fonts/my-custom-sans.woff2',
});
The signature supports the same styles array shape as addGlobalFont. These fonts are serialized with store.toJSON(), so anyone reopening the design loads the same files.
Font file quality itself
Even with correct declarations, bad metadata inside the font files causes platform divergence. Check these three things:
① usWeightClass must match the declared fontWeight
Every font writes a usWeightClass into its OS/2 table (100 / 200 / … / 700 / … / 900). CSS fontWeight: bold maps to 700. If your "Bold" file has usWeightClass: 500, the browser may not consider it bold and will synthesize a fake bold instead.
How to check:
- Online: Wakamai Fondue — drop the file, see all metadata
- CLI:
ttx MyFont.ttf→ inspectOS_2.usWeightClass
② Vertical metrics must be consistent within a family
Regular, Bold, and Italic must share the same ascender / descender / line-gap, otherwise switching weights makes line heights "jump" and multi-line text shifts its baseline. This is the most common cause of macOS/Windows line-height differences.
Open the files in FontForge / fonttools and verify the hhea and OS/2 tables match across a family.
③ Name table alignment
fontFamilymust exactly match the string you pass toaddGlobalFont({ fontFamily })(case / underscores / spaces all matter)- Within a family, different style files should share the same
Family Nameand differ only inSubfamily Name(Regular / Bold / Italic / Bold Italic)
④ Format suggestions
- Modern browsers + cloud-render server: prefer
.woff2(best compression) - Fallback compatibility:
.ttf/.otf - Avoid: EOT (IE only), SVG fonts (deprecated)
When do you need to "normalize" fonts?
If you hit any of these, the font files themselves are broken and need a one-time fix:
- The same paragraph has different line heights on Mac vs Windows
- Switching weights "jumps"
- Cloud Render API output doesn't match the client preview
- Third-party / user-uploaded fonts behave weirdly
Common toolchains:
- fonttools + gftools — Python, good for automation
- FontForge — GUI + scripting, good for one-off fixes
- Transfonter — online tool for quick testing / format conversion
A normalization pass typically:
- Unifies
hhea.ascender/hhea.descender/hhea.lineGapacross all styles in a family - Fixes
OS/2.usWeightClass/usWidthClass - Normalizes
nametable entries (Family / Subfamily) - Converts to
.woff2to shrink file size
For production, consider a font-upload normalization pipeline — every font gets a pass before it's stored — which is far cheaper than chasing runtime bugs later.
Validating user-uploaded fonts
If your product lets users upload their own fonts (the built-in Text panel has an "Upload font" entry), validate at upload time. It's far kinder than letting users design with a broken font and only find out later.
Client-side quick check
// Pseudocode: use opentype.js to inspect metadata up-front
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) family name must not collide with an existing global font
if (globalFonts.some(f => f.fontFamily === familyName)) {
return { ok: false, reason: `Duplicate font family: ${familyName}` };
}
// 2) weight must be a legal value
if (weight < 100 || weight > 900) {
return { ok: false, reason: `Unexpected usWeightClass: ${weight}` };
}
// 3) sanity-check vertical metrics
if (ascender <= 0 || descender >= 0) {
return { ok: false, reason: 'Invalid vertical metrics' };
}
return { ok: true, familyName, weight };
}
Server-side normalization (recommended)
Client-side checks miss many details (cross-style consistency, cross-browser divergence). A server-side normalization pass at ingest time is the robust solution:
- User uploads the file to your backend (see Upload panel for a custom
setUploadFunc) - The backend runs it through
fonttoolsto unify metrics, fix the weight class, convert to woff2 - The normalized file goes to your OSS / CDN
- Persist
{ fontFamily, url }back to your business store
Handle load failures
Even with validation, production will occasionally hit "font load timeout". Use setFontLoadTimeoutCallback to give user feedback:
import {
setFontLoadTimeout,
setFontLoadTimeoutCallback,
} from '@ydesign/react-editor';
import { message } from 'antd';
setFontLoadTimeout(15_000); // 15s timeout
setFontLoadTimeoutCallback(msg => {
message.warning(`Font load timeout: ${msg}`);
});
A note specific to cloud rendering
The Node side of Cloud Render API cannot access fonts installed on your machine, so every non-system font used in the design must be declared in the request body's fonts array:
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: [
// Every fontFamily + every real font file URL
{
fontFamily: 'FiraSans',
url: 'https://your-cdn.com/fonts/FiraSans-Regular.ttf',
},
// ⚠️ If the design uses Bold, list the Bold file separately too
{
fontFamily: 'FiraSans',
url: 'https://your-cdn.com/fonts/FiraSans-Bold.ttf',
},
],
}),
});
A convenient shortcut is to serialize store.fonts directly (assuming you registered them with the styles array where each entry has its own URL):
fonts: store.fonts.flatMap(f =>
(f.styles || [{ src: f.url }]).map(s => ({
fontFamily: f.fontFamily,
url: s.src.replace(/^url\(|\)$/g, '').replace(/['"]/g, ''), // strip `url('...')` wrapping
}))
)
Debugging checklist
When a user reports "the text looks weird", walk this list — it catches ~90% of issues:
- Open DevTools → Network → Font. Does the file load 200 with no CORS error?
- Call
isFontLoaded('MyFont')to confirm it actually loaded (@ydesign/react-editor/utils/fonts) - Drop the file into Wakamai Fondue and confirm
usWeightClassmatches your declaration - Compare
ascender/descenderacross weights in the same family (viattxor FontForge) - For cloud render issues, check the request body's
fontsarray — does it include every weight the design uses? - Finally, double-check the
fontFamilystring spelling and whether the browser is silently synthesizing a fake bold / italic
See also
- 👉 Editor Configuration · Font management — full
addGlobalFont/removeGlobalFont/replaceGlobalFontsAPI - 👉 Store API · Fonts —
store.addFont/store.loadFont - 👉 Utility Functions · fonts —
isFontLoaded/loadFont/injectCustomFont - 👉 Cloud Render API — declaring fonts for server-side rendering
- 👉 CORS — headers required for font files