fix(pdf): resolve bold text weight from the family's bold face (#3335)

* fix(pdf): resolve bold text weight from the family's bold face

Bold text (<strong>, 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 <im.amruth@gmail.com>
This commit is contained in:
Santhi Prakash
2026-08-27 03:16:46 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent b47f805321
commit e065a10824
9 changed files with 212 additions and 12 deletions
+40
View File
@@ -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 <strong> 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");
});
});
+44
View File
@@ -177,6 +177,50 @@ export function sortFontWeights<T extends string>(fontWeights: T[]): T[] {
return [...fontWeights].sort((a, b) => Number(a) - Number(b));
}
/**
* Resolves the font weight used for bold text (`<strong>`, 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 `<strong>` 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<FontWeight>(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
@@ -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(() => {});
+15 -6
View File
@@ -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.
@@ -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" });
});
});
+4 -1
View File
@@ -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) => {
@@ -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),
@@ -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
// <strong> 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");
});
});
@@ -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: {