diff --git a/packages/fonts/src/index.test.ts b/packages/fonts/src/index.test.ts new file mode 100644 index 000000000..732200d76 --- /dev/null +++ b/packages/fonts/src/index.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { fontList, getPdfCjkFallbackFontFamily, getWebFontSource } from "./index"; + +const sortFontFamilies = (families: string[]) => { + return [...families].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); +}; + +describe("fontList", () => { + it("is ordered by font family name instead of localized display name", () => { + const families = fontList.map((font) => font.family); + + expect(families).toEqual(sortFontFamilies(families)); + }); +}); + +describe("font source helpers", () => { + it("uses the full normal font source when an italic variant is unavailable", () => { + expect(getWebFontSource("Noto Serif SC", "400", true)).toBe(getWebFontSource("Noto Serif SC", "400", false)); + }); + + it("returns CJK PDF fallbacks for standard PDF fonts", () => { + expect(getPdfCjkFallbackFontFamily("Helvetica")).toBe("Noto Sans SC"); + expect(getPdfCjkFallbackFontFamily("Times-Roman")).toBe("Noto Serif SC"); + }); +}); diff --git a/packages/fonts/src/index.ts b/packages/fonts/src/index.ts index 8d48b2b6a..fd958bf80 100644 --- a/packages/fonts/src/index.ts +++ b/packages/fonts/src/index.ts @@ -111,12 +111,6 @@ function unique(items: T[]) { function orderFonts(fonts: FontRecord[]) { return [...fonts].sort((a, b) => { - const aLabel = getFontDisplayName(a.family); - const bLabel = getFontDisplayName(b.family); - const labelComparison = aLabel.localeCompare(bLabel, undefined, { sensitivity: "base" }); - - if (labelComparison !== 0) return labelComparison; - return a.family.localeCompare(b.family, undefined, { sensitivity: "base" }); }); } @@ -174,7 +168,7 @@ export function getWebFontSource(family: string, weight: FontWeight = "400", ita if (!webFont) return null; const key = `${weight}${italic ? "italic" : ""}` as FontFileWeight; - return webFont.files[key] ?? webFont.preview; + return webFont.files[key] ?? (italic ? webFont.files[weight] : undefined) ?? webFont.preview; } export function getFallbackWebFontFamilies(family: string) { @@ -187,15 +181,13 @@ export function getFallbackWebFontFamilies(family: string) { /** * 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). + * (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; diff --git a/packages/pdf/src/hooks/use-register-fonts.test.ts b/packages/pdf/src/hooks/use-register-fonts.test.ts new file mode 100644 index 000000000..5dc6009d6 --- /dev/null +++ b/packages/pdf/src/hooks/use-register-fonts.test.ts @@ -0,0 +1,90 @@ +import type { 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"; + +const typography = { + body: { + fontSize: 10, + fontFamily: "IBM Plex Serif", + lineHeight: 1.5, + fontWeights: ["400", "500"], + }, + heading: { + fontSize: 14, + fontFamily: "IBM Plex Serif", + lineHeight: 1.5, + fontWeights: ["600"], + }, +} satisfies Typography; + +const cjkTypography = { + body: { + fontSize: 10, + fontFamily: "Noto Serif SC", + lineHeight: 1.5, + fontWeights: ["400"], + }, + heading: { + fontSize: 14, + fontFamily: "Noto Serif SC", + lineHeight: 1.5, + fontWeights: ["400"], + }, +} satisfies Typography; + +describe("registerFonts", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("registers CJK PDF fallbacks for normal and italic text styles", async () => { + const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {}); + vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {}); + const cjkFallbackSource = getWebFontSource("Noto Serif SC", "400", false); + const { registerFonts } = await import("./use-register-fonts"); + + const pdfTypography = registerFonts(typography); + + 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", + fontWeight: 400, + fontStyle: "normal", + }), + ); + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + family: "Noto Serif SC", + fontWeight: 400, + fontStyle: "italic", + src: cjkFallbackSource, + }), + ); + }); + + it("uses the full CJK font source for synthetic italic variants when the CJK font is primary", async () => { + const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {}); + vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {}); + const cjkFallbackSource = getWebFontSource("Noto Serif SC", "400", false); + const { registerFonts } = await import("./use-register-fonts"); + + registerFonts(cjkTypography); + + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + family: "Noto Serif SC", + fontWeight: 400, + fontStyle: "italic", + src: cjkFallbackSource, + }), + ); + }); +}); diff --git a/packages/pdf/src/hooks/use-register-fonts.ts b/packages/pdf/src/hooks/use-register-fonts.ts index ae50b17b3..2ca5cd092 100644 --- a/packages/pdf/src/hooks/use-register-fonts.ts +++ b/packages/pdf/src/hooks/use-register-fonts.ts @@ -97,16 +97,19 @@ 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 is enough — - // substitution is per-codepoint, not per-weight. + // 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); if (bodyCjkFallback) { registerFont(bodyCjkFallback, 400, false); + registerFont(bodyCjkFallback, 400, true); } + if (headingCjkFallback && headingCjkFallback !== bodyCjkFallback) { registerFont(headingCjkFallback, 400, false); + registerFont(headingCjkFallback, 400, true); } // Latin-only path: no fallback registered, return as-is. diff --git a/packages/utils/package.json b/packages/utils/package.json index 68aadfae0..36b8ed147 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -37,7 +37,7 @@ "docx": "^9.6.1", "dompurify": "^3.4.2", "fast-json-patch": "^3.1.1", - "tailwind-merge": "^3.5.0", + "tailwind-merge": "^3.6.0", "unique-names-generator": "^4.7.1", "uuid": "^14.0.0", "zod": "^4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2cdf44e56..94c082f18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -844,8 +844,8 @@ importers: specifier: ^3.1.1 version: 3.1.1 tailwind-merge: - specifier: ^3.5.0 - version: 3.5.0 + specifier: ^3.6.0 + version: 3.6.0 unique-names-generator: specifier: ^4.7.1 version: 4.7.1 @@ -5297,8 +5297,8 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} - baseline-browser-mapping@2.10.28: - resolution: {integrity: sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA==} + baseline-browser-mapping@2.10.29: + resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -8344,6 +8344,9 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + tailwindcss@4.3.0: resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} @@ -13731,7 +13734,7 @@ snapshots: base64id@2.0.0: {} - baseline-browser-mapping@2.10.28: {} + baseline-browser-mapping@2.10.29: {} bcrypt@6.0.0: dependencies: @@ -13824,7 +13827,7 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.28 + baseline-browser-mapping: 2.10.29 caniuse-lite: 1.0.30001792 electron-to-chromium: 1.5.353 node-releases: 2.0.38 @@ -15602,7 +15605,7 @@ snapshots: dependencies: '@next/env': 16.2.3 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.28 + baseline-browser-mapping: 2.10.29 caniuse-lite: 1.0.30001792 postcss: 8.4.31 react: 19.2.6 @@ -16932,6 +16935,8 @@ snapshots: tailwind-merge@3.5.0: {} + tailwind-merge@3.6.0: {} + tailwindcss@4.3.0: {} tapable@2.3.3: {}