fix(pdf): register CJK fallback font so Chinese/Japanese/Korean text renders correctly (#3016)

Closes #2986.

Since v5.1.0 the renderer was migrated from Puppeteer to
@react-pdf/renderer. The new pipeline only registers the user-selected
typography family (e.g. Roboto, IBM Plex Serif), which contains no CJK
glyphs, so any Chinese / Japanese / Korean characters in the resume
fall back to .notdef and render as garbled boxes in both the in-app
preview and the exported PDF.

@react-pdf/renderer's textkit layer already supports per-codepoint
font substitution when a Text node is styled with `fontFamily` as a
string array — but only if every family in the stack has been
registered via Font.register. This change wires that up:

- packages/fonts: new `getPdfCjkFallbackFontFamily(family)` returns
  Noto Sans SC / Noto Serif SC depending on whether the primary font
  is sans-serif or serif, and `null` when no fallback is needed
  (standard PDF font, or primary already is the fallback). Source Han
  Sans/Serif SC covers all CJK-Unified ideographs, so a single font
  transparently handles Simplified/Traditional Chinese, Japanese
  kanji and Korean hanja.

- packages/pdf/hooks/use-register-fonts: after registering the
  primary body/heading fonts as before, additionally register the
  resolved CJK fallback (regular weight only — substitution is
  per-codepoint, not per-weight, so one face is enough). The
  function's return type is widened to a new `PdfTypography` whose
  `body.fontFamily` and `heading.fontFamily` become
  `[primary, cjkFallback]` two-element stacks.

- packages/pdf/document: cast the widened typography back through the
  schema-typed `ResumeData` so the wider runtime value reaches
  templates without changing the public `Typography` schema. All 15
  templates already consume `metadata.typography.body.fontFamily`
  directly, and `StyleSheet.fontFamily` accepts both string and
  string[], so no template edits are required.

Latin-only resumes are unaffected:
- `getPdfCjkFallbackFontFamily` returns `null` for standard PDF fonts
  and existing CJK selections, so the extra Font.register call is
  skipped.
- When no fallback applies, `registerFonts` returns the original
  typography reference unchanged (zero allocation).
- Even when the fallback is registered, textkit only consults it for
  codepoints the primary font cannot render, so Latin glyphs still
  come from the user-selected font with identical metrics.
This commit is contained in:
JamesGoslings
2026-05-09 18:49:59 +02:00
committed by GitHub
parent fabe22089d
commit 62f4532157
3 changed files with 68 additions and 5 deletions
+19
View File
@@ -184,6 +184,25 @@ export function getFallbackWebFontFamilies(family: string) {
return fallback === family ? [] : [fallback];
}
/**
* 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
* (standard PDF font, or primary already is the fallback).
*
* 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.
*/
export function getPdfCjkFallbackFontFamily(family: string): string | null {
if (isStandardPdfFontFamily(family)) return null;
const fallback = getPrimaryCjkWebFont(family);
if (fallback === family) return null;
if (!getWebFont(fallback)) return null;
return fallback;
}
export function getLoadableWebFontWeights(family: string, preferredWeights: string[]) {
const font = webFontMap.get(family);
if (!font) return [];