diff --git a/packages/pdf/src/hooks/use-register-fonts.test.ts b/packages/pdf/src/hooks/use-register-fonts.test.ts index cc22f2436..37b90c8b7 100644 --- a/packages/pdf/src/hooks/use-register-fonts.test.ts +++ b/packages/pdf/src/hooks/use-register-fonts.test.ts @@ -71,6 +71,43 @@ describe("registerFonts", () => { ); }); + it("registers a bold CJK fallback variant so renders bold for CJK glyphs", async () => { + const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {}); + vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {}); + const { registerFonts } = await import("./use-register-fonts"); + + // body has highest weight 700, heading has 600 — both should be registered + // for the CJK fallback so textkit can substitute bold CJK glyphs. + const boldTypography = { + body: { ...typography.body, fontWeights: ["400", "700"] }, + heading: { ...typography.heading, fontWeights: ["400", "600"] }, + } satisfies Typography; + + registerFonts(boldTypography, "zh-CN"); + + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + family: "Noto Serif SC", + fontWeight: 700, + fontStyle: "normal", + }), + ); + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + family: "Noto Serif SC", + fontWeight: 700, + fontStyle: "italic", + }), + ); + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + family: "Noto Serif SC", + fontWeight: 600, + fontStyle: "normal", + }), + ); + }); + 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(() => {}); diff --git a/packages/pdf/src/hooks/use-register-fonts.ts b/packages/pdf/src/hooks/use-register-fonts.ts index e05d293ef..956d93131 100644 --- a/packages/pdf/src/hooks/use-register-fonts.ts +++ b/packages/pdf/src/hooks/use-register-fonts.ts @@ -133,19 +133,38 @@ export const registerFonts = (typography: Typography, locale: Locale, hasCjkCont } // 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. + // characters the primary font lacks (#2986). We register both the + // regular and bold weights so that /font-weight: 700 styles + // are honored for CJK glyphs — without the bold variant, textkit + // would only find a 400-weight match and synthesize an unbolded run. const bodyCjkFallback = needsCjkTextSupport ? getPdfCjkFallbackFontFamily(bodyFontFamily) : null; const headingCjkFallback = needsCjkTextSupport ? getPdfCjkFallbackFontFamily(headingFontFamily) : null; - if (bodyCjkFallback) { - registerFont(bodyCjkFallback, 400, false); - registerFont(bodyCjkFallback, 400, true); - } + const registerCjkFallback = (family: string, ranges: FontWeightRange[]) => { + const weights = new Set(); + for (const range of ranges) { + weights.add(range.lowest); + weights.add(range.highest); + } + for (const italic of [false, true]) { + for (const weight of weights) { + registerFont(family, weight, italic); + } + } + }; - if (headingCjkFallback && headingCjkFallback !== bodyCjkFallback) { - registerFont(headingCjkFallback, 400, false); - registerFont(headingCjkFallback, 400, true); + if (bodyCjkFallback && bodyCjkFallback === headingCjkFallback) { + // Same fallback for body and heading: merge weight ranges so that + // bold styles applied to either typography level have a matching + // CJK glyph variant. + registerCjkFallback(bodyCjkFallback, [bodyRange, headingRange]); + } else { + if (bodyCjkFallback) { + registerCjkFallback(bodyCjkFallback, [bodyRange]); + } + if (headingCjkFallback) { + registerCjkFallback(headingCjkFallback, [headingRange]); + } } // Latin-only path: no fallback registered, return as-is.