fix(pdf): register CJK fallback at primary font weights so bold rende… (#3080)

The CJK fallback (Noto Sans SC / Noto Serif SC) was only registered at
weight 400. When react-pdf rendered CJK characters with font-weight 700
(e.g. <strong> from a rich-text section, or templates' bold style), it
walked the font-family stack [primary, cjkFallback], failed on the
primary (no CJK glyphs), then fell back to the only registered fallback
variant (400) — and react-pdf does not synthesize bold. The bold style
was silently dropped for CJK runs in both the live preview and the
exported PDF, while still working for Latin runs.

Register the CJK fallback at the same weight range as the primary font
(lowest + highest, both styles). When body and heading share the same
fallback (the common case where both are sans or both are serif), merge
their weight ranges so each weight is registered exactly once.

webfontlist.json already ships all weights for the default CJK
fallbacks, so no font-list changes are required.

Closes #3079

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
JamesGoslings
2026-05-19 15:09:28 +08:00
committed by GitHub
parent dd7623f11e
commit 5b1297fa2b
2 changed files with 65 additions and 9 deletions
@@ -71,6 +71,43 @@ describe("registerFonts", () => {
);
});
it("registers a bold CJK fallback variant so <strong> 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(() => {});
+28 -9
View File
@@ -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 <strong>/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<number>();
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.