fix: fallback for cjk fonts when italic font style not available

This commit is contained in:
Amruth Pillai
2026-05-10 17:35:32 +02:00
parent a93e7bd190
commit 33103536ae
6 changed files with 135 additions and 20 deletions
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { fontList, getPdfCjkFallbackFontFamily, getWebFontSource } from "./index";
const sortFontFamilies = (families: string[]) => {
return [...families].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
};
describe("fontList", () => {
it("is ordered by font family name instead of localized display name", () => {
const families = fontList.map((font) => font.family);
expect(families).toEqual(sortFontFamilies(families));
});
});
describe("font source helpers", () => {
it("uses the full normal font source when an italic variant is unavailable", () => {
expect(getWebFontSource("Noto Serif SC", "400", true)).toBe(getWebFontSource("Noto Serif SC", "400", false));
});
it("returns CJK PDF fallbacks for standard PDF fonts", () => {
expect(getPdfCjkFallbackFontFamily("Helvetica")).toBe("Noto Sans SC");
expect(getPdfCjkFallbackFontFamily("Times-Roman")).toBe("Noto Serif SC");
});
});
+2 -10
View File
@@ -111,12 +111,6 @@ function unique<T>(items: T[]) {
function orderFonts(fonts: FontRecord[]) {
return [...fonts].sort((a, b) => {
const aLabel = getFontDisplayName(a.family);
const bLabel = getFontDisplayName(b.family);
const labelComparison = aLabel.localeCompare(bLabel, undefined, { sensitivity: "base" });
if (labelComparison !== 0) return labelComparison;
return a.family.localeCompare(b.family, undefined, { sensitivity: "base" });
});
}
@@ -174,7 +168,7 @@ export function getWebFontSource(family: string, weight: FontWeight = "400", ita
if (!webFont) return null;
const key = `${weight}${italic ? "italic" : ""}` as FontFileWeight;
return webFont.files[key] ?? webFont.preview;
return webFont.files[key] ?? (italic ? webFont.files[weight] : undefined) ?? webFont.preview;
}
export function getFallbackWebFontFamilies(family: string) {
@@ -187,15 +181,13 @@ export function getFallbackWebFontFamilies(family: string) {
/**
* Returns a CJK web font (Noto Sans/Serif SC) to register as a glyph-level
* fallback for PDF rendering, or `null` when no fallback is needed
* (standard PDF font, or primary already is the fallback).
* (primary already is the fallback).
*
* Source Han Sans/Serif SC covers all CJK-Unified ideographs, so a single
* font handles Simplified/Traditional Chinese, Japanese kanji and Korean
* hanja — the locales reporting #2986 / #3006.
*/
export function getPdfCjkFallbackFontFamily(family: string): string | null {
if (isStandardPdfFontFamily(family)) return null;
const fallback = getPrimaryCjkWebFont(family);
if (fallback === family) return null;
if (!getWebFont(fallback)) return null;
@@ -0,0 +1,90 @@
import type { Typography } from "@reactive-resume/schema/resume/data";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Font } from "@react-pdf/renderer";
import { getWebFontSource } from "@reactive-resume/fonts";
const typography = {
body: {
fontSize: 10,
fontFamily: "IBM Plex Serif",
lineHeight: 1.5,
fontWeights: ["400", "500"],
},
heading: {
fontSize: 14,
fontFamily: "IBM Plex Serif",
lineHeight: 1.5,
fontWeights: ["600"],
},
} satisfies Typography;
const cjkTypography = {
body: {
fontSize: 10,
fontFamily: "Noto Serif SC",
lineHeight: 1.5,
fontWeights: ["400"],
},
heading: {
fontSize: 14,
fontFamily: "Noto Serif SC",
lineHeight: 1.5,
fontWeights: ["400"],
},
} satisfies Typography;
describe("registerFonts", () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("registers CJK PDF fallbacks for normal and italic text styles", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const cjkFallbackSource = getWebFontSource("Noto Serif SC", "400", false);
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography);
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
expect(registerSpy).toHaveBeenCalledWith(
expect.objectContaining({
family: "Noto Serif SC",
fontWeight: 400,
fontStyle: "normal",
}),
);
expect(registerSpy).toHaveBeenCalledWith(
expect.objectContaining({
family: "Noto Serif SC",
fontWeight: 400,
fontStyle: "italic",
src: cjkFallbackSource,
}),
);
});
it("uses the full CJK font source for synthetic italic variants when the CJK font is primary", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const cjkFallbackSource = getWebFontSource("Noto Serif SC", "400", false);
const { registerFonts } = await import("./use-register-fonts");
registerFonts(cjkTypography);
expect(registerSpy).toHaveBeenCalledWith(
expect.objectContaining({
family: "Noto Serif SC",
fontWeight: 400,
fontStyle: "italic",
src: cjkFallbackSource,
}),
);
});
});
+5 -2
View File
@@ -97,16 +97,19 @@ export const registerFonts = (typography: Typography): PdfTypography => {
}
// Register a CJK fallback so textkit can substitute per-codepoint for
// characters the primary font lacks (#2986). One weight is enough —
// substitution is per-codepoint, not per-weight.
// characters the primary font lacks (#2986). One weight per style is
// enough — substitution is per-codepoint, not per-weight.
const bodyCjkFallback = getPdfCjkFallbackFontFamily(bodyFontFamily);
const headingCjkFallback = getPdfCjkFallbackFontFamily(headingFontFamily);
if (bodyCjkFallback) {
registerFont(bodyCjkFallback, 400, false);
registerFont(bodyCjkFallback, 400, true);
}
if (headingCjkFallback && headingCjkFallback !== bodyCjkFallback) {
registerFont(headingCjkFallback, 400, false);
registerFont(headingCjkFallback, 400, true);
}
// Latin-only path: no fallback registered, return as-is.
+1 -1
View File
@@ -37,7 +37,7 @@
"docx": "^9.6.1",
"dompurify": "^3.4.2",
"fast-json-patch": "^3.1.1",
"tailwind-merge": "^3.5.0",
"tailwind-merge": "^3.6.0",
"unique-names-generator": "^4.7.1",
"uuid": "^14.0.0",
"zod": "^4.4.3"
+12 -7
View File
@@ -844,8 +844,8 @@ importers:
specifier: ^3.1.1
version: 3.1.1
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
specifier: ^3.6.0
version: 3.6.0
unique-names-generator:
specifier: ^4.7.1
version: 4.7.1
@@ -5297,8 +5297,8 @@ packages:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
baseline-browser-mapping@2.10.28:
resolution: {integrity: sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA==}
baseline-browser-mapping@2.10.29:
resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -8344,6 +8344,9 @@ packages:
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
tailwindcss@4.3.0:
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
@@ -13731,7 +13734,7 @@ snapshots:
base64id@2.0.0: {}
baseline-browser-mapping@2.10.28: {}
baseline-browser-mapping@2.10.29: {}
bcrypt@6.0.0:
dependencies:
@@ -13824,7 +13827,7 @@ snapshots:
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.28
baseline-browser-mapping: 2.10.29
caniuse-lite: 1.0.30001792
electron-to-chromium: 1.5.353
node-releases: 2.0.38
@@ -15602,7 +15605,7 @@ snapshots:
dependencies:
'@next/env': 16.2.3
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.28
baseline-browser-mapping: 2.10.29
caniuse-lite: 1.0.30001792
postcss: 8.4.31
react: 19.2.6
@@ -16932,6 +16935,8 @@ snapshots:
tailwind-merge@3.5.0: {}
tailwind-merge@3.6.0: {}
tailwindcss@4.3.0: {}
tapable@2.3.3: {}