mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
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:
co-authored by
Amruth Pillai
parent
b47f805321
commit
e065a10824
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user