Files
Reactive-Resume/packages/fonts/src/index.ts
T
Santhi PrakashandAmruth Pillai 5fc9c3ee04 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>
2026-08-13 22:50:58 +02:00

213 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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+2000U+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;
}