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 }, + }; };