diff --git a/packages/fonts/src/index.test.ts b/packages/fonts/src/index.test.ts index e3d08cad1..861540e7d 100644 --- a/packages/fonts/src/index.test.ts +++ b/packages/fonts/src/index.test.ts @@ -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", () => { diff --git a/packages/fonts/src/index.ts b/packages/fonts/src/index.ts index de131e471..242d9c137 100644 --- a/packages/fonts/src/index.ts +++ b/packages/fonts/src/index.ts @@ -84,6 +84,15 @@ const scriptFonts: Record = { 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(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; } diff --git a/packages/pdf/src/hooks/use-register-fonts.test.ts b/packages/pdf/src/hooks/use-register-fonts.test.ts index e079687f2..5824d9587 100644 --- a/packages/pdf/src/hooks/use-register-fonts.test.ts +++ b/packages/pdf/src/hooks/use-register-fonts.test.ts @@ -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" })); });