mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 23:32:19 +10:00
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:
@@ -184,6 +184,25 @@ export function getFallbackWebFontFamilies(family: string) {
|
|||||||
return fallback === family ? [] : [fallback];
|
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[]) {
|
export function getLoadableWebFontWeights(family: string, preferredWeights: string[]) {
|
||||||
const font = webFontMap.get(family);
|
const font = webFontMap.get(family);
|
||||||
if (!font) return [];
|
if (!font) return [];
|
||||||
|
|||||||
@@ -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 { Template } from "@reactive-resume/schema/templates";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import type { SectionTitleResolver } from "./section-title";
|
import type { SectionTitleResolver } from "./section-title";
|
||||||
@@ -23,8 +23,13 @@ export type ResumeDocumentProps = {
|
|||||||
export const ResumeDocument = ({ data, template, resolveSectionTitle }: ResumeDocumentProps) => {
|
export const ResumeDocument = ({ data, template, resolveSectionTitle }: ResumeDocumentProps) => {
|
||||||
const TemplatePageComponent = getTemplatePage(template);
|
const TemplatePageComponent = getTemplatePage(template);
|
||||||
const typography = registerFonts(data.metadata.typography);
|
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 =
|
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 (
|
return (
|
||||||
<RenderProvider data={resumeData} resolveSectionTitle={resolveSectionTitle}>
|
<RenderProvider data={resumeData} resolveSectionTitle={resolveSectionTitle}>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { FontWeight } from "@reactive-resume/fonts";
|
import type { FontWeight } from "@reactive-resume/fonts";
|
||||||
import type { Typography } from "@reactive-resume/schema/resume/data";
|
import type { Typography } from "@reactive-resume/schema/resume/data";
|
||||||
import { Font } from "@react-pdf/renderer";
|
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 = {
|
type FontWeightRange = {
|
||||||
lowest: number;
|
lowest: number;
|
||||||
@@ -11,6 +16,13 @@ type FontWeightRange = {
|
|||||||
const registeredFontVariants = new Set<string>();
|
const registeredFontVariants = new Set<string>();
|
||||||
const fallbackFontFamily = "IBM Plex Serif";
|
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<Typography, "body" | "heading"> & {
|
||||||
|
body: Omit<Typography["body"], "fontFamily"> & { fontFamily: string | string[] };
|
||||||
|
heading: Omit<Typography["heading"], "fontFamily"> & { fontFamily: string | string[] };
|
||||||
|
};
|
||||||
|
|
||||||
const getFontWeightRange = (fontWeights: string[]): FontWeightRange => {
|
const getFontWeightRange = (fontWeights: string[]): FontWeightRange => {
|
||||||
const numericWeights = fontWeights.map(Number).filter((weight) => Number.isFinite(weight));
|
const numericWeights = fontWeights.map(Number).filter((weight) => Number.isFinite(weight));
|
||||||
if (numericWeights.length === 0) return { lowest: 400, highest: 700 };
|
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]);
|
Font.registerHyphenationCallback((word) => [word]);
|
||||||
|
|
||||||
const pdfTypography = resolvePdfTypography(typography);
|
const pdfTypography = resolvePdfTypography(typography);
|
||||||
@@ -84,5 +96,32 @@ export const registerFonts = (typography: Typography): Typography => {
|
|||||||
registerFont(headingFontFamily, headingRange.highest, italic);
|
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 },
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user