diff --git a/packages/fonts/src/index.test.ts b/packages/fonts/src/index.test.ts index 50a9ac234..8515e28da 100644 --- a/packages/fonts/src/index.test.ts +++ b/packages/fonts/src/index.test.ts @@ -8,6 +8,7 @@ import { getFontSearchKeywords, getLoadableWebFontWeights, getPdfCjkFallbackFontFamily, + getPdfFallbackFontFamilies, getWebFont, getWebFontSource, isStandardPdfFontFamily, @@ -163,6 +164,76 @@ describe("getPdfCjkFallbackFontFamily", () => { }); }); +describe("getPdfFallbackFontFamilies", () => { + 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"]); + }); + + 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"]); + }); + + 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"]); + }); + + 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 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"]); + }); + + 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"]); + }); + + it("uses the Thai Noto font for th-TH", () => { + expect(getPdfFallbackFontFamilies("Helvetica", { locale: "th-TH" })).toEqual(["Noto Sans Thai"]); + }); + + 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: ["thai"] })).not.toContain("Noto Sans SC"); + }); + + it("orders the locale script first, then content scripts (mixed RTL + CJK resume)", () => { + expect(getPdfFallbackFontFamilies("Helvetica", { locale: "ko-KR", scripts: ["arabic"] })).toEqual([ + "Noto Sans KR", + "Noto Sans Arabic", + "Noto Sans SC", + ]); + }); + + 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"]); + }); + + 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"], + ); + }); + + it("excludes the family itself when it already is a fallback", () => { + expect(getPdfFallbackFontFamilies("Noto Sans KR", { locale: "ko-KR" })).toEqual(["Noto Sans SC"]); + }); + + it("only returns fonts that exist in the webfontlist", () => { + const families = getPdfFallbackFontFamilies("Helvetica", { + locale: "ko-KR", + scripts: ["hangul", "kana", "han-simplified", "arabic", "hebrew", "thai"], + }); + for (const family of families) { + expect(getWebFont(family)).toBeDefined(); + } + }); +}); + describe("getFallbackWebFontFamilies", () => { it("returns empty array for standard PDF fonts", () => { expect(getFallbackWebFontFamilies("Helvetica")).toEqual([]); diff --git a/packages/fonts/src/index.ts b/packages/fonts/src/index.ts index b4d468e72..481ca5c9c 100644 --- a/packages/fonts/src/index.ts +++ b/packages/fonts/src/index.ts @@ -1,4 +1,6 @@ +import type { Locale, Script } from "@reactive-resume/utils/locale"; import { unique } from "@reactive-resume/utils/field"; +import { getLocaleScript, isCjkScript } from "@reactive-resume/utils/locale"; import webFontListJSON from "./webfontlist.json"; export type FontCategory = "display" | "handwriting" | "monospace" | "serif" | "sans-serif"; @@ -81,6 +83,23 @@ const resumeCjkSerifFontFallbacks = [ "FangSong", ] as const; +// Per-script Noto web font, split by serif/sans category. These match the +// actual writing system: Hangul lives only in the KR fonts, Kana only in JP, +// Arabic glyphs only in the Arabic fonts, etc. — so a Latin or Simplified- +// Chinese font cannot render them and produces tofu. Where Noto ships no serif +// variant for a script (Hebrew, Thai), the serif slot reuses the sans font so +// serif resumes still render real glyphs instead of nothing. All entries are +// present in webfontlist.json. +const scriptFonts: Record = { + hangul: { serif: "Noto Serif KR", sansSerif: "Noto Sans KR" }, + kana: { serif: "Noto Serif JP", sansSerif: "Noto Sans JP" }, + "han-traditional": { serif: "Noto Serif TC", sansSerif: "Noto Sans TC" }, + "han-simplified": { serif: "Noto Serif SC", sansSerif: "Noto Sans SC" }, + arabic: { serif: "Noto Naskh Arabic", sansSerif: "Noto Sans Arabic" }, + hebrew: { serif: "Noto Sans Hebrew", sansSerif: "Noto Sans Hebrew" }, + thai: { serif: "Noto Sans Thai", sansSerif: "Noto Sans Thai" }, +}; + const genericFontFamilies = new Set([ "-apple-system", "BlinkMacSystemFont", @@ -172,6 +191,11 @@ function getPrimaryCjkWebFont(family: string) { return category === "serif" ? "Noto Serif SC" : "Noto Sans SC"; } +function getScriptFont(script: Script, category: FontCategory | null) { + const variants = scriptFonts[script]; + return category === "serif" ? variants.serif : variants.sansSerif; +} + export function isStandardPdfFontFamily(family: string) { return standardFontList.some((font) => font.family === family); } @@ -200,13 +224,39 @@ 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 - * (primary already is the fallback). + * Returns an ordered stack of Noto web fonts to register as glyph-level + * fallbacks for PDF rendering. react-pdf resolves the font per-codepoint + * left-to-right across the stack, so listing one font per writing system lets + * a single resume mix Latin with Hangul, Kana, Han, Arabic, Hebrew or Thai. * - * 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. + * Ordering: the locale's primary script first (the dominant language), then + * any other scripts actually detected in the content. When the stack contains + * a CJK script, a Simplified Chinese entry is appended as a safety net for + * stray CJK-Unified ideographs (preserving prior behavior); non-CJK scripts + * get no such net. The result is deduped, has the primary family removed, and + * only keeps fonts that exist in the webfontlist. + */ +export function getPdfFallbackFontFamilies( + family: string, + options: { locale?: Locale; scripts?: Iterable