fix(pdf): apply custom style fontSize to icons and level indicators (#3120) and

* fix(pdf): apply custom style fontSize to icon and level indicator sizes

Map fontSize from Icon and Level Indicator custom style slots to Phosphor
icon size and level indicator dimensions, since react-pdf icons ignore
fontSize in favor of the size prop.

* fix: separate global icon and scoped level indicator font sizes

Icon slot fontSize now drives all resume icons plus level display
decorations. Level indicator fontSize overrides only within level display.
Shared sizing logic lives in schema; design sidebar preview uses global rules.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Amruth Pillai
2026-05-29 00:41:21 +02:00
committed by GitHub
parent c1d11236ae
commit 1414fecade
12 changed files with 350 additions and 72 deletions
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { parseStyleFontSize, resolveIconSize, resolveStyleFontSize } from "./icon-size";
describe("parseStyleFontSize", () => {
it("parses numeric and px font sizes", () => {
expect(parseStyleFontSize(14)).toBe(14);
expect(parseStyleFontSize("18px")).toBe(18);
expect(parseStyleFontSize("invalid")).toBeUndefined();
});
});
describe("resolveStyleFontSize", () => {
it("returns the last font size across composed style inputs", () => {
expect(resolveStyleFontSize({ fontSize: 10 }, [{ fontSize: 12 }, { fontSize: 16 }])).toBe(16);
});
});
describe("resolveIconSize", () => {
it("prefers an explicit size over custom style font sizes", () => {
expect(
resolveIconSize({
size: 20,
styles: [{ fontSize: 12 }],
}),
).toBe(20);
});
it("falls back to custom style font sizes when size is omitted", () => {
expect(
resolveIconSize({
styles: [{ fontSize: 10 }, { fontSize: 18 }],
}),
).toBe(18);
});
it("allows a template default size to be supplied by the caller", () => {
expect(
resolveIconSize({
styles: [{ fontSize: 12 }],
}) ?? 10,
).toBe(12);
expect(resolveIconSize({ styles: [] }) ?? 10).toBe(10);
});
});
@@ -0,0 +1,36 @@
import type { Style } from "@react-pdf/types";
import type { StyleInput } from "./styles";
import { composeStyles } from "./styles";
const parseFiniteNumber = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) ? value : undefined;
const parsePxValue = (value: unknown): number | undefined => {
if (typeof value !== "string" || !value.endsWith("px")) return undefined;
const parsedValue = Number.parseFloat(value);
return Number.isFinite(parsedValue) ? parsedValue : undefined;
};
export const parseStyleFontSize = (fontSize: Style["fontSize"] | undefined): number | undefined =>
parseFiniteNumber(fontSize) ?? parsePxValue(fontSize);
export const resolveStyleFontSize = (...styles: StyleInput[]): number | undefined => {
for (let index = styles.length - 1; index >= 0; index -= 1) {
for (const style of composeStyles(styles[index]).toReversed()) {
const fontSize = parseStyleFontSize(style.fontSize);
if (fontSize !== undefined) return fontSize;
}
}
return undefined;
};
export const resolveIconSize = (options: {
size?: number | string | undefined;
styles?: StyleInput[];
}): number | string | undefined => {
if (options.size !== undefined) return options.size;
return resolveStyleFontSize(...(options.styles ?? []));
};
@@ -1,8 +1,10 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import { resolveLevelDisplaySizes } from "@reactive-resume/schema/resume/level-display-sizes";
import { useRender } from "../../context";
import { View } from "../../renderer";
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
import { resolveStyleFontSize } from "./icon-size";
import { getTemplateMetrics } from "./metrics";
import { Icon } from "./primitives";
import { composeStyles } from "./styles";
@@ -16,14 +18,19 @@ type LevelDisplayProps = {
export const LevelDisplay = ({ level }: LevelDisplayProps) => {
const data = useRender();
const levelDesign = data.metadata.design.level;
const iconSize = data.metadata.typography.body.fontSize - 2;
const metrics = getTemplateMetrics(data.metadata.page);
const iconProps = useTemplateIconSlot("icon");
const levelContainerStyle = useTemplateStyle("levelContainer");
const levelItemStyle = useTemplateStyle("levelItem");
const levelItemActiveStyle = useTemplateStyle("levelItemActive");
const levelItemInactiveStyle = useTemplateStyle("levelItemInactive");
const iconRuleStyle = useSectionStyleRule("icon");
const levelRuleStyle = useSectionStyleRule("level");
const { decorationSize, levelIconExplicitSize } = resolveLevelDisplaySizes({
bodyFontSize: data.metadata.typography.body.fontSize,
iconFontSize: resolveStyleFontSize(iconRuleStyle),
levelFontSize: resolveStyleFontSize(levelRuleStyle),
});
const color = typeof iconProps.color === "string" ? iconProps.color : "#000000";
if (level === 0) return null;
@@ -55,7 +62,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
return (
<Icon
key={itemKey}
size={iconSize + 4}
{...(levelIconExplicitSize === undefined ? {} : { size: levelIconExplicitSize })}
name={levelDesign.icon as IconName}
style={{ opacity: isActive ? 1 : 0.35 }}
/>
@@ -69,7 +76,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
style={composeStyles(
{
flex: 1,
height: iconSize,
height: decorationSize,
borderWidth: 0.75,
borderColor: color,
backgroundColor: isActive ? color : "transparent",
@@ -83,7 +90,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
const itemStyle: Style = {};
let borderRadius = 0;
let width: string | number = iconSize;
let width: string | number = decorationSize;
if (levelDesign.type === "rectangle") {
width = 16;
@@ -104,7 +111,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => {
style={composeStyles(
{
width,
height: iconSize,
height: decorationSize,
borderWidth: 0.75,
borderColor: color,
borderRadius,
@@ -4,6 +4,7 @@ import type { StyleInput } from "./styles";
import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic";
import { Link as PdfLink, Text as PdfText, View } from "../../renderer";
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
import { resolveIconSize } from "./icon-size";
import { safeTextStyle } from "./safe-text-style";
import { composeLinkStyles, composeStyles } from "./styles";
@@ -64,9 +65,17 @@ export const Bold = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
);
};
export const Icon = ({ style, ...props }: ComponentProps<typeof PhosphorIcon>) => {
const { style: iconStyle, ...iconProps } = useTemplateIconSlot("icon");
export const Icon = ({ style, size: sizeProp, ...props }: ComponentProps<typeof PhosphorIcon>) => {
const { style: iconStyle, size: templateSize, ...iconProps } = useTemplateIconSlot("icon");
const iconRuleStyle = useSectionStyleRule("icon");
const composedStyle = composeStyles(asStyleInput(iconStyle), iconRuleStyle, asStyleInput(style));
const templateIconSize =
typeof templateSize === "number" || typeof templateSize === "string" ? templateSize : undefined;
const resolvedSize =
resolveIconSize({
size: sizeProp,
styles: [iconRuleStyle, asStyleInput(style)],
}) ?? templateIconSize;
if (iconProps.display === "none") return null;
@@ -74,7 +83,8 @@ export const Icon = ({ style, ...props }: ComponentProps<typeof PhosphorIcon>) =
<PhosphorIcon
{...iconProps}
{...props}
style={composeStyles(asStyleInput(iconStyle), iconRuleStyle, asStyleInput(style))}
{...(resolvedSize === undefined ? {} : { size: resolvedSize })}
style={composedStyle}
/>
);
};
@@ -1,22 +1,17 @@
import type { Style } from "@react-pdf/types";
import type {
CustomSectionType,
ResumeData,
SectionType,
StyleIntent,
StyleSlot,
} from "@reactive-resume/schema/resume/data";
import type { ResumeData, StyleIntent, StyleSlot } from "@reactive-resume/schema/resume/data";
import type { SectionStyleRuleContext } from "@reactive-resume/schema/resume/style-rules";
import { getSectionStyleRuleContext, resolveStyleIntentForSlot } from "@reactive-resume/schema/resume/style-rules";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
export type SectionStyleRuleContext = {
sectionId: string;
sectionType?: CustomSectionType | undefined;
};
export type { SectionStyleRuleContext };
export type ResolveStyleRuleSlotOptions = SectionStyleRuleContext & {
slot: StyleSlot;
};
export { getSectionStyleRuleContext };
const spacingProperties = [
"padding",
"paddingTop",
@@ -42,21 +37,6 @@ const spacingPropertyRange = (property: (typeof spacingProperties)[number]) => {
return { min: -72, max: 72 };
};
const builtInSectionTypes = new Set<SectionType>([
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
]);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const toPdfColor = (value: string) => {
@@ -98,37 +78,6 @@ const toStyle = (intent: StyleIntent | undefined): Style => {
return style;
};
export const getSectionStyleRuleContext = (data: ResumeData, sectionId: string): SectionStyleRuleContext => {
if (sectionId === "summary") return { sectionId, sectionType: "summary" };
if (builtInSectionTypes.has(sectionId as SectionType)) {
return { sectionId, sectionType: sectionId as SectionType };
}
const customSection = data.customSections.find((section) => section.id === sectionId);
return { sectionId, sectionType: customSection?.type };
};
export const resolveStyleRuleSlot = (data: ResumeData, options: ResolveStyleRuleSlotOptions): Style => {
const matchingRules = (data.metadata.styleRules ?? []).filter((rule) => {
if (!rule.enabled) return false;
if (!rule.slots[options.slot]) return false;
if (rule.target.scope === "global") return true;
if (rule.target.scope === "sectionType") return rule.target.sectionType === options.sectionType;
if (rule.target.scope === "sectionId") return rule.target.sectionId === options.sectionId;
return false;
});
const specificity = { global: 0, sectionType: 1, sectionId: 2 } satisfies Record<
"global" | "sectionType" | "sectionId",
number
>;
const bySpecificity = [...matchingRules].sort((a, b) => {
return specificity[a.target.scope] - specificity[b.target.scope];
});
return Object.assign({}, ...bySpecificity.map((rule) => toStyle(rule.slots[options.slot])));
return toStyle(resolveStyleIntentForSlot(data, options));
};