fix(cjk): resolve hyphenation callback with cjk content in resume

This commit is contained in:
Amruth Pillai
2026-05-11 22:04:45 +02:00
parent 0a8fe05653
commit 62b0a1d533
27 changed files with 486 additions and 525 deletions
+14 -3
View File
@@ -1,11 +1,12 @@
import type { LayoutPage, ResumeData, Typography } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { Locale } from "@reactive-resume/utils/locale";
import type { ComponentType } from "react";
import type { SectionTitleResolver } from "./section-title";
import { Document } from "@react-pdf/renderer";
import { useMemo } from "react";
import { RenderProvider } from "./context";
import { registerFonts } from "./hooks/use-register-fonts";
import { registerFonts, resumeContentContainsCJK } from "./hooks/use-register-fonts";
import { getTemplatePage } from "./templates";
export type TemplatePageProps = {
@@ -23,7 +24,12 @@ export type ResumeDocumentProps = {
export const ResumeDocument = ({ data, template, resolveSectionTitle }: ResumeDocumentProps) => {
const TemplatePageComponent = getTemplatePage(template);
const typography = registerFonts(data.metadata.typography) as Typography;
const hasCjkContent = useMemo(() => resumeContentContainsCJK(data), [data]);
const typography = registerFonts(
data.metadata.typography,
data.metadata.page.locale as Locale,
hasCjkContent,
) as Typography;
// `registerFonts` widens `fontFamily` to `string | string[]` for CJK
// fallback (#2986); the cast carries that wider runtime value through
@@ -33,9 +39,14 @@ export const ResumeDocument = ({ data, template, resolveSectionTitle }: ResumeDo
return (
<RenderProvider data={resumeData} resolveSectionTitle={resolveSectionTitle}>
<Document
title={`${resumeData.basics.name} Resume`}
pageMode="useNone"
creationDate={new Date()}
producer="Reactive Resume"
title={resumeData.basics.name}
author={resumeData.basics.name}
creator={resumeData.basics.name}
subject={resumeData.basics.headline}
language={resumeData.metadata.page.locale}
>
{resumeData.metadata.layout.pages.map((page, index) => (
<TemplatePageComponent key={index} page={page} pageIndex={index} />
@@ -1,7 +1,8 @@
import type { Typography } from "@reactive-resume/schema/resume/data";
import type { ResumeData, Typography } from "@reactive-resume/schema/resume/data";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Font } from "@react-pdf/renderer";
import { getWebFontSource } from "@reactive-resume/fonts";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
const typography = {
body: {
@@ -48,7 +49,7 @@ describe("registerFonts", () => {
const cjkFallbackSource = getWebFontSource("Noto Serif SC", "400", false);
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography);
const pdfTypography = registerFonts(typography, "zh-CN");
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
@@ -76,7 +77,7 @@ describe("registerFonts", () => {
const cjkFallbackSource = getWebFontSource("Noto Serif SC", "400", false);
const { registerFonts } = await import("./use-register-fonts");
registerFonts(cjkTypography);
registerFonts(cjkTypography, "zh-CN");
expect(registerSpy).toHaveBeenCalledWith(
expect.objectContaining({
@@ -88,17 +89,93 @@ describe("registerFonts", () => {
);
});
it("skips CJK PDF fallbacks for Latin locale and Latin content", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography, "en-US");
expect(pdfTypography.body.fontFamily).toBe("IBM Plex Serif");
expect(pdfTypography.heading.fontFamily).toBe("IBM Plex Serif");
expect(registerSpy).not.toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif SC" }));
});
it("registers CJK PDF fallbacks for Latin locale when resume content contains CJK text", async () => {
const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {});
vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts(typography, "en-US", true);
expect(pdfTypography.body.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
expect(pdfTypography.heading.fontFamily).toEqual(["IBM Plex Serif", "Noto Serif SC"]);
expect(registerSpy).toHaveBeenCalledWith(expect.objectContaining({ family: "Noto Serif SC" }));
});
it("uses CJK line breaking for Latin locale when resume content contains CJK text", async () => {
const registerHyphenationSpy = vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
registerFonts(typography, "en-US", true);
const hyphenationCallback = registerHyphenationSpy.mock.calls.at(-1)?.[0];
expect(hyphenationCallback?.("翠翠红红处处")).toEqual(["翠", "", "翠", "", "红", "", "红", "", "处", "", "处", ""]);
expect(hyphenationCallback?.("Reactive")).toEqual([
"R",
"",
"e",
"",
"a",
"",
"c",
"",
"t",
"",
"i",
"",
"v",
"",
"e",
"",
]);
});
it("returns typography with font weights sorted ascending", async () => {
vi.spyOn(Font, "register").mockImplementation(() => {});
const { registerFonts } = await import("./use-register-fonts");
const pdfTypography = registerFonts({
const baseTypography = {
...typography,
body: { ...typography.body, fontFamily: "Source Sans 3", fontWeights: ["800", "600", "400"] },
heading: { ...typography.heading, fontFamily: "Source Sans 3", fontWeights: ["900", "500"] },
});
} satisfies Typography;
const pdfTypography = registerFonts(baseTypography, "en-US");
expect(pdfTypography.body.fontWeights).toEqual(["400", "600", "800"]);
expect(pdfTypography.heading.fontWeights).toEqual(["500", "900"]);
});
});
describe("resumeContentContainsCJK", () => {
it("detects CJK letters in summary content", async () => {
const { resumeContentContainsCJK } = await import("./use-register-fonts");
const data = {
...defaultResumeData,
summary: { ...defaultResumeData.summary, content: "<p>翠翠红红处处莺莺燕燕</p>" },
} satisfies ResumeData;
expect(resumeContentContainsCJK(data)).toBe(true);
});
it("does not scan private metadata notes", async () => {
const { resumeContentContainsCJK } = await import("./use-register-fonts");
const data = {
...defaultResumeData,
metadata: { ...defaultResumeData.metadata, notes: "中文" },
} satisfies ResumeData;
expect(resumeContentContainsCJK(data)).toBe(false);
});
});
+32 -7
View File
@@ -1,7 +1,8 @@
import type { FontWeight } from "@reactive-resume/fonts";
import type { Typography } from "@reactive-resume/schema/resume/data";
import type { ResumeData, Typography } from "@reactive-resume/schema/resume/data";
import type { Locale } from "@reactive-resume/utils/locale";
import { Font } from "@react-pdf/renderer";
import { all as cjk } from "cjk-regex";
import { letters as cjkLetters } from "cjk-regex";
import {
getFont,
getPdfCjkFallbackFontFamily,
@@ -9,6 +10,7 @@ import {
isStandardPdfFontFamily,
sortFontWeights,
} from "@reactive-resume/fonts";
import { isCJKLocale } from "@reactive-resume/utils/locale";
type FontWeightRange = {
lowest: number;
@@ -17,6 +19,7 @@ type FontWeightRange = {
const registeredFontVariants = new Set<string>();
const fallbackFontFamily = "IBM Plex Serif";
const cjkLetterRegex = cjkLetters().toRegExp();
// `fontFamily` is widened to `string | string[]` so react-pdf can do
// glyph-level font fallback for CJK characters (#2986).
@@ -65,9 +68,32 @@ const resolvePdfTypography = (typography: Typography): Typography => {
};
};
export const registerFonts = (typography: Typography): PdfTypography => {
const containsCjkLetters = (value: unknown): boolean => {
if (typeof value === "string") return cjkLetterRegex.test(value);
if (!value || typeof value !== "object") return false;
if (Array.isArray(value)) return value.some(containsCjkLetters);
return Object.values(value as Record<string, unknown>).some(containsCjkLetters);
};
export const resumeContentContainsCJK = (data: ResumeData): boolean => {
return containsCjkLetters({
basics: data.basics,
summary: data.summary,
sections: data.sections,
customSections: data.customSections,
});
};
export const registerFonts = (typography: Typography, locale: Locale, hasCjkContent = false): PdfTypography => {
const needsCjkTextSupport = isCJKLocale(locale) || hasCjkContent;
Font.registerHyphenationCallback((word) => {
if (cjk().toRegExp().test(word)) return word.split("").flatMap((l) => [l, ""]);
if (needsCjkTextSupport) {
if (word === " ") return ["\u200C "];
return [...word].flatMap((l) => [l, ""]);
}
return [word];
});
@@ -102,8 +128,8 @@ export const registerFonts = (typography: Typography): PdfTypography => {
// Register a CJK fallback so textkit can substitute per-codepoint for
// characters the primary font lacks (#2986). One weight per style is
// enough — substitution is per-codepoint, not per-weight.
const bodyCjkFallback = getPdfCjkFallbackFontFamily(bodyFontFamily);
const headingCjkFallback = getPdfCjkFallbackFontFamily(headingFontFamily);
const bodyCjkFallback = needsCjkTextSupport ? getPdfCjkFallbackFontFamily(bodyFontFamily) : null;
const headingCjkFallback = needsCjkTextSupport ? getPdfCjkFallbackFontFamily(headingFontFamily) : null;
if (bodyCjkFallback) {
registerFont(bodyCjkFallback, 400, false);
@@ -126,7 +152,6 @@ export const registerFonts = (typography: Typography): PdfTypography => {
: headingFontFamily;
return {
...pdfTypography,
body: { ...pdfTypography.body, fontFamily: bodyStack },
heading: { ...pdfTypography.heading, fontFamily: headingStack },
};
@@ -10,7 +10,7 @@ import { composeStyles } from "./styles";
export const LevelDisplay = ({ level }: { level: number }) => {
const data = useRender();
const levelDesign = data.metadata.design.level;
const iconSize = data.metadata.typography.body.fontSize - 4;
const iconSize = data.metadata.typography.body.fontSize - 2;
const metrics = getTemplateMetrics(data.metadata.page);
const iconProps = useTemplateIconSlot("icon");
const levelContainerStyle = useTemplateStyle("levelContainer");