mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-17 00:30:05 +10:00
Merge branch 'main' of github.com:amruthpillai/reactive-resume
This commit is contained in:
@@ -323,7 +323,7 @@ const useGlalieTemplate = (): GlalieTemplate => {
|
||||
width: "100%",
|
||||
borderWidth: 1,
|
||||
borderColor: primary,
|
||||
borderRadius: picture.borderRadius / 4,
|
||||
borderRadius: 0,
|
||||
padding: metrics.gapX(0.75),
|
||||
rowGap: metrics.gapY(0.125),
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveLevelDisplaySizes } from "./level-display-sizes";
|
||||
|
||||
describe("resolveLevelDisplaySizes", () => {
|
||||
it("uses typography defaults when no custom font sizes are set", () => {
|
||||
expect(resolveLevelDisplaySizes({ bodyFontSize: 12 })).toEqual({
|
||||
decorationSize: 10,
|
||||
levelIconExplicitSize: 14,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the global icon font size for decorations and defers icon sizing to icon rules", () => {
|
||||
expect(resolveLevelDisplaySizes({ bodyFontSize: 12, iconFontSize: 20 })).toEqual({
|
||||
decorationSize: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the level font size for all level display visuals", () => {
|
||||
expect(resolveLevelDisplaySizes({ bodyFontSize: 12, iconFontSize: 20, levelFontSize: 16 })).toEqual({
|
||||
decorationSize: 16,
|
||||
levelIconExplicitSize: 16,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
export type ResolveLevelDisplaySizesOptions = {
|
||||
bodyFontSize: number;
|
||||
iconFontSize?: number | undefined;
|
||||
levelFontSize?: number | undefined;
|
||||
};
|
||||
|
||||
export type LevelDisplaySizes = {
|
||||
decorationSize: number;
|
||||
levelIconExplicitSize?: number | undefined;
|
||||
};
|
||||
|
||||
export const resolveLevelDisplaySizes = (options: ResolveLevelDisplaySizesOptions): LevelDisplaySizes => {
|
||||
const defaultDecorationSize = options.bodyFontSize - 2;
|
||||
const legacyLevelIconSize = defaultDecorationSize + 4;
|
||||
|
||||
const decorationSize = options.levelFontSize ?? options.iconFontSize ?? defaultDecorationSize;
|
||||
|
||||
if (options.levelFontSize !== undefined) {
|
||||
return { decorationSize, levelIconExplicitSize: options.levelFontSize };
|
||||
}
|
||||
|
||||
if (options.iconFontSize !== undefined) {
|
||||
return { decorationSize };
|
||||
}
|
||||
|
||||
return { decorationSize, levelIconExplicitSize: legacyLevelIconSize };
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "./default";
|
||||
import { resolveStyleRuleFontSize } from "./style-rules";
|
||||
|
||||
describe("resolveStyleRuleFontSize", () => {
|
||||
it("returns global icon font sizes without section context", () => {
|
||||
const data = {
|
||||
...defaultResumeData,
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
styleRules: [
|
||||
{
|
||||
id: "icon-global",
|
||||
label: "",
|
||||
enabled: true,
|
||||
target: { scope: "global" as const },
|
||||
slots: { icon: { fontSize: 22 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveStyleRuleFontSize(data, { slot: "icon" })).toBe(22);
|
||||
});
|
||||
|
||||
it("does not apply level font sizes to the icon slot", () => {
|
||||
const data = {
|
||||
...defaultResumeData,
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
styleRules: [
|
||||
{
|
||||
id: "level-global",
|
||||
label: "",
|
||||
enabled: true,
|
||||
target: { scope: "global" as const },
|
||||
slots: { level: { fontSize: 18 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveStyleRuleFontSize(data, { slot: "icon" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { CustomSectionType, ResumeData, SectionType, StyleIntent, StyleSlot } from "./data";
|
||||
|
||||
export type SectionStyleRuleContext = {
|
||||
sectionId: string;
|
||||
sectionType?: CustomSectionType | undefined;
|
||||
};
|
||||
|
||||
export type ResolveStyleRuleSlotOptions = {
|
||||
slot: StyleSlot;
|
||||
sectionId?: string | undefined;
|
||||
sectionType?: CustomSectionType | undefined;
|
||||
};
|
||||
|
||||
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));
|
||||
|
||||
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 resolveStyleIntentForSlot = (data: ResumeData, options: ResolveStyleRuleSlotOptions): StyleIntent => {
|
||||
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) => rule.slots[options.slot]));
|
||||
};
|
||||
|
||||
export const resolveStyleRuleFontSize = (
|
||||
data: ResumeData,
|
||||
options: ResolveStyleRuleSlotOptions,
|
||||
): number | undefined => {
|
||||
const fontSize = resolveStyleIntentForSlot(data, options).fontSize;
|
||||
if (fontSize === undefined) return undefined;
|
||||
|
||||
return clamp(fontSize, 6, 48);
|
||||
};
|
||||
Reference in New Issue
Block a user