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>
This commit is contained in:
Santhi Prakash
2026-08-13 22:50:58 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent a8d1f5a685
commit 5fc9c3ee04
3 changed files with 91 additions and 33 deletions
+22 -1
View File
@@ -84,6 +84,15 @@ const scriptFonts: Record<Script, { serif: string; sansSerif: string }> = {
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));
@@ -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;
}