mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-27 02:14:50 +10:00
fix(cjk): resolve hyphenation callback with cjk content in resume
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user