fix(fonts): register Vazirmatn in webfont catalog for JSON imports (#3331)

* fix(fonts): register Vazirmatn in webfont catalog for JSON imports

Imported resumes can set typography.fontFamily to Vazirmatn, but the
popularity-sorted Google Fonts slice omits it so PDF registration fell
back to IBM Plex Serif and Persian/Arabic glyphs stacked or tofu (#3098).

Add Vazirmatn as a locale-coverage manual entry (same pattern as Carlito)
and cover catalog resolution with unit tests.

* test(pdf): keep Vazirmatn as primary family for fa-IR registration

Prove JSON-imported Vazirmatn is handed to Font.register instead of
being rewritten to IBM Plex Serif (#3098).

* fix(fonts): address CodeRabbit review on Vazirmatn catalog

Assert getWebFontSource resolves files["400"] for Vazirmatn instead of
only matching the preview fallback, and split the font-generation log
line to satisfy the 120-column Biome limit.
This commit is contained in:
Santhi Prakash
2026-08-27 03:15:31 +02:00
committed by GitHub
parent a416d01112
commit 2761bd6715
4 changed files with 100 additions and 4 deletions
+17
View File
@@ -289,3 +289,20 @@ describe("legacy font compatibility (#2989)", () => {
expect(getFontDisplayName("Times New Roman")).toBe("Times New Roman");
});
});
describe("locale coverage fonts (#3098)", () => {
it("resolves Vazirmatn from the webfont catalog", () => {
expect(getFont("Vazirmatn")?.family).toBe("Vazirmatn");
expect(getWebFont("Vazirmatn")?.category).toBe("sans-serif");
});
it("returns a gstatic source for Vazirmatn regular so PDF registration can load it", () => {
const regularSource = getWebFont("Vazirmatn")?.files["400"];
expect(regularSource).toBeDefined();
const source = getWebFontSource("Vazirmatn", "400");
expect(source).toBe(regularSource);
expect(source).toEqual(expect.stringContaining("fonts.gstatic.com"));
expect(source).toEqual(expect.stringMatching(/\.ttf(\?|$)/));
});
});
+18
View File
@@ -7522,5 +7522,23 @@
"400italic": "https://fonts.gstatic.com/s/carlito/v4/3Jn_SDPw3m-pk039DDKxTl0F.ttf",
"700italic": "https://fonts.gstatic.com/s/carlito/v4/3Jn6SDPw3m-pk039DDK59XgVUcBD.ttf"
}
},
{
"type": "web",
"category": "sans-serif",
"family": "Vazirmatn",
"weights": ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
"preview": "https://fonts.gstatic.com/s/vazirmatn/v16/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklWgzCRCT60LA7Qdazg.ttf",
"files": {
"100": "https://fonts.gstatic.com/s/vazirmatn/v16/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklWgyCRCT60LA7Qdazg.ttf",
"200": "https://fonts.gstatic.com/s/vazirmatn/v16/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklegzCRCT60LA7Qdazg.ttf",
"300": "https://fonts.gstatic.com/s/vazirmatn/v16/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklTYzCRCT60LA7Qdazg.ttf",
"400": "https://fonts.gstatic.com/s/vazirmatn/v16/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklWgzCRCT60LA7Qdazg.ttf",
"500": "https://fonts.gstatic.com/s/vazirmatn/v16/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklVozCRCT60LA7Qdazg.ttf",
"600": "https://fonts.gstatic.com/s/vazirmatn/v16/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklbY0CRCT60LA7Qdazg.ttf",
"700": "https://fonts.gstatic.com/s/vazirmatn/v16/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklY80CRCT60LA7Qdazg.ttf",
"800": "https://fonts.gstatic.com/s/vazirmatn/v16/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRkleg0CRCT60LA7Qdazg.ttf",
"900": "https://fonts.gstatic.com/s/vazirmatn/v16/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklcE0CRCT60LA7Qdazg.ttf"
}
}
]
@@ -299,6 +299,25 @@ describe("registerFonts", () => {
expect(registerSpy).not.toHaveBeenCalledWith(expect.objectContaining({ family: "Lato", fontWeight: 600 }));
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Lato", fontWeight: 700 }));
});
it("keeps Vazirmatn as the primary PDF family instead of substituting IBM Plex Serif (#3098)", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const vazirTypography = {
...typography,
body: { ...typography.body, fontFamily: "Vazirmatn", fontWeights: ["400"] },
heading: { ...typography.heading, fontFamily: "Vazirmatn", fontWeights: ["700"] },
} satisfies Typography;
const pdfTypography = registerFonts(vazirTypography, "fa-IR");
expect(pdfTypography.body.fontFamily).toEqual(["Vazirmatn", "Noto Sans Arabic", "Noto Sans"]);
expect(pdfTypography.heading.fontFamily).toEqual(["Vazirmatn", "Noto Sans Arabic", "Noto Sans"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Vazirmatn", fontWeight: 400 }));
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Vazirmatn", fontWeight: 700 }));
});
});
describe("resumeContentContainsCJK", () => {
+46 -4
View File
@@ -160,6 +160,37 @@ function getMetricCompatibleFonts(): WebFont[] {
];
}
/**
* Helper: locale-coverage web fonts that aren't in the popularity-sorted
* Google Fonts slice but are stored as `typography.*.fontFamily` in imported
* resume JSON. Without a catalog entry, PDF registration silently substitutes
* IBM Plex Serif and Persian/Arabic glyphs stack or tofu (#3098).
*/
function getLocaleCoverageFonts(): WebFont[] {
const CDN = "https://fonts.gstatic.com/s/vazirmatn/v16";
return [
{
type: "web",
category: "sans-serif",
family: "Vazirmatn",
weights: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
preview: `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklWgzCRCT60LA7Qdazg.ttf`,
files: {
"100": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklWgyCRCT60LA7Qdazg.ttf`,
"200": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklegzCRCT60LA7Qdazg.ttf`,
"300": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklTYzCRCT60LA7Qdazg.ttf`,
"400": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklWgzCRCT60LA7Qdazg.ttf`,
"500": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklVozCRCT60LA7Qdazg.ttf`,
"600": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklbY0CRCT60LA7Qdazg.ttf`,
"700": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklY80CRCT60LA7Qdazg.ttf`,
"800": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRkleg0CRCT60LA7Qdazg.ttf`,
"900": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklcE0CRCT60LA7Qdazg.ttf`,
},
},
];
}
async function generateFonts() {
const response = await getGoogleFontsJSON();
console.log(`Found ${response.items.length} fonts in total (Google Fonts).`);
@@ -196,16 +227,27 @@ async function generateFonts() {
const computerModernFonts = getComputerModernWebFonts();
// Manually append metric-compatible fonts not covered by the popularity slice
const metricCompatFonts = getMetricCompatibleFonts();
const localeCoverageFonts = getLocaleCoverageFonts();
// De-duplicate against the Google Fonts slice in case a manual entry
// later enters the popularity top-N.
const googleFontFamilies = new Set(googleFontResults.map((f) => f.family));
const filteredMetricCompat = metricCompatFonts.filter((f) => !googleFontFamilies.has(f.family));
const filteredLocaleCoverage = localeCoverageFonts.filter((f) => !googleFontFamilies.has(f.family));
const allWebFonts: WebFont[] = [...computerModernFonts, ...googleFontResults, ...filteredMetricCompat];
const allWebFonts: WebFont[] = [
...computerModernFonts,
...googleFontResults,
...filteredMetricCompat,
...filteredLocaleCoverage,
];
console.log(
`Added ${computerModernFonts.length} Computer Modern Web Fonts and ${filteredMetricCompat.length} metric-compatible fonts. Total output: ${allWebFonts.length} web fonts.`,
);
const manualFontSummary = [
`${computerModernFonts.length} Computer Modern Web Fonts`,
`${filteredMetricCompat.length} metric-compatible fonts`,
`and ${filteredLocaleCoverage.length} locale-coverage fonts`,
].join(", ");
console.log(`Added ${manualFontSummary}. Total output: ${allWebFonts.length} web fonts.`);
const jsonString = argCompress ? JSON.stringify(allWebFonts) : JSON.stringify(allWebFonts, null, 2);
await mkdir(FONTS_DIR, { recursive: true });