Skip to content

Commit c5992c7

Browse files
authored
fix: fixed variable default font support with named weight handling a… (#83)
* fix: fixed variable default font support with named weight handling and caching * test: add font path exact-match and weight resolution tests * test: add default font test template
1 parent 456a792 commit c5992c7

5 files changed

Lines changed: 492 additions & 96 deletions

File tree

src/components/canvas/players/rich-text-player.ts

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,20 @@ type TextEngine = Awaited<ReturnType<typeof createTextEngine>>;
1616

1717
export class RichTextPlayer extends Player {
1818
private static readonly PREVIEW_FPS = 60;
19+
/** CSS font-weight string → numeric value. Extends WEIGHT_MODIFIERS (@core/fonts/font-config.ts) with CSS aliases. */
20+
private static readonly NAMED_WEIGHTS: Record<string, number> = {
21+
thin: 100, hairline: 100,
22+
extralight: 200, ultralight: 200,
23+
light: 300,
24+
normal: 400, regular: 400,
25+
medium: 500,
26+
semibold: 600, demibold: 600,
27+
bold: 700,
28+
extrabold: 800, ultrabold: 800,
29+
black: 900, heavy: 900,
30+
};
1931
private static readonly fontCapabilityCache = new Map<string, Promise<boolean>>();
32+
private static readonly fontBytesCache = new Map<string, Promise<ArrayBuffer>>();
2033
private textEngine: TextEngine | null = null;
2134
private renderer: ReturnType<TextEngine["createRenderer"]> | null = null;
2235
private canvas: HTMLCanvasElement | null = null;
@@ -36,6 +49,22 @@ export class RichTextPlayer extends Player {
3649
return withoutHash.split("?", 1)[0];
3750
}
3851

52+
private static fetchFontBytes(url: string): Promise<ArrayBuffer> {
53+
const cacheKey = RichTextPlayer.getFontSourceCacheKey(url);
54+
const cached = RichTextPlayer.fontBytesCache.get(cacheKey);
55+
if (cached) return cached;
56+
57+
const fetchPromise = fetch(url).then(res => {
58+
if (!res.ok) throw new Error(`Failed to fetch font: ${res.status}`);
59+
return res.arrayBuffer();
60+
}).catch(err => {
61+
RichTextPlayer.fontBytesCache.delete(cacheKey);
62+
throw err;
63+
});
64+
RichTextPlayer.fontBytesCache.set(cacheKey, fetchPromise);
65+
return fetchPromise;
66+
}
67+
3968
constructor(edit: Edit, clipConfiguration: ResolvedClip) {
4069
// Remove fit property for rich-text assets
4170
// This aligns with @shotstack/schemas v1.5.6 which filters fit at track validation
@@ -45,13 +74,14 @@ export class RichTextPlayer extends Player {
4574

4675
private resolveFontWeight(richTextAsset: RichTextAsset, fallbackWeight: number): number {
4776
const explicitWeight = richTextAsset.font?.weight;
77+
if (typeof explicitWeight === "number") return explicitWeight;
4878
if (typeof explicitWeight === "string") {
49-
return parseInt(explicitWeight, 10) || fallbackWeight;
50-
}
51-
if (typeof explicitWeight === "number") {
52-
return explicitWeight;
79+
const named = RichTextPlayer.NAMED_WEIGHTS[explicitWeight.toLowerCase().trim()];
80+
if (named !== undefined) return named;
81+
const parsed = parseInt(explicitWeight, 10);
82+
if (!Number.isNaN(parsed)) return parsed;
83+
console.warn(`Unrecognized font weight "${explicitWeight}", defaulting to ${fallbackWeight}`);
5384
}
54-
5585
return fallbackWeight;
5686
}
5787

@@ -108,7 +138,7 @@ export class RichTextPlayer extends Player {
108138

109139
// Determine the font family for the canvas payload:
110140
// Use matched custom font name, or built-in font, or fall back to Roboto
111-
const hasFontMatch = customFonts || (requestedFamily && resolveFontPath(requestedFamily));
141+
const hasFontMatch = customFonts || (requestedFamily && resolveFontPath(requestedFamily, fontWeight));
112142
const resolvedFamily = hasFontMatch ? baseFontFamily || requestedFamily : undefined;
113143

114144
return {
@@ -136,7 +166,8 @@ export class RichTextPlayer extends Player {
136166
try {
137167
const fontDesc = { family, weight: weight.toString() };
138168
if (source.type === "url") {
139-
await this.textEngine!.registerFontFromUrl(source.path, fontDesc);
169+
const bytes = await RichTextPlayer.fetchFontBytes(source.path);
170+
await this.textEngine!.registerFontFromFile(new Blob([bytes]), fontDesc);
140171
} else {
141172
await this.textEngine!.registerFontFromFile(source.path, fontDesc);
142173
}
@@ -152,11 +183,7 @@ export class RichTextPlayer extends Player {
152183
private createFontCapabilityCheckPromise(fontUrl: string): Promise<boolean> {
153184
return (async (): Promise<boolean> => {
154185
try {
155-
const response = await fetch(fontUrl);
156-
if (!response.ok) {
157-
throw new Error(`Failed to fetch font: ${response.status}`);
158-
}
159-
const buffer = await response.arrayBuffer();
186+
const buffer = await RichTextPlayer.fetchFontBytes(fontUrl);
160187
const font = opentype.parse(buffer);
161188

162189
// Check for fvar table (variable font) with weight axis
@@ -193,14 +220,13 @@ export class RichTextPlayer extends Player {
193220
return this.fontSupportsBold;
194221
}
195222

196-
private resolveFont(family: string): { url: string; baseFontFamily: string; fontWeight: number } | null {
197-
const { baseFontFamily, fontWeight } = parseFontFamily(family);
223+
private resolveFont(family: string, weight: number): { url: string; baseFontFamily: string; fontWeight: number } | null {
224+
const { baseFontFamily } = parseFontFamily(family);
198225

199226
// Check stored font metadata first (for template fonts with UUID-based URLs)
200-
// Uses normalized base family + weight to match the correct font file
201-
const metadataUrl = this.edit.getFontUrlByFamilyAndWeight(baseFontFamily, fontWeight);
227+
const metadataUrl = this.edit.getFontUrlByFamilyAndWeight(baseFontFamily, weight);
202228
if (metadataUrl) {
203-
return { url: metadataUrl, baseFontFamily, fontWeight };
229+
return { url: metadataUrl, baseFontFamily, fontWeight: weight };
204230
}
205231

206232
// Check timeline fonts by filename matching (legacy fallback)
@@ -213,13 +239,13 @@ export class RichTextPlayer extends Player {
213239
});
214240

215241
if (matchingFont) {
216-
return { url: matchingFont.src, baseFontFamily, fontWeight };
242+
return { url: matchingFont.src, baseFontFamily, fontWeight: weight };
217243
}
218244

219-
// Fall back to built-in fonts from FONT_PATHS
220-
const builtInPath = resolveFontPath(family);
221-
if (builtInPath) {
222-
return { url: builtInPath, baseFontFamily, fontWeight };
245+
// Fall back to built-in/Google fonts — single function, single priority chain
246+
const resolvedPath = resolveFontPath(family, weight);
247+
if (resolvedPath) {
248+
return { url: resolvedPath, baseFontFamily, fontWeight: weight };
223249
}
224250

225251
return null;
@@ -261,10 +287,10 @@ export class RichTextPlayer extends Player {
261287
const family = richTextAsset.font?.family;
262288
if (!family) return null;
263289

264-
const resolved = this.resolveFont(family);
290+
const fontWeight = this.resolveFontWeight(richTextAsset, parseFontFamily(family).fontWeight);
291+
const resolved = this.resolveFont(family, fontWeight);
265292
if (!resolved) return null;
266293

267-
const fontWeight = this.resolveFontWeight(richTextAsset, resolved.fontWeight);
268294
await this.registerFont(resolved.baseFontFamily, fontWeight, { type: "url", path: resolved.url });
269295
return resolved.url;
270296
}

src/core/fonts/font-config.ts

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,17 @@ import { GOOGLE_FONTS_BY_FILENAME, GOOGLE_FONTS_BY_NAME } from "./google-fonts";
66

77
const FONT_CDN = "https://templates.shotstack.io/basic/asset/font";
88

9-
/** Font family name to file path mapping */
9+
/** Font family name to file path mapping (variable fonts where available, matching Edit API) */
1010
export const FONT_PATHS: Record<string, string> = {
1111
Arapey: `${FONT_CDN}/arapey-regular.ttf`,
1212
"Clear Sans": `${FONT_CDN}/clearsans-regular.ttf`,
1313
"Clear Sans Bold": `${FONT_CDN}/clearsans-bold.ttf`,
1414
"Didact Gothic": `${FONT_CDN}/didactgothic-regular.ttf`,
15-
Montserrat: `${FONT_CDN}/montserrat-regular.ttf`,
16-
"Montserrat Bold": `${FONT_CDN}/montserrat-bold.ttf`,
17-
"Montserrat ExtraBold": `${FONT_CDN}/montserrat-extrabold.ttf`,
18-
"Montserrat SemiBold": `${FONT_CDN}/montserrat-semibold.ttf`,
19-
"Montserrat Light": `${FONT_CDN}/montserrat-light.ttf`,
20-
"Montserrat Medium": `${FONT_CDN}/montserrat-medium.ttf`,
21-
"Montserrat Black": `${FONT_CDN}/montserrat-black.ttf`,
15+
Montserrat: `${FONT_CDN}/Montserrat.ttf`,
2216
MovLette: `${FONT_CDN}/movlette.ttf`,
23-
"Open Sans": `${FONT_CDN}/opensans-regular.ttf`,
24-
"Open Sans Bold": `${FONT_CDN}/opensans-bold.ttf`,
25-
"Open Sans ExtraBold": `${FONT_CDN}/opensans-extrabold.ttf`,
17+
"Open Sans": `${FONT_CDN}/OpenSans.ttf`,
2618
"Permanent Marker": `${FONT_CDN}/permanentmarker-regular.ttf`,
27-
Roboto: `${FONT_CDN}/roboto-regular.ttf`,
28-
"Roboto Bold": `${FONT_CDN}/roboto-bold.ttf`,
29-
"Roboto Light": `${FONT_CDN}/roboto-light.ttf`,
30-
"Roboto Medium": `${FONT_CDN}/roboto-medium.ttf`,
19+
Roboto: `${FONT_CDN}/Roboto.ttf`,
3120
"Sue Ellen Francisco": `${FONT_CDN}/sueellenfrancisco-regular.ttf`,
3221
"Work Sans": `${FONT_CDN}/worksans.ttf`
3322
};
@@ -75,32 +64,45 @@ export function parseFontFamily(fontFamily: string): { baseFontFamily: string; f
7564
}
7665

7766
/**
78-
* Resolve a font family name to its file path
67+
* Resolve a font family name to its file path.
68+
* Priority: Google filename hash → weight-specific built-in → exact match →
69+
* base font (variable) → Google variable → Google by display name.
7970
*/
80-
export function resolveFontPath(fontFamily: string): string | undefined {
71+
export function resolveFontPath(fontFamily: string, weight?: number): string | undefined {
8172
// Try Google Fonts by filename hash (from FontPicker selection)
8273
const googleFontByFilename = GOOGLE_FONTS_BY_FILENAME.get(fontFamily);
8374
if (googleFontByFilename) {
8475
return googleFontByFilename.url;
8576
}
8677

87-
// Try built-in fonts by exact match (e.g., "Montserrat ExtraBold")
88-
if (FONT_PATHS[fontFamily]) {
89-
return FONT_PATHS[fontFamily];
78+
// Parse family and resolve aliases once (shared by all resolution steps)
79+
const { baseFontFamily } = parseFontFamily(fontFamily);
80+
const resolved = FONT_ALIASES[baseFontFamily] ?? baseFontFamily;
81+
82+
// Try weight-specific built-in (e.g., "Clear Sans Bold" for weight 700)
83+
if (weight !== undefined && weight !== 400) {
84+
const modifier = Object.entries(WEIGHT_MODIFIERS).find(([, w]) => w === weight)?.[0];
85+
if (modifier) {
86+
const weightedName = `${resolved} ${modifier}`;
87+
if (FONT_PATHS[weightedName]) return FONT_PATHS[weightedName];
88+
}
9089
}
9190

92-
// Try built-in fonts by alias or base name
93-
const { baseFontFamily } = parseFontFamily(fontFamily);
94-
const resolvedName = FONT_ALIASES[baseFontFamily] ?? baseFontFamily;
95-
if (FONT_PATHS[resolvedName]) {
96-
return FONT_PATHS[resolvedName];
91+
// Try built-in fonts by exact input (e.g., "Clear Sans Bold" as literal key)
92+
if (FONT_PATHS[fontFamily]) return FONT_PATHS[fontFamily];
93+
94+
// Try base font after alias resolution — covers variable fonts (Roboto, Montserrat, etc.)
95+
if (FONT_PATHS[resolved]) return FONT_PATHS[resolved];
96+
97+
// Try Google Fonts — prefer variable when weight is specified
98+
if (weight !== undefined) {
99+
const googleFont = GOOGLE_FONTS_BY_NAME.get(resolved);
100+
if (googleFont?.isVariable) return googleFont.url;
97101
}
98102

99-
// Fall back to Google Fonts by display name (for fonts not in built-in list)
103+
// Fall back to Google Fonts by display name
100104
const googleFontByName = GOOGLE_FONTS_BY_NAME.get(fontFamily);
101-
if (googleFontByName) {
102-
return googleFontByName.url;
103-
}
105+
if (googleFontByName) return googleFontByName.url;
104106

105107
return undefined;
106108
}

0 commit comments

Comments
 (0)