From e065a10824a1b8de022a20b88792ad202b31fbb1 Mon Sep 17 00:00:00 2001 From: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:46:46 +0530 Subject: [PATCH] fix(pdf): resolve bold text weight from the family's bold face (#3335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pdf): resolve bold text weight from the family's bold face Bold text (, rich-text bold, template bold styles) previously rendered at the last stored body weight, which is ambiguous: families are commonly stored as ["400","600"] (the typography picker's default pairing), so bold rendered at SemiBold — nearly indistinguishable from Regular for faces like Open Sans (#3310). Add resolveBoldFontWeight() to the fonts package: keep a deliberate stored bold-class choice (>= 700), else prefer the family's true Bold face ("700"), else the heaviest >= 600 face; return null so callers keep their existing fallback when the family has no bold-class face. Wire it through use-register-fonts, the shared base-template-styles builder, base-styles and the Scizor template. Default body IBM Plex Serif ["400","500"] now renders bold at 700 (base-reset-fidelity expectation updated accordingly). Fixes #3310 * fix(pdf): register bold fallback faces for CJK glyph substitution When resolveBoldFontWeight maps stored weights like ["400","600"] to the family's 700 face, register that weight on each PDF fallback font too so glyph-level substitution keeps bold glyphs instead of snapping to 600. Also reorder @reactive-resume/fonts imports per Biome convention. --------- Co-authored-by: Amruth Pillai --- packages/fonts/src/index.test.ts | 40 +++++++++++++++ packages/fonts/src/index.ts | 44 +++++++++++++++++ .../pdf/src/hooks/use-register-fonts.test.ts | 45 +++++++++++++++++ packages/pdf/src/hooks/use-register-fonts.ts | 21 +++++--- .../src/semantic/base-reset-fidelity.test.tsx | 8 ++- packages/pdf/src/semantic/base-styles.ts | 5 +- .../pdf/src/templates/scizor/ScizorPage.tsx | 4 +- .../shared/base-template-styles.test.ts | 49 +++++++++++++++++++ .../templates/shared/base-template-styles.ts | 8 ++- 9 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 packages/pdf/src/templates/shared/base-template-styles.test.ts diff --git a/packages/fonts/src/index.test.ts b/packages/fonts/src/index.test.ts index 4e64ac340..7f9e82f0b 100644 --- a/packages/fonts/src/index.test.ts +++ b/packages/fonts/src/index.test.ts @@ -8,6 +8,7 @@ import { getWebFont, getWebFontSource, isStandardPdfFontFamily, + resolveBoldFontWeight, resolveLegacyFontAlias, standardFontList, webFontList, @@ -312,3 +313,42 @@ describe("locale coverage fonts (#3098)", () => { expect(source).toEqual(expect.stringMatching(/\.ttf(\?|$)/)); }); }); + +describe("resolveBoldFontWeight (#3310)", () => { + it("prefers the family's true Bold face over the default Regular+SemiBold pairing", () => { + // Open Sans ships 300–800; the typography picker stores ["400", "600"], + // which rendered at SemiBold — nearly invisible next to Regular. + expect(resolveBoldFontWeight("Open Sans", ["400", "600"])).toBe("700"); + }); + + it("keeps a deliberate bold-class stored weight (>= 700)", () => { + expect(resolveBoldFontWeight("Open Sans", ["400", "800"])).toBe("800"); + }); + + it("is stable for families already stored with their Bold face", () => { + // PT Sans ships exactly ["400", "700"]; bold already renders correctly. + expect(resolveBoldFontWeight("PT Sans", ["400", "700"])).toBe("700"); + }); + + it("uses the heaviest face at or above SemiBold when the family has no Bold face", () => { + // Londrina Solid ships 100/300/400/900 — no 700, so its 900 is the + // only bold-class face available. + expect(resolveBoldFontWeight("Londrina Solid", ["300", "400"])).toBe("900"); + }); + + it("returns null when the family has no bold-class face at all", () => { + // Archivo Black ships a single 400 face; callers keep their fallback. + expect(resolveBoldFontWeight("Archivo Black", ["400"])).toBeNull(); + }); + + it("returns null for unknown families so callers keep their fallback", () => { + expect(resolveBoldFontWeight("Not A Real Font", ["400", "600"])).toBeNull(); + expect(resolveBoldFontWeight("", ["400"])).toBeNull(); + }); + + it("resolves a PDF fallback stack by its primary family", () => { + // CJK fallback stacks widen fontFamily to string[] (#2986); the + // user-chosen primary family decides the bold weight. + expect(resolveBoldFontWeight(["Open Sans", "Noto Sans"], ["400", "600"])).toBe("700"); + }); +}); diff --git a/packages/fonts/src/index.ts b/packages/fonts/src/index.ts index 5181210c2..1c7d4a4a6 100644 --- a/packages/fonts/src/index.ts +++ b/packages/fonts/src/index.ts @@ -177,6 +177,50 @@ export function sortFontWeights(fontWeights: T[]): T[] { return [...fontWeights].sort((a, b) => Number(a) - Number(b)); } +/** + * Resolves the font weight used for bold text (``, rich-text bold + * and the template `bold` styles). + * + * The last stored body weight is ambiguous: families are commonly stored as + * `["400", "600"]` (the default pairing from the typography picker), which + * renders `` at SemiBold — nearly indistinguishable from Regular for + * faces like Open Sans (#3310). Bold text should use the family's true Bold + * face when one exists. + * + * Resolution order: + * 1. A stored weight at or above Bold (700) that the family actually has — + * that is a deliberate bold-class choice by the user, so keep it. + * 2. The family's true Bold face ("700"). + * 3. The heaviest available face at or above SemiBold (600). + * 4. `null` — the family has no bold-class face; callers keep their existing + * `fontWeights.at(-1)` fallback. + * + * `family` may be a PDF fallback stack (`string[]`, see #2986); the primary + * (first) family decides because `fontWeight` applies across the stack. + */ +export function resolveBoldFontWeight(family: string | string[], storedWeights: readonly string[]): FontWeight | null { + const familyName = Array.isArray(family) ? family[0] : family; + if (!familyName) return null; + + const weights = getFont(familyName)?.weights; + if (!weights || weights.length === 0) return null; + + const available = new Set(weights); + + const deliberateBoldClass = sortFontWeights( + storedWeights.filter( + (weight): weight is FontWeight => available.has(weight as FontWeight) && Number(weight) >= 700, + ), + ); + const heaviestDeliberate = deliberateBoldClass[deliberateBoldClass.length - 1]; + if (heaviestDeliberate) return heaviestDeliberate; + + if (available.has("700")) return "700"; + + const boldClass = sortFontWeights(weights.filter((weight) => Number(weight) >= 600)); + return boldClass[boldClass.length - 1] ?? null; +} + /** * Returns an ordered stack of Noto web fonts to register as glyph-level * fallbacks for PDF rendering. react-pdf resolves the font per-codepoint diff --git a/packages/pdf/src/hooks/use-register-fonts.test.ts b/packages/pdf/src/hooks/use-register-fonts.test.ts index be9f90427..0b64839d9 100644 --- a/packages/pdf/src/hooks/use-register-fonts.test.ts +++ b/packages/pdf/src/hooks/use-register-fonts.test.ts @@ -71,6 +71,30 @@ describe("registerFonts", () => { ); }); + it("registers the family's true Bold face when the stored weights stop below it (#3310)", async () => { + const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {}); + vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {}); + const { registerFonts } = await import("./use-register-fonts"); + + // Open Sans stored with the default Regular+SemiBold pairing: bold + // styles resolve to 700, so that face must be registered or + // @react-pdf/renderer would silently fall back to the nearest weight. + const openSansTypography = { + ...typography, + body: { ...typography.body, fontFamily: "Open Sans", fontWeights: ["400", "600"] }, + heading: { ...typography.heading, fontFamily: "Open Sans", fontWeights: ["400", "600"] }, + } satisfies Typography; + + registerFonts(openSansTypography, "en-US"); + + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ family: "Open Sans", fontWeight: 700, fontStyle: "normal" }), + ); + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ family: "Open Sans", fontWeight: 700, fontStyle: "italic" }), + ); + }); + it("registers the Korean Noto fallback for the ko-KR locale so Hangul renders", async () => { const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {}); vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {}); @@ -198,6 +222,27 @@ describe("registerFonts", () => { expect(hyphenationCallback?.("سلام")).toEqual(["سلام"]); }); + it("registers the fallback family's true Bold face when primary bold exceeds stored weights (#3310)", async () => { + const registerSpy = vi.spyOn(Font, "register").mockImplementation(() => {}); + vi.spyOn(Font, "registerHyphenationCallback").mockImplementation(() => {}); + const { registerFonts } = await import("./use-register-fonts"); + + const openSansTypography = { + ...typography, + body: { ...typography.body, fontFamily: "Open Sans", fontWeights: ["400", "600"] }, + heading: { ...typography.heading, fontFamily: "Open Sans", fontWeights: ["400", "600"] }, + } satisfies Typography; + + registerFonts(openSansTypography, "zh-CN"); + + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ family: "Noto Sans SC", fontWeight: 700, fontStyle: "normal" }), + ); + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ family: "Noto Sans SC", fontWeight: 700, fontStyle: "italic" }), + ); + }); + it("registers bold CJK fallback variants so strong text keeps bold glyphs", 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 aafc2c475..2818d444f 100644 --- a/packages/pdf/src/hooks/use-register-fonts.ts +++ b/packages/pdf/src/hooks/use-register-fonts.ts @@ -7,6 +7,7 @@ import { getPdfFallbackFontFamilies, getWebFontSource, isStandardPdfFontFamily, + resolveBoldFontWeight, resolveLegacyFontAlias, sortFontWeights, } from "@reactive-resume/fonts"; @@ -237,6 +238,11 @@ export const registerFonts = ( const headingFontFamily = pdfTypography.heading.fontFamily; const bodyRange = getFontWeightRange(pdfTypography.body.fontWeights); const headingRange = getFontWeightRange(pdfTypography.heading.fontWeights); + // Bold styles resolve to the family's true Bold face when one exists + // (#3310), which can be heavier than the stored weight range (e.g. + // ["400", "600"] for Open Sans) — make sure that face is registered or + // @react-pdf/renderer would silently fall back to the nearest one. + const bodyBoldWeight = resolveBoldFontWeight(bodyFontFamily, pdfTypography.body.fontWeights); const registerFont = (family: string, weight: number, italic = false) => { if (isStandardPdfFontFamily(family)) return; @@ -256,6 +262,7 @@ export const registerFonts = ( for (const italic of [false, true]) { registerFont(bodyFontFamily, bodyRange.lowest, italic); registerFont(bodyFontFamily, bodyRange.highest, italic); + if (bodyBoldWeight) registerFont(bodyFontFamily, Number(bodyBoldWeight), italic); registerFont(headingFontFamily, headingRange.lowest, italic); registerFont(headingFontFamily, headingRange.highest, italic); } @@ -274,10 +281,12 @@ export const registerFonts = ( const bodyFallbacks = getPdfFallbackFontFamilies(bodyFontFamily, { locale, scripts: fallbackScripts }); const headingFallbacks = getPdfFallbackFontFamilies(headingFontFamily, { locale, scripts: fallbackScripts }); - const registerFallbacks = (families: string[], ranges: FontWeightRange[]) => { - const weights = new Set(ranges.flatMap(({ lowest, highest }) => [lowest, highest])); - + const registerFallbacks = (families: string[], ranges: FontWeightRange[], storedWeights: readonly string[]) => { for (const family of families) { + const weights = new Set(ranges.flatMap(({ lowest, highest }) => [lowest, highest])); + const fallbackBoldWeight = resolveBoldFontWeight(family, storedWeights); + if (fallbackBoldWeight) weights.add(Number(fallbackBoldWeight)); + for (const weight of weights) { registerFont(family, weight, false); registerFont(family, weight, true); @@ -290,10 +299,10 @@ export const registerFonts = ( bodyFallbacks.every((family, index) => family === headingFallbacks[index]); if (sameStack) { - registerFallbacks(bodyFallbacks, [bodyRange, headingRange]); + registerFallbacks(bodyFallbacks, [bodyRange, headingRange], pdfTypography.body.fontWeights); } else { - registerFallbacks(bodyFallbacks, [bodyRange]); - registerFallbacks(headingFallbacks, [headingRange]); + registerFallbacks(bodyFallbacks, [bodyRange], pdfTypography.body.fontWeights); + registerFallbacks(headingFallbacks, [headingRange], pdfTypography.heading.fontWeights); } // Latin-only path: no fallback registered, return as-is. diff --git a/packages/pdf/src/semantic/base-reset-fidelity.test.tsx b/packages/pdf/src/semantic/base-reset-fidelity.test.tsx index 1a2dba6b2..a604b7b0e 100644 --- a/packages/pdf/src/semantic/base-reset-fidelity.test.tsx +++ b/packages/pdf/src/semantic/base-reset-fidelity.test.tsx @@ -152,7 +152,11 @@ describe("PDF semantic base and reset fidelity", () => { }); it("restores Onyx's local company weight with revert", async () => { - expect(await finalOnyxCompanyStyle()).toMatchObject({ fontWeight: "500" }); - expect(await finalOnyxCompanyStyle("revert")).toMatchObject({ fontWeight: "500" }); + // The local value is the template's bold weight for the body family: + // IBM Plex Serif stored as ["400", "500"] resolves to its true Bold + // face (#3310) — still distinct from the inherited 400 and the initial + // undefined, so the reset-keyword contract below stays verifiable. + expect(await finalOnyxCompanyStyle()).toMatchObject({ fontWeight: "700" }); + expect(await finalOnyxCompanyStyle("revert")).toMatchObject({ fontWeight: "700" }); }); }); diff --git a/packages/pdf/src/semantic/base-styles.ts b/packages/pdf/src/semantic/base-styles.ts index 2fd1318a6..9632e9796 100644 --- a/packages/pdf/src/semantic/base-styles.ts +++ b/packages/pdf/src/semantic/base-styles.ts @@ -1,6 +1,7 @@ import type { ResolvedNodeStyle, SemanticNode } from "@reactive-resume/resume/stylesheet"; import type { ResumeData } from "@reactive-resume/schema/resume/data"; import type { Template } from "@reactive-resume/schema/templates"; +import { resolveBoldFontWeight } from "@reactive-resume/fonts"; export type BuildPdfBaseStylesInput = { data: ResumeData; @@ -57,7 +58,9 @@ export function buildPdfBaseStyles({ const body = data.metadata.typography.body; const heading = data.metadata.typography.heading; const bodyWeight = body.fontWeights[0] ?? "400"; - const boldWeight = body.fontWeights.at(-1) ?? "600"; + // Bold must use the family's true Bold face when one exists; the last + // stored weight is only a fallback (#3310). + const boldWeight = resolveBoldFontWeight(body.fontFamily, body.fontWeights) ?? body.fontWeights.at(-1) ?? "600"; const headingWeight = heading.fontWeights.at(-1) ?? "600"; const visit = (node: SemanticNode) => { diff --git a/packages/pdf/src/templates/scizor/ScizorPage.tsx b/packages/pdf/src/templates/scizor/ScizorPage.tsx index af18cdd9b..9a80c199a 100644 --- a/packages/pdf/src/templates/scizor/ScizorPage.tsx +++ b/packages/pdf/src/templates/scizor/ScizorPage.tsx @@ -135,7 +135,9 @@ const useScizorTemplate = (): ScizorTemplate => { rowGap: metrics.sectionGap, }, heading: { ...base.heading, fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "700" }, - bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "700", color: foreground }, + // `base.bold` already resolves the family's true Bold face (#3310); + // scizor only adds its foreground color. + bold: { ...base.bold, color: foreground }, section: { flexDirection: "column", rowGap: metrics.gapY(0.25), diff --git a/packages/pdf/src/templates/shared/base-template-styles.test.ts b/packages/pdf/src/templates/shared/base-template-styles.test.ts new file mode 100644 index 000000000..db6d70778 --- /dev/null +++ b/packages/pdf/src/templates/shared/base-template-styles.test.ts @@ -0,0 +1,49 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import { describe, expect, it } from "vitest"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { createBaseTemplateStyles } from "./base-template-styles"; +import { getTemplateMetrics } from "./metrics"; +import { createRtlStyleHelpers } from "./rtl"; + +const boldWeightFor = ( + fontFamily: string, + fontWeights: ResumeData["metadata"]["typography"]["body"]["fontWeights"], +) => { + const metadata = { + ...defaultResumeData.metadata, + typography: { + ...defaultResumeData.metadata.typography, + body: { ...defaultResumeData.metadata.typography.body, fontFamily, fontWeights }, + }, + }; + + return createBaseTemplateStyles({ + metadata, + foreground: "#111111", + background: "#ffffff", + r: createRtlStyleHelpers(false), + metrics: getTemplateMetrics(metadata.page), + picture: defaultResumeData.picture, + }).bold.fontWeight; +}; + +describe("bold style weight (#3310)", () => { + it("uses the family's true Bold face when the stored weights stop below it", () => { + // The reporter's case: Open Sans stored as ["400", "600"] rendered + // at SemiBold, indistinguishable from the Regular body. + expect(boldWeightFor("Open Sans", ["400", "600"])).toBe("700"); + expect(boldWeightFor("Open Sans", ["400", "500"])).toBe("700"); + }); + + it("keeps a deliberate bold-class stored weight", () => { + expect(boldWeightFor("Open Sans", ["400", "800"])).toBe("800"); + }); + + it("stays on Bold for families stored with their Bold face", () => { + expect(boldWeightFor("PT Sans", ["400", "700"])).toBe("700"); + }); + + it("falls back to the last stored weight when no bold-class face exists", () => { + expect(boldWeightFor("Not A Real Font", ["400", "600"])).toBe("600"); + }); +}); diff --git a/packages/pdf/src/templates/shared/base-template-styles.ts b/packages/pdf/src/templates/shared/base-template-styles.ts index 051ef0fc6..75aee707a 100644 --- a/packages/pdf/src/templates/shared/base-template-styles.ts +++ b/packages/pdf/src/templates/shared/base-template-styles.ts @@ -2,6 +2,7 @@ import type { Style } from "@react-pdf/types"; import type { Picture, ResumeData } from "@reactive-resume/schema/resume/data"; import type { getTemplateMetrics } from "./metrics"; import type { createRtlStyleHelpers } from "./rtl"; +import { resolveBoldFontWeight } from "@reactive-resume/fonts"; import { rgbaStringToHex } from "@reactive-resume/utils/color"; type BaseTemplateStylesInput = { @@ -82,9 +83,12 @@ export function createBaseTemplateStyles({ fontSize: metadata.typography.body.fontSize * 0.875, } satisfies Style, - /** Default fallback "600". scizor overrides to "700". */ + /** True Bold face when the family has one (#3310); falls back to the last stored weight. scizor overrides color only. */ bold: { - fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600", + fontWeight: + resolveBoldFontWeight(metadata.typography.body.fontFamily, metadata.typography.body.fontWeights) ?? + metadata.typography.body.fontWeights.at(-1) ?? + "600", } satisfies Style, richParagraph: {