mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 00:43:29 +10:00
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:
@@ -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));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user