mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-19 13:01:44 +10:00
fix(pdf): register Noto punctuation fallback for missing glyphs (#3294)
* fix(pdf): register Noto punctuation fallback for missing glyphs
- Problem: U+2022 bullet characters render as garbled glyphs when the body
font (e.g. IBM Plex Serif) lacks the glyph and no PDF fallback is registered.
- Fix: append Noto Serif/Sans to the PDF fallback stack as a general-purpose
punctuation source covering General Punctuation (U+2000–U+206F).
- Verification: pnpm --filter @reactive-resume/fonts test;
pnpm --filter @reactive-resume/pdf test src/hooks/use-register-fonts.test.ts
* test(fonts): clarify zh-CN fallback test description
- Problem: getPdfFallbackFontFamilies("Times-Roman", { locale: "zh-CN" }) now
returns ["Noto Serif SC", "Noto Serif"] (the general-purpose punctuation
fallback is appended), so the test description "returns only the Simplified
Chinese font for zh-CN (unchanged behavior)" is no longer accurate.
- Fix: rename the test to describe that it uses the Simplified Chinese font
plus the punctuation fallback. The assertion is unchanged.
- Verification: pnpm --filter @reactive-resume/fonts test -> 45/45 passing.
---------
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
co-authored by
Amruth Pillai
parent
a8d1f5a685
commit
5fc9c3ee04
@@ -147,39 +147,61 @@ describe("getWebFontSource", () => {
|
||||
});
|
||||
|
||||
describe("getPdfFallbackFontFamilies", () => {
|
||||
it("appends a Noto punctuation fallback for Latin-only PDF fonts (#3190)", () => {
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman")).toEqual(["Noto Serif"]);
|
||||
expect(getPdfFallbackFontFamilies("Helvetica")).toEqual(["Noto Sans"]);
|
||||
expect(getPdfFallbackFontFamilies("IBM Plex Serif")).toEqual(["Noto Serif"]);
|
||||
});
|
||||
|
||||
it("puts the Korean Noto font first for the ko-KR locale (Hangul needs KR, not SC)", () => {
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "ko-KR" })).toEqual(["Noto Serif KR", "Noto Serif SC"]);
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "ko-KR" })).toEqual(["Noto Sans KR", "Noto Sans SC"]);
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "ko-KR" })).toEqual([
|
||||
"Noto Serif KR",
|
||||
"Noto Serif SC",
|
||||
"Noto Serif",
|
||||
]);
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "ko-KR" })).toEqual([
|
||||
"Noto Sans KR",
|
||||
"Noto Sans SC",
|
||||
"Noto Sans",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the Japanese Noto font for the ja-JP locale", () => {
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "ja-JP" })).toEqual(["Noto Serif JP", "Noto Serif SC"]);
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "ja-JP" })).toEqual([
|
||||
"Noto Serif JP",
|
||||
"Noto Serif SC",
|
||||
"Noto Serif",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the Traditional Chinese Noto font for the zh-TW locale", () => {
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "zh-TW" })).toEqual(["Noto Serif TC", "Noto Serif SC"]);
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "zh-TW" })).toEqual([
|
||||
"Noto Serif TC",
|
||||
"Noto Serif SC",
|
||||
"Noto Serif",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns only the Simplified Chinese font for zh-CN (unchanged behavior)", () => {
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "zh-CN" })).toEqual(["Noto Serif SC"]);
|
||||
it("uses the Simplified Chinese font and punctuation fallback for zh-CN", () => {
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "zh-CN" })).toEqual(["Noto Serif SC", "Noto Serif"]);
|
||||
});
|
||||
|
||||
it("uses the Arabic Noto font for the fa-IR (Persian) and ar-SA locales", () => {
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "fa-IR" })).toEqual(["Noto Sans Arabic"]);
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "ar-SA" })).toEqual(["Noto Naskh Arabic"]);
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "fa-IR" })).toEqual(["Noto Sans Arabic", "Noto Sans"]);
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "ar-SA" })).toEqual(["Noto Naskh Arabic", "Noto Serif"]);
|
||||
});
|
||||
|
||||
it("uses the Hebrew Noto font for he-IL, reusing the sans font for serif (no Noto Serif Hebrew)", () => {
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "he-IL" })).toEqual(["Noto Sans Hebrew"]);
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "he-IL" })).toEqual(["Noto Sans Hebrew"]);
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "he-IL" })).toEqual(["Noto Sans Hebrew", "Noto Sans"]);
|
||||
expect(getPdfFallbackFontFamilies("Times-Roman", { locale: "he-IL" })).toEqual(["Noto Sans Hebrew", "Noto Serif"]);
|
||||
});
|
||||
|
||||
it("uses the Thai Noto font for th-TH", () => {
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "th-TH" })).toEqual(["Noto Sans Thai"]);
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "th-TH" })).toEqual(["Noto Sans Thai", "Noto Sans"]);
|
||||
});
|
||||
|
||||
it("does not append the Simplified Chinese safety net for non-CJK scripts", () => {
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { scripts: ["arabic"] })).toEqual(["Noto Sans Arabic"]);
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { scripts: ["arabic"] })).toEqual(["Noto Sans Arabic", "Noto Sans"]);
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { scripts: ["thai"] })).not.toContain("Noto Sans SC");
|
||||
});
|
||||
|
||||
@@ -188,21 +210,31 @@ describe("getPdfFallbackFontFamilies", () => {
|
||||
"Noto Sans KR",
|
||||
"Noto Sans Arabic",
|
||||
"Noto Sans SC",
|
||||
"Noto Sans",
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes a Korean font before SC when Hangul is detected in Latin-locale content", () => {
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { scripts: ["hangul"] })).toEqual(["Noto Sans KR", "Noto Sans SC"]);
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { scripts: ["hangul"] })).toEqual([
|
||||
"Noto Sans KR",
|
||||
"Noto Sans SC",
|
||||
"Noto Sans",
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes the locale script and content scripts", () => {
|
||||
expect(getPdfFallbackFontFamilies("Helvetica", { locale: "ko-KR", scripts: ["hangul", "han-simplified"] })).toEqual(
|
||||
["Noto Sans KR", "Noto Sans SC"],
|
||||
["Noto Sans KR", "Noto Sans SC", "Noto Sans"],
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes the family itself when it already is a fallback", () => {
|
||||
expect(getPdfFallbackFontFamilies("Noto Sans KR", { locale: "ko-KR" })).toEqual(["Noto Sans SC"]);
|
||||
expect(getPdfFallbackFontFamilies("Noto Sans KR", { locale: "ko-KR" })).toEqual(["Noto Sans SC", "Noto Sans"]);
|
||||
});
|
||||
|
||||
it("omits the punctuation fallback when the primary family is already the punctuation font", () => {
|
||||
expect(getPdfFallbackFontFamilies("Noto Serif")).toEqual([]);
|
||||
expect(getPdfFallbackFontFamilies("Noto Sans")).toEqual([]);
|
||||
});
|
||||
|
||||
it("only returns fonts that exist in the webfontlist", () => {
|
||||
|
||||
@@ -84,6 +84,15 @@ const scriptFonts: Record<Script, { serif: string; sansSerif: string }> = {
|
||||
thai: { serif: "Noto Sans Thai", sansSerif: "Noto Sans Thai" },
|
||||
};
|
||||
|
||||
// Covers General Punctuation (U+2000–U+206F) and other symbols missing from
|
||||
// many Latin body fonts (e.g. U+2022 BULLET in IBM Plex Serif). react-pdf has
|
||||
// no browser-style system fallback, so we register Noto as a last-resort
|
||||
// glyph source in the PDF font stack (#3190).
|
||||
const punctuationFallbackFonts = {
|
||||
serif: "Noto Serif",
|
||||
sansSerif: "Noto Sans",
|
||||
} as const;
|
||||
|
||||
export const webFontList = webFontListJSON as WebFont[];
|
||||
export const webFontMap = new Map<string, WebFont>(webFontList.map((font) => [font.family, font]));
|
||||
export const standardFontList = standardPdfFontList.filter((font) => !webFontMap.has(font.family));
|
||||
@@ -140,6 +149,11 @@ function getScriptFont(script: Script, category: FontCategory | null) {
|
||||
return category === "serif" ? variants.serif : variants.sansSerif;
|
||||
}
|
||||
|
||||
function getPunctuationFallbackFont(category: FontCategory | null) {
|
||||
const family = category === "serif" ? punctuationFallbackFonts.serif : punctuationFallbackFonts.sansSerif;
|
||||
return getWebFont(family) ? family : null;
|
||||
}
|
||||
|
||||
export function isStandardPdfFontFamily(family: string) {
|
||||
return standardFontList.some((font) => font.family === family);
|
||||
}
|
||||
@@ -185,7 +199,14 @@ export function getPdfFallbackFontFamilies(
|
||||
if (options.scripts) ordered.push(...options.scripts);
|
||||
if (ordered.some(isCjkScript)) ordered.push("han-simplified");
|
||||
|
||||
return unique(ordered.map((script) => getScriptFont(script, category)))
|
||||
const fallbacks = unique(ordered.map((script) => getScriptFont(script, category)))
|
||||
.filter((candidate) => candidate !== family)
|
||||
.filter((candidate) => Boolean(getWebFont(candidate)));
|
||||
|
||||
const punctuationFallback = getPunctuationFallbackFont(category);
|
||||
if (punctuationFallback && punctuationFallback !== family && !fallbacks.includes(punctuationFallback)) {
|
||||
fallbacks.push(punctuationFallback);
|
||||
}
|
||||
|
||||
return fallbacks;
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ describe("registerFonts", () => {
|
||||
|
||||
const pdfTypography = registerFonts(typography, "zh-CN");
|
||||
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
|
||||
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC", "Noto Serif"]);
|
||||
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC", "Noto Serif"]);
|
||||
|
||||
expect(registerSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -78,8 +78,13 @@ describe("registerFonts", () => {
|
||||
|
||||
const pdfTypography = registerFonts(typography, "ko-KR");
|
||||
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif KR", "Noto Serif SC"]);
|
||||
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif KR", "Noto Serif SC"]);
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif KR", "Noto Serif SC", "Noto Serif"]);
|
||||
expect(pdfTypography.heading.fontFamily).toEqual([
|
||||
"IBM Plex Serif",
|
||||
"Noto Serif KR",
|
||||
"Noto Serif SC",
|
||||
"Noto Serif",
|
||||
]);
|
||||
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif KR" }));
|
||||
});
|
||||
|
||||
@@ -90,7 +95,7 @@ describe("registerFonts", () => {
|
||||
|
||||
const pdfTypography = registerFonts(typography, "ja-JP");
|
||||
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif JP", "Noto Serif SC"]);
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif JP", "Noto Serif SC", "Noto Serif"]);
|
||||
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif JP" }));
|
||||
});
|
||||
|
||||
@@ -101,7 +106,7 @@ describe("registerFonts", () => {
|
||||
|
||||
const pdfTypography = registerFonts(typography, "zh-TW");
|
||||
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif TC", "Noto Serif SC"]);
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif TC", "Noto Serif SC", "Noto Serif"]);
|
||||
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif TC" }));
|
||||
});
|
||||
|
||||
@@ -112,7 +117,7 @@ describe("registerFonts", () => {
|
||||
|
||||
const pdfTypography = registerFonts(typography, "en-US", true, new Set(["hangul"]));
|
||||
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif KR", "Noto Serif SC"]);
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif KR", "Noto Serif SC", "Noto Serif"]);
|
||||
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif KR" }));
|
||||
});
|
||||
|
||||
@@ -123,7 +128,7 @@ describe("registerFonts", () => {
|
||||
|
||||
const pdfTypography = registerFonts(typography, "fa-IR");
|
||||
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Naskh Arabic"]);
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Naskh Arabic", "Noto Serif"]);
|
||||
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Naskh Arabic" }));
|
||||
});
|
||||
|
||||
@@ -134,7 +139,7 @@ describe("registerFonts", () => {
|
||||
|
||||
const pdfTypography = registerFonts(typography, "en-US", false, new Set(["arabic"]));
|
||||
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Naskh Arabic"]);
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Naskh Arabic", "Noto Serif"]);
|
||||
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Naskh Arabic" }));
|
||||
});
|
||||
|
||||
@@ -145,7 +150,7 @@ describe("registerFonts", () => {
|
||||
|
||||
const pdfTypography = registerFonts(typography, "he-IL");
|
||||
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Sans Hebrew"]);
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Sans Hebrew", "Noto Serif"]);
|
||||
expect(registerSpy).not.toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif SC" }));
|
||||
});
|
||||
|
||||
@@ -156,7 +161,7 @@ describe("registerFonts", () => {
|
||||
|
||||
const pdfTypography = registerFonts(typography, "th-TH");
|
||||
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Sans Thai"]);
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Sans Thai", "Noto Serif"]);
|
||||
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Sans Thai" }));
|
||||
});
|
||||
|
||||
@@ -225,16 +230,16 @@ describe("registerFonts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("skips CJK PDF fallbacks for Latin locale and Latin content", async () => {
|
||||
it("registers a punctuation fallback for Latin locale and Latin content (#3190)", async () => {
|
||||
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
|
||||
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
|
||||
const { registerFonts } = await import("./use-register-fonts");
|
||||
|
||||
const pdfTypography = registerFonts(typography, "en-US");
|
||||
|
||||
expect(pdfTypography.body.fontFamily).toBe("IBM Plex Serif");
|
||||
expect(pdfTypography.heading.fontFamily).toBe("IBM Plex Serif");
|
||||
expect(registerSpy).not.toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif SC" }));
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif"]);
|
||||
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif"]);
|
||||
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif" }));
|
||||
});
|
||||
|
||||
it("registers CJK PDF fallbacks for Latin locale when resume content contains CJK text", async () => {
|
||||
@@ -244,8 +249,8 @@ describe("registerFonts", () => {
|
||||
|
||||
const pdfTypography = registerFonts(typography, "en-US", true);
|
||||
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
|
||||
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
|
||||
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC", "Noto Serif"]);
|
||||
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC", "Noto Serif"]);
|
||||
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif SC" }));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user