mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-13 22:37:14 +10:00
fix(cjk): resolve hyphenation callback with cjk content in resume
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
"@ai-sdk/react": "^3.0.179",
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@better-auth/api-key": "^1.6.10",
|
||||
"@better-auth/infra": "^0.2.6",
|
||||
"@better-auth/infra": "^0.2.7",
|
||||
"@better-auth/oauth-provider": "^1.6.10",
|
||||
"@better-auth/passkey": "^1.6.10",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -50,10 +50,10 @@
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-form": "^1.32.0",
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tanstack/react-router": "^1.169.2",
|
||||
"@tanstack/react-router-ssr-query": "^1.166.12",
|
||||
"@tanstack/react-start": "^1.167.65",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-router": "^1.169.8",
|
||||
"@tanstack/react-router-ssr-query": "^1.166.18",
|
||||
"@tanstack/react-start": "^1.167.71",
|
||||
"@tiptap/extension-color": "^3.23.1",
|
||||
"@tiptap/extension-highlight": "^3.23.1",
|
||||
"@tiptap/extension-table": "^3.23.1",
|
||||
@@ -98,12 +98,12 @@
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"nitro": "3.0.260429-beta",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.11",
|
||||
"vite": "^8.0.12",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import type { SingleComboboxProps } from "@/components/ui/combobox";
|
||||
import type { Locale } from "@/libs/locale";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
|
||||
@@ -1,70 +1,11 @@
|
||||
import type { MessageDescriptor, Messages } from "@lingui/core";
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { createIsomorphicFn, createServerFn } from "@tanstack/react-start";
|
||||
import { getCookie, setCookie } from "@tanstack/react-start/server";
|
||||
import Cookies from "js-cookie";
|
||||
import z from "zod";
|
||||
|
||||
const localeSchema = z.union([
|
||||
z.literal("af-ZA"),
|
||||
z.literal("am-ET"),
|
||||
z.literal("ar-SA"),
|
||||
z.literal("az-AZ"),
|
||||
z.literal("bg-BG"),
|
||||
z.literal("bn-BD"),
|
||||
z.literal("ca-ES"),
|
||||
z.literal("cs-CZ"),
|
||||
z.literal("da-DK"),
|
||||
z.literal("de-DE"),
|
||||
z.literal("el-GR"),
|
||||
z.literal("en-US"),
|
||||
z.literal("en-GB"),
|
||||
z.literal("es-ES"),
|
||||
z.literal("fa-IR"),
|
||||
z.literal("fi-FI"),
|
||||
z.literal("fr-FR"),
|
||||
z.literal("he-IL"),
|
||||
z.literal("hi-IN"),
|
||||
z.literal("hu-HU"),
|
||||
z.literal("id-ID"),
|
||||
z.literal("it-IT"),
|
||||
z.literal("ja-JP"),
|
||||
z.literal("km-KH"),
|
||||
z.literal("kn-IN"),
|
||||
z.literal("ko-KR"),
|
||||
z.literal("lt-LT"),
|
||||
z.literal("lv-LV"),
|
||||
z.literal("ml-IN"),
|
||||
z.literal("mr-IN"),
|
||||
z.literal("ms-MY"),
|
||||
z.literal("ne-NP"),
|
||||
z.literal("nl-NL"),
|
||||
z.literal("no-NO"),
|
||||
z.literal("or-IN"),
|
||||
z.literal("pl-PL"),
|
||||
z.literal("pt-BR"),
|
||||
z.literal("pt-PT"),
|
||||
z.literal("ro-RO"),
|
||||
z.literal("ru-RU"),
|
||||
z.literal("sk-SK"),
|
||||
z.literal("sl-SI"),
|
||||
z.literal("sq-AL"),
|
||||
z.literal("sr-SP"),
|
||||
z.literal("sv-SE"),
|
||||
z.literal("ta-IN"),
|
||||
z.literal("te-IN"),
|
||||
z.literal("th-TH"),
|
||||
z.literal("tr-TR"),
|
||||
z.literal("uk-UA"),
|
||||
z.literal("uz-UZ"),
|
||||
z.literal("vi-VN"),
|
||||
z.literal("zh-CN"),
|
||||
z.literal("zh-TW"),
|
||||
z.literal("zu-ZA"),
|
||||
]);
|
||||
|
||||
export type Locale = z.infer<typeof localeSchema>;
|
||||
import { localeSchema } from "@reactive-resume/utils/locale";
|
||||
|
||||
const storageKey = "locale";
|
||||
const defaultLocale: Locale = "en-US";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { IconProps } from "@phosphor-icons/react";
|
||||
import type { FeatureFlags } from "@reactive-resume/api/services/flags";
|
||||
import type { AuthSession } from "@reactive-resume/auth/types";
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { Locale } from "@/libs/locale";
|
||||
import type { orpc } from "@/libs/orpc/client";
|
||||
import type { Theme } from "@/libs/theme";
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/amruthpillai/reactive-resume.git"
|
||||
},
|
||||
"packageManager": "pnpm@11.0.9+sha512.34ce82e6780233cf9cad8685029a8f81d2e06196c5a9bad98879f7424940c6817c4e4524fb7d38b8553ceed48b9758b8ebaf1abd3600c232c4c8cf7366086f38",
|
||||
"packageManager": "pnpm@11.1.0+sha512.0c44e842e5686b2c061a81adda8b2258bd8818e9704b2cf2c63d56b931a7b2e910092e085027003b96ca3911ab56a07f6df5abaed2be9925034cdd686a535b14",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@@ -45,13 +45,13 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.6.2",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"happy-dom": "^20.9.0",
|
||||
"knip": "^6.12.2",
|
||||
"lefthook": "^2.1.6",
|
||||
"npm-check-updates": "^22.1.1",
|
||||
"turbo": "^2.9.12",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5"
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@tanstack/react-start": "^1.167.65",
|
||||
"@tanstack/react-start": "^1.167.71",
|
||||
"ai": "^6.0.177",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.10",
|
||||
@@ -48,7 +48,7 @@
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
"dependencies": {
|
||||
"@better-auth/api-key": "^1.6.10",
|
||||
"@better-auth/drizzle-adapter": "^1.6.10",
|
||||
"@better-auth/infra": "^0.2.6",
|
||||
"@better-auth/infra": "^0.2.7",
|
||||
"@better-auth/oauth-provider": "^1.6.10",
|
||||
"@better-auth/passkey": "^1.6.10",
|
||||
"@reactive-resume/db": "workspace:*",
|
||||
"@reactive-resume/email": "workspace:*",
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@tanstack/react-start": "^1.167.65",
|
||||
"@tanstack/react-start": "^1.167.71",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.10",
|
||||
"drizzle-orm": "1.0.0-beta.22",
|
||||
@@ -37,7 +37,7 @@
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"drizzle-kit": "1.0.0-beta.22",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -22,7 +22,7 @@
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "^25.6.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,12 @@
|
||||
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
|
||||
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reactive-resume/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { unique } from "@reactive-resume/utils/field";
|
||||
import webFontListJSON from "./webfontlist.json";
|
||||
|
||||
export type FontCategory = "display" | "handwriting" | "monospace" | "serif" | "sans-serif";
|
||||
@@ -105,10 +106,6 @@ export const standardFontList = standardPdfFontList.filter((font) => !webFontMap
|
||||
const fontMap = new Map<string, FontRecord>();
|
||||
const chinesePrioritySet = new Set<string>(preferredChineseFontFamilies);
|
||||
|
||||
function unique<T>(items: T[]) {
|
||||
return items.filter((item, index) => items.indexOf(item) === index);
|
||||
}
|
||||
|
||||
function orderFonts(fonts: FontRecord[]) {
|
||||
return [...fonts].sort((a, b) => {
|
||||
return a.family.localeCompare(b.family, undefined, { sensitivity: "base" });
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/react": "^19.2.14",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"drizzle-orm": "1.0.0-beta.22",
|
||||
"pg": "^8.20.0",
|
||||
"tsx": "^4.21.0"
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3"
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "^25.6.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260510.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260511.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,3 +22,7 @@ export function filterFieldValues<TKey extends string, TField extends KeyedField
|
||||
const filteredFields = fields.filter((field) => Boolean(values[field.key]?.trim()));
|
||||
return new Map(filteredFields.map((field) => [field.key, field] as const));
|
||||
}
|
||||
|
||||
export function unique<T>(items: T[]) {
|
||||
return items.filter((item, index) => items.indexOf(item) === index);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ describe("isLocale", () => {
|
||||
expect(isLocale("en-US")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for any non-empty string (no validation of locale shape)", () => {
|
||||
expect(isLocale("xyz")).toBe(true);
|
||||
it("returns false for unsupported locale string", () => {
|
||||
expect(isLocale("xyz")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
|
||||
@@ -1,8 +1,71 @@
|
||||
// Minimal locale type — the full lingui wiring lives in apps/web (Phase 13).
|
||||
export type Locale = string;
|
||||
import z from "zod";
|
||||
|
||||
export const localeSchema = z.union([
|
||||
z.literal("af-ZA"),
|
||||
z.literal("am-ET"),
|
||||
z.literal("ar-SA"),
|
||||
z.literal("az-AZ"),
|
||||
z.literal("bg-BG"),
|
||||
z.literal("bn-BD"),
|
||||
z.literal("ca-ES"),
|
||||
z.literal("cs-CZ"),
|
||||
z.literal("da-DK"),
|
||||
z.literal("de-DE"),
|
||||
z.literal("el-GR"),
|
||||
z.literal("en-US"),
|
||||
z.literal("en-GB"),
|
||||
z.literal("es-ES"),
|
||||
z.literal("fa-IR"),
|
||||
z.literal("fi-FI"),
|
||||
z.literal("fr-FR"),
|
||||
z.literal("he-IL"),
|
||||
z.literal("hi-IN"),
|
||||
z.literal("hu-HU"),
|
||||
z.literal("id-ID"),
|
||||
z.literal("it-IT"),
|
||||
z.literal("ja-JP"),
|
||||
z.literal("km-KH"),
|
||||
z.literal("kn-IN"),
|
||||
z.literal("ko-KR"),
|
||||
z.literal("lt-LT"),
|
||||
z.literal("lv-LV"),
|
||||
z.literal("ml-IN"),
|
||||
z.literal("mr-IN"),
|
||||
z.literal("ms-MY"),
|
||||
z.literal("ne-NP"),
|
||||
z.literal("nl-NL"),
|
||||
z.literal("no-NO"),
|
||||
z.literal("or-IN"),
|
||||
z.literal("pl-PL"),
|
||||
z.literal("pt-BR"),
|
||||
z.literal("pt-PT"),
|
||||
z.literal("ro-RO"),
|
||||
z.literal("ru-RU"),
|
||||
z.literal("sk-SK"),
|
||||
z.literal("sl-SI"),
|
||||
z.literal("sq-AL"),
|
||||
z.literal("sr-SP"),
|
||||
z.literal("sv-SE"),
|
||||
z.literal("ta-IN"),
|
||||
z.literal("te-IN"),
|
||||
z.literal("th-TH"),
|
||||
z.literal("tr-TR"),
|
||||
z.literal("uk-UA"),
|
||||
z.literal("uz-UZ"),
|
||||
z.literal("vi-VN"),
|
||||
z.literal("zh-CN"),
|
||||
z.literal("zh-TW"),
|
||||
z.literal("zu-ZA"),
|
||||
]);
|
||||
|
||||
export type Locale = z.infer<typeof localeSchema>;
|
||||
|
||||
export const defaultLocale: Locale = "en-US";
|
||||
|
||||
export function isLocale(value: unknown): value is Locale {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
return localeSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function isCJKLocale(locale: Locale): boolean {
|
||||
return locale === "zh-CN" || locale === "zh-TW" || locale === "ja-JP" || locale === "ko-KR";
|
||||
}
|
||||
|
||||
Generated
+251
-411
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user