s.values.type);
const previewIcon = useStore(form.store, (s) => s.values.icon);
+ const iconFontSize = resolveStyleRuleFontSize(resume.data, { slot: "icon" });
+ const levelFontSize = resolveStyleRuleFontSize(resume.data, { slot: "level" });
+ const { decorationSize, levelIconExplicitSize } = resolveLevelDisplaySizes({
+ bodyFontSize: resume.data.metadata.typography.body.fontSize,
+ iconFontSize,
+ levelFontSize,
+ });
return (
diff --git a/packages/pdf/src/templates/shared/icon-size.test.ts b/packages/pdf/src/templates/shared/icon-size.test.ts
new file mode 100644
index 000000000..338f79618
--- /dev/null
+++ b/packages/pdf/src/templates/shared/icon-size.test.ts
@@ -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);
+ });
+});
diff --git a/packages/pdf/src/templates/shared/icon-size.ts b/packages/pdf/src/templates/shared/icon-size.ts
new file mode 100644
index 000000000..8929934ff
--- /dev/null
+++ b/packages/pdf/src/templates/shared/icon-size.ts
@@ -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 ?? []));
+};
diff --git a/packages/pdf/src/templates/shared/level-display.tsx b/packages/pdf/src/templates/shared/level-display.tsx
index 7472c265e..aff4d85cc 100644
--- a/packages/pdf/src/templates/shared/level-display.tsx
+++ b/packages/pdf/src/templates/shared/level-display.tsx
@@ -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 (
@@ -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,
diff --git a/packages/pdf/src/templates/shared/primitives.tsx b/packages/pdf/src/templates/shared/primitives.tsx
index b3ded8590..655049192 100644
--- a/packages/pdf/src/templates/shared/primitives.tsx
+++ b/packages/pdf/src/templates/shared/primitives.tsx
@@ -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
) => {
);
};
-export const Icon = ({ style, ...props }: ComponentProps) => {
- const { style: iconStyle, ...iconProps } = useTemplateIconSlot("icon");
+export const Icon = ({ style, size: sizeProp, ...props }: ComponentProps) => {
+ 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) =
);
};
diff --git a/packages/pdf/src/templates/shared/style-rules.ts b/packages/pdf/src/templates/shared/style-rules.ts
index 1d946b65f..dd411c775 100644
--- a/packages/pdf/src/templates/shared/style-rules.ts
+++ b/packages/pdf/src/templates/shared/style-rules.ts
@@ -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([
- "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));
};
diff --git a/packages/schema/src/resume/level-display-sizes.test.ts b/packages/schema/src/resume/level-display-sizes.test.ts
new file mode 100644
index 000000000..148f36ca0
--- /dev/null
+++ b/packages/schema/src/resume/level-display-sizes.test.ts
@@ -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,
+ });
+ });
+});
diff --git a/packages/schema/src/resume/level-display-sizes.ts b/packages/schema/src/resume/level-display-sizes.ts
new file mode 100644
index 000000000..fc1ddba04
--- /dev/null
+++ b/packages/schema/src/resume/level-display-sizes.ts
@@ -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 };
+};
diff --git a/packages/schema/src/resume/style-rules.test.ts b/packages/schema/src/resume/style-rules.test.ts
new file mode 100644
index 000000000..65df3e083
--- /dev/null
+++ b/packages/schema/src/resume/style-rules.test.ts
@@ -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();
+ });
+});
diff --git a/packages/schema/src/resume/style-rules.ts b/packages/schema/src/resume/style-rules.ts
new file mode 100644
index 000000000..0cdb5c84e
--- /dev/null
+++ b/packages/schema/src/resume/style-rules.ts
@@ -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([
+ "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);
+};