mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 14:52:18 +10:00
* 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>
213 lines
7.7 KiB
TypeScript
213 lines
7.7 KiB
TypeScript
import type { Locale, Script } from "@reactive-resume/utils/locale";
|
||
import { getLocaleScript, isCjkScript } from "@reactive-resume/utils/locale";
|
||
|
||
// ponytail: inlined from @reactive-resume/utils/field (sole consumer)
|
||
const unique = <T>(items: T[]): T[] => [...new Set(items)];
|
||
|
||
import webFontListJSON from "./webfontlist.json";
|
||
|
||
type FontCategory = "display" | "handwriting" | "monospace" | "serif" | "sans-serif";
|
||
export type FontWeight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
|
||
type FontFileWeight = FontWeight | `${FontWeight}italic`;
|
||
|
||
type StandardFont = {
|
||
type: "standard";
|
||
category: FontCategory;
|
||
family: string;
|
||
weights: FontWeight[];
|
||
};
|
||
|
||
export type WebFont = {
|
||
type: "web";
|
||
category: FontCategory;
|
||
family: string;
|
||
weights: FontWeight[];
|
||
preview: string;
|
||
files: Record<FontFileWeight, string>;
|
||
};
|
||
|
||
type FontRecord = StandardFont | WebFont;
|
||
|
||
const preferredChineseFontFamilies = [
|
||
"Noto Sans SC",
|
||
"Noto Serif SC",
|
||
"PingFang SC",
|
||
"Microsoft YaHei",
|
||
"Source Han Sans SC",
|
||
"Source Han Serif SC",
|
||
"Songti SC",
|
||
"SimSun",
|
||
"SimHei",
|
||
"KaiTi",
|
||
"FangSong",
|
||
"ZCOOL QingKe HuangYou",
|
||
] as const;
|
||
|
||
const standardPdfFontList = [
|
||
{ type: "standard", category: "sans-serif", family: "Helvetica", weights: ["400", "700"] },
|
||
{ type: "standard", category: "monospace", family: "Courier", weights: ["400", "700"] },
|
||
{ type: "standard", category: "serif", family: "Times-Roman", weights: ["400", "700"] },
|
||
] satisfies StandardFont[];
|
||
|
||
const fontDisplayNames: Partial<Record<string, string>> = {
|
||
FangSong: "仿宋",
|
||
"Hiragino Sans GB": "冬青黑体简体中文",
|
||
KaiTi: "楷体",
|
||
"Microsoft YaHei": "微软雅黑",
|
||
"Noto Sans SC": "思源黑体",
|
||
"Noto Sans TC": "思源黑体(繁中)",
|
||
"Noto Serif SC": "思源宋体",
|
||
"Noto Serif TC": "思源宋体(繁中)",
|
||
"PingFang SC": "苹方",
|
||
SimHei: "黑体",
|
||
SimSun: "宋体",
|
||
"Songti SC": "华文宋体",
|
||
"Source Han Sans SC": "思源黑体(本地)",
|
||
"Source Han Serif SC": "思源宋体(本地)",
|
||
"ZCOOL QingKe HuangYou": "站酷庆科黄油体",
|
||
};
|
||
|
||
// 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" },
|
||
};
|
||
|
||
// 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));
|
||
|
||
const fontMap = new Map<string, FontRecord>();
|
||
const chinesePrioritySet = new Set<string>(preferredChineseFontFamilies);
|
||
|
||
export const fontList = [...standardFontList, ...webFontList].sort((a, b) =>
|
||
a.family.localeCompare(b.family, undefined, { sensitivity: "base" }),
|
||
);
|
||
|
||
for (const font of fontList) {
|
||
fontMap.set(font.family, font);
|
||
}
|
||
|
||
// Compatibility aliases for fonts that v5.0.x resolved via the browser
|
||
// (Arial, Times New Roman, ...) but that aren't registered with
|
||
// @react-pdf/renderer in v5.1+. Targets are metric-compatible web fonts
|
||
// already shipped in webfontlist (#2989).
|
||
const legacyFontAliases: Record<string, string> = {
|
||
Arial: "Arimo",
|
||
Cambria: "Tinos",
|
||
Calibri: "Carlito",
|
||
Garamond: "EB Garamond",
|
||
"Times New Roman": "Times-Roman",
|
||
};
|
||
|
||
export function resolveLegacyFontAlias(family: string): string | null {
|
||
return legacyFontAliases[family] ?? null;
|
||
}
|
||
|
||
export function getFont(family: string) {
|
||
const direct = fontMap.get(family);
|
||
if (direct) return direct;
|
||
|
||
const alias = legacyFontAliases[family];
|
||
return alias ? fontMap.get(alias) : undefined;
|
||
}
|
||
|
||
export function getFontDisplayName(family: string) {
|
||
return fontDisplayNames[family] ?? family;
|
||
}
|
||
|
||
export function getFontSearchKeywords(family: string) {
|
||
return unique(
|
||
[family, fontDisplayNames[family], chinesePrioritySet.has(family) ? "中文" : undefined].filter(
|
||
(keyword): keyword is string => Boolean(keyword),
|
||
),
|
||
);
|
||
}
|
||
|
||
function getScriptFont(script: Script, category: FontCategory | null) {
|
||
const variants = scriptFonts[script];
|
||
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);
|
||
}
|
||
|
||
export function getWebFont(family: string) {
|
||
return webFontMap.get(family);
|
||
}
|
||
|
||
export function getWebFontSource(family: string, weight: FontWeight = "400", italic = false) {
|
||
const webFont = getWebFont(family);
|
||
if (!webFont) return null;
|
||
|
||
const key = `${weight}${italic ? "italic" : ""}` as FontFileWeight;
|
||
return webFont.files[key] ?? (italic ? webFont.files[weight] : undefined) ?? webFont.preview;
|
||
}
|
||
|
||
export function sortFontWeights<T extends string>(fontWeights: T[]): T[] {
|
||
return [...fontWeights].sort((a, b) => Number(a) - Number(b));
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* 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 = getFont(family)?.category ?? null;
|
||
|
||
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");
|
||
|
||
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;
|
||
}
|