From 62f4532157dd48791e0b6e1e7d33b23c594cdef2 Mon Sep 17 00:00:00 2001 From: JamesGoslings <3248175240@qq.com> Date: Sun, 10 May 2026 00:49:59 +0800 Subject: [PATCH] fix(pdf): register CJK fallback font so Chinese/Japanese/Korean text renders correctly (#3016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/fonts/src/index.ts | 19 +++++++++ packages/pdf/src/document.tsx | 9 +++- packages/pdf/src/hooks/use-register-fonts.ts | 45 ++++++++++++++++++-- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/packages/fonts/src/index.ts b/packages/fonts/src/index.ts index cde60df26..8d48b2b6a 100644 --- a/packages/fonts/src/index.ts +++ b/packages/fonts/src/index.ts @@ -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 []; diff --git a/packages/pdf/src/document.tsx b/packages/pdf/src/document.tsx index 019a32140..ef10c108e 100644 --- a/packages/pdf/src/document.tsx +++ b/packages/pdf/src/document.tsx @@ -1,4 +1,4 @@ -import type { LayoutPage, ResumeData } from "@reactive-resume/schema/resume/data"; +import type { LayoutPage, ResumeData, Typography } from "@reactive-resume/schema/resume/data"; import type { Template } from "@reactive-resume/schema/templates"; import type { ComponentType } from "react"; import type { SectionTitleResolver } from "./section-title"; @@ -23,8 +23,13 @@ export type ResumeDocumentProps = { export const ResumeDocument = ({ data, template, resolveSectionTitle }: ResumeDocumentProps) => { const TemplatePageComponent = getTemplatePage(template); const typography = registerFonts(data.metadata.typography); + // `registerFonts` widens `fontFamily` to `string | string[]` for CJK + // fallback (#2986); the cast carries that wider runtime value through + // `ResumeData` without changing the public schema. const resumeData = - typography === data.metadata.typography ? data : { ...data, metadata: { ...data.metadata, typography } }; + typography === data.metadata.typography + ? data + : { ...data, metadata: { ...data.metadata, typography: typography as unknown as Typography } }; return ( diff --git a/packages/pdf/src/hooks/use-register-fonts.ts b/packages/pdf/src/hooks/use-register-fonts.ts index ba70b4922..ae50b17b3 100644 --- a/packages/pdf/src/hooks/use-register-fonts.ts +++ b/packages/pdf/src/hooks/use-register-fonts.ts @@ -1,7 +1,12 @@ import type { FontWeight } from "@reactive-resume/fonts"; import type { Typography } from "@reactive-resume/schema/resume/data"; import { Font } from "@react-pdf/renderer"; -import { getFont, getWebFontSource, isStandardPdfFontFamily } from "@reactive-resume/fonts"; +import { + getFont, + getPdfCjkFallbackFontFamily, + getWebFontSource, + isStandardPdfFontFamily, +} from "@reactive-resume/fonts"; type FontWeightRange = { lowest: number; @@ -11,6 +16,13 @@ type FontWeightRange = { const registeredFontVariants = new Set(); const fallbackFontFamily = "IBM Plex Serif"; +// `fontFamily` is widened to `string | string[]` so react-pdf can do +// glyph-level font fallback for CJK characters (#2986). +export type PdfTypography = Omit & { + body: Omit & { fontFamily: string | string[] }; + heading: Omit & { fontFamily: string | string[] }; +}; + const getFontWeightRange = (fontWeights: string[]): FontWeightRange => { const numericWeights = fontWeights.map(Number).filter((weight) => Number.isFinite(weight)); if (numericWeights.length === 0) return { lowest: 400, highest: 700 }; @@ -53,7 +65,7 @@ const resolvePdfTypography = (typography: Typography): Typography => { }; }; -export const registerFonts = (typography: Typography): Typography => { +export const registerFonts = (typography: Typography): PdfTypography => { Font.registerHyphenationCallback((word) => [word]); const pdfTypography = resolvePdfTypography(typography); @@ -84,5 +96,32 @@ export const registerFonts = (typography: Typography): Typography => { registerFont(headingFontFamily, headingRange.highest, italic); } - return pdfTypography; + // Register a CJK fallback so textkit can substitute per-codepoint for + // characters the primary font lacks (#2986). One weight is enough — + // substitution is per-codepoint, not per-weight. + const bodyCjkFallback = getPdfCjkFallbackFontFamily(bodyFontFamily); + const headingCjkFallback = getPdfCjkFallbackFontFamily(headingFontFamily); + + if (bodyCjkFallback) { + registerFont(bodyCjkFallback, 400, false); + } + if (headingCjkFallback && headingCjkFallback !== bodyCjkFallback) { + registerFont(headingCjkFallback, 400, false); + } + + // Latin-only path: no fallback registered, return as-is. + if (!bodyCjkFallback && !headingCjkFallback) { + return pdfTypography as PdfTypography; + } + + const bodyStack: string | string[] = bodyCjkFallback ? [bodyFontFamily, bodyCjkFallback] : bodyFontFamily; + const headingStack: string | string[] = headingCjkFallback + ? [headingFontFamily, headingCjkFallback] + : headingFontFamily; + + return { + ...pdfTypography, + body: { ...pdfTypography.body, fontFamily: bodyStack }, + heading: { ...pdfTypography.heading, fontFamily: headingStack }, + }; };