Skip to content

Commit 2c06c4f

Browse files
authored
Merge pull request #19 from shotstack/derk/font-family-validation
[1/2] fix: match font filenames like the renderer in validate
2 parents 5d282c2 + 58fa53c commit 2c06c4f

2 files changed

Lines changed: 106 additions & 20 deletions

File tree

‎src/lib/validate.ts‎

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ const BUILTIN_FONTS = new Set([
3939
"sueellenfrancisco", "uni neue", "unineue", "work sans", "worksans",
4040
]);
4141

42+
// Weight/style suffix the render engine strips from a font filename before matching.
43+
const FILENAME_WEIGHT_SUFFIX =
44+
/-(?:(?:Extra(?:Light|Bold)|SemiBold|Regular|Medium|Light|Black|Bold|Thin|Heavy)(?:Italic|Oblique)?|(?:Italic|Oblique))$/i;
45+
4246
// Asset types whose `src` must be a public, fetchable URL.
4347
const URL_SRC_TYPES = new Set(["image", "video", "audio", "luma"]);
4448

@@ -93,12 +97,11 @@ function semanticChecks(input: unknown): ValidationIssue[] {
9397
const tracks = timeline?.tracks;
9498
if (!Array.isArray(tracks)) return issues;
9599

96-
// Custom font families that are actually loaded.
97-
const loaded = new Set<string>();
100+
const loaded: FontFileNames[] = [];
98101
if (Array.isArray(timeline?.fonts)) {
99102
for (const font of timeline.fonts) {
100-
const base = fontBasename(font?.src);
101-
if (base) loaded.add(base);
103+
const names = fontFileNames(font?.src);
104+
if (names) loaded.push(names);
102105
}
103106
}
104107

@@ -136,17 +139,8 @@ function semanticChecks(input: unknown): ValidationIssue[] {
136139

137140
const family = asset?.font?.family;
138141
if (typeof family === "string" && family.length > 0) {
139-
if (!loaded.has(family) && !BUILTIN_FONTS.has(family.toLowerCase())) {
140-
issues.push({
141-
path: `${at}.asset.font.family`,
142-
code: "font_not_loaded",
143-
level: "warning",
144-
message:
145-
`font.family '${family}' is neither a built-in font nor loaded via ` +
146-
`timeline.fonts[] — it will fail at render with "Font not found". Add its ` +
147-
`URL to timeline.fonts[] (family must equal the URL's file basename).`,
148-
});
149-
}
142+
const fontIssue = checkFontFamily(family, loaded, `${at}.asset.font.family`);
143+
if (fontIssue) issues.push(fontIssue);
150144
}
151145

152146
// --- html5 JS syntax check --------------------------------------------
@@ -191,12 +185,71 @@ function semanticChecks(input: unknown): ValidationIssue[] {
191185
return issues;
192186
}
193187

194-
function fontBasename(src: unknown): string | null {
188+
interface FontFileNames {
189+
/** Filename without extension. */
190+
full: string;
191+
/** Filename without extension or weight suffix ("SpecialElite-Regular" → "SpecialElite"). */
192+
base: string;
193+
}
194+
195+
// The render engine matches font.family case-insensitively against each loaded font's filename
196+
// or the family name stored inside the file. validate can only see filenames, and the stored name
197+
// can differ from the one on Google Fonts. An unmatched family renders in Roboto without an error,
198+
// unless it is the only loaded font, which is then used whatever font.family says.
199+
function checkFontFamily(family: string, loaded: FontFileNames[], path: string): ValidationIssue | null {
200+
const wanted = family.toLowerCase();
201+
if (BUILTIN_FONTS.has(wanted)) return null;
202+
if (loaded.some((f) => wanted === f.full.toLowerCase() || wanted === f.base.toLowerCase())) return null;
203+
204+
const storedName =
205+
`the exact family name stored in the font file, which can differ from the name on Google Fonts ` +
206+
`(Space Grotesk's file says 'Space Grotesk Light')`;
207+
208+
if (loaded.length === 0) {
209+
return {
210+
path,
211+
code: "font_not_loaded",
212+
level: "warning",
213+
message:
214+
`font.family '${family}' is neither a built-in font nor loaded via timeline.fonts[] — ` +
215+
`it renders in Roboto. Add the font's URL to timeline.fonts[] and use its filename as font.family.`,
216+
};
217+
}
218+
219+
if (loaded.length === 1) {
220+
return {
221+
path,
222+
code: "font_not_loaded",
223+
level: "warning",
224+
message:
225+
`font.family '${family}' doesn't match the timeline.fonts[] filename. It renders in that font ` +
226+
`only because it's the sole entry; add another font and it falls back to Roboto unless it's ` +
227+
`${storedName}. Use the filename to be sure.`,
228+
suggestion: loaded[0]!.full,
229+
};
230+
}
231+
232+
return {
233+
path,
234+
code: "font_not_loaded",
235+
level: "warning",
236+
message:
237+
`font.family '${family}' doesn't match any timeline.fonts[] filename ` +
238+
`(${loaded.map((f) => `'${f.full}'`).join(", ")}). It renders in Roboto unless it's ` +
239+
`${storedName}. Use the filename to be sure.`,
240+
};
241+
}
242+
243+
function fontFileNames(src: unknown): FontFileNames | null {
195244
if (typeof src !== "string") return null;
196-
const noQuery = src.split("?")[0] ?? src;
197-
const base = noQuery.split("/").pop() ?? "";
198-
const stripped = base.replace(/\.(ttf|otf|woff2?|eot)$/i, "");
199-
return stripped.length ? stripped : null;
245+
let filename = src.split(/[?#]/, 1)[0]!.split("/").pop() ?? "";
246+
try {
247+
filename = decodeURIComponent(filename);
248+
} catch {
249+
// Malformed escape: keep the raw filename, as the render engine does.
250+
}
251+
const full = filename.replace(/\.(ttf|otf|woff2?|eot)$/i, "");
252+
return full.length ? { full, base: full.replace(FILENAME_WEIGHT_SUFFIX, "") } : null;
200253
}
201254

202255
function checkJsSyntax(js: string, path: string): ValidationIssue | null {

‎tests/validate.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,39 @@ describe("validateEdit — fonts", () => {
157157
test("a loaded family produces no warning", () => {
158158
expect(codes(validateEdit(baseEdit()))).not.toContain("font_not_loaded");
159159
});
160+
161+
const LUCKIEST_URL = "https://fonts.gstatic.com/s/luckiestguy/v25/_gP_1RrxsjcxVyin9l9n_j2RStR3qDpraA.ttf";
162+
const ELITE_URL = "https://shotstack-assets.s3.amazonaws.com/fonts/SpecialElite-Regular.ttf";
163+
const fontIssue = (family: string, fonts: string[]) => {
164+
const edit = baseEdit();
165+
edit.timeline.fonts = fonts.map((src) => ({ src }));
166+
edit.timeline.tracks[0].clips[0].asset.font.family = family;
167+
return validateEdit(edit).issues.find((i) => i.code === "font_not_loaded");
168+
};
169+
170+
test("the filename matches ignoring case and a weight suffix", () => {
171+
expect(fontIssue("_gp_1rrxsjcxvyin9l9n_j2rstr3qdpraa", [LUCKIEST_URL, ELITE_URL])).toBeUndefined();
172+
expect(fontIssue("SpecialElite-Regular", [LUCKIEST_URL, ELITE_URL])).toBeUndefined();
173+
expect(fontIssue("specialelite", [LUCKIEST_URL, ELITE_URL])).toBeUndefined();
174+
});
175+
176+
test("no fonts loaded says it renders in Roboto", () => {
177+
expect(fontIssue("Helvetica", [])?.message).toMatch(/renders in Roboto/);
178+
});
179+
180+
test("a family name with several fonts loaded warns and lists the filenames", () => {
181+
const issue = fontIssue("Luckiest Guy", [LUCKIEST_URL, ELITE_URL]);
182+
expect(issue?.level).toBe("warning");
183+
expect(issue?.message).toMatch(/'_gP_1RrxsjcxVyin9l9n_j2RStR3qDpraA', 'SpecialElite-Regular'/);
184+
expect(issue?.message).toMatch(/renders in Roboto unless/);
185+
});
186+
187+
test("a family name with one font loaded suggests its filename", () => {
188+
const issue = fontIssue("Luckiest Guy", [LUCKIEST_URL]);
189+
expect(issue?.level).toBe("warning");
190+
expect(issue?.suggestion).toBe("_gP_1RrxsjcxVyin9l9n_j2RStR3qDpraA");
191+
expect(issue?.message).toMatch(/sole entry/);
192+
});
160193
});
161194

162195
describe("validateEdit — asset URLs", () => {

0 commit comments

Comments
 (0)