mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-27 02:14:50 +10:00
fix: register language-specific Noto fallback fonts for non-Latin scripts (#3158)
* fix: use language-specific Noto fonts for CJK PDF fallback * feat: extend fallback to Arabic/Hebrew/Thai --------- Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
@@ -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([]);
|
||||
|
||||
@@ -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<Script, { serif: string; sansSerif: string }> = {
|
||||
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<Script> } = {},
|
||||
): string[] {
|
||||
const category = getFontCategory(family);
|
||||
|
||||
const ordered: Script[] = [];
|
||||
const localeScript = getLocaleScript(options.locale);
|
||||
if (localeScript) ordered.push(localeScript);
|
||||
if (options.scripts) ordered.push(...options.scripts);
|
||||
if (ordered.some(isCjkScript)) ordered.push("han-simplified");
|
||||
|
||||
return unique(ordered.map((script) => getScriptFont(script, category)))
|
||||
.filter((candidate) => candidate !== family)
|
||||
.filter((candidate) => Boolean(getWebFont(candidate)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-compat single-font resolver kept for the public `@reactive-resume/fonts`
|
||||
* surface: returns the Simplified Chinese fallback (Noto Sans/Serif SC) for a
|
||||
* family, or `null` when the family already is that fallback.
|
||||
*/
|
||||
export function getPdfCjkFallbackFontFamily(family: string): string | null {
|
||||
const fallback = getPrimaryCjkWebFont(family);
|
||||
|
||||
Reference in New Issue
Block a user