mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
fix: fallback for cjk fonts when italic font style not available
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+12
-7
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user