feat: implement style rules

This commit is contained in:
Amruth Pillai
2026-05-27 10:57:33 +02:00
parent 7bff6644d8
commit b04eef1479
41 changed files with 2995 additions and 396 deletions
@@ -1,4 +1,6 @@
import type { StyleSlot } from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
import type { SectionStyleRuleContext } from "./style-rules";
import type { StyleInput, TemplatePlacement } from "./styles";
import type {
SectionTimelineStyleSlots,
@@ -11,6 +13,8 @@ import type {
TemplateStyleSlots,
} from "./types";
import { createContext, use, useMemo } from "react";
import { useRender } from "../../context";
import { resolveStyleRuleSlot } from "./style-rules";
type TemplateContextValue = {
styles: TemplateStyleSlots;
@@ -27,6 +31,7 @@ type TemplateProviderProps = Omit<TemplateContextValue, "featureStyles" | "featu
const TemplateContext = createContext<TemplateContextValue | null>(null);
const TemplatePlacementContext = createContext<TemplatePlacement>("main");
const SectionStyleContext = createContext<SectionStyleRuleContext | null>(null);
const resolveStyleSlot = (slot: TemplateStyleSlot | undefined, context: TemplateStyleContextValue): StyleInput => {
if (!slot) return undefined;
@@ -78,6 +83,16 @@ export const TemplatePlacementProvider = ({
return <TemplatePlacementContext.Provider value={placement}>{children}</TemplatePlacementContext.Provider>;
};
export const SectionStyleProvider = ({
context,
children,
}: {
context: SectionStyleRuleContext;
children: ReactNode;
}) => {
return <SectionStyleContext.Provider value={context}>{children}</SectionStyleContext.Provider>;
};
const useTemplateContext = () => {
const context = use(TemplateContext);
@@ -108,6 +123,15 @@ export const useTemplateStyle = (slot: keyof TemplateStyleSlots): StyleInput =>
return resolveStyleSlot(styles[slot] as TemplateStyleSlot | undefined, context);
};
export const useSectionStyleRule = (slot: StyleSlot): StyleInput => {
const data = useRender();
const context = use(SectionStyleContext);
if (!context) return undefined;
return resolveStyleRuleSlot(data, { ...context, slot });
};
export const useTemplateFeatureStyle = (
feature: keyof TemplateFeatureStyleSlots,
slot: keyof SectionTimelineStyleSlots,
@@ -2,7 +2,7 @@ import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import { useRender } from "../../context";
import { View } from "../../renderer";
import { useTemplateIconSlot, useTemplateStyle } from "./context";
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
import { getTemplateMetrics } from "./metrics";
import { Icon } from "./primitives";
import { composeStyles } from "./styles";
@@ -19,6 +19,7 @@ export const LevelDisplay = ({ level }: { level: number }) => {
const levelItemStyle = useTemplateStyle("levelItem");
const levelItemActiveStyle = useTemplateStyle("levelItemActive");
const levelItemInactiveStyle = useTemplateStyle("levelItemInactive");
const levelRuleStyle = useSectionStyleRule("level");
const color = typeof iconProps.color === "string" ? iconProps.color : "#000000";
if (level === 0) return null;
@@ -40,6 +41,7 @@ export const LevelDisplay = ({ level }: { level: number }) => {
style={composeStyles(
{ flexDirection: "row", alignItems: "center", marginTop: 2, columnGap: gap },
levelContainerStyle,
levelRuleStyle,
)}
>
{LEVEL_ITEM_KEYS.map((itemKey, index) => {
@@ -3,7 +3,7 @@ import type { ComponentProps } from "react";
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 { useTemplateIconSlot, useTemplateStyle } from "./context";
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
import { safeTextStyle } from "./safe-text-style";
import { composeLinkStyles, composeStyles } from "./styles";
@@ -17,40 +17,64 @@ export const Div = ({ style, ...props }: ComponentProps<typeof View>) => {
export const Text = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
const textStyle = useTemplateStyle("text");
const textRuleStyle = useSectionStyleRule("text");
return <PdfText style={composeStyles(textStyle, asStyleInput(style), safeTextStyle)} {...props} />;
return <PdfText style={composeStyles(textStyle, textRuleStyle, asStyleInput(style), safeTextStyle)} {...props} />;
};
export const Heading = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
const headingStyle = useTemplateStyle("heading");
const headingRuleStyle = useSectionStyleRule("heading");
return <PdfText style={composeStyles(headingStyle, asStyleInput(style), safeTextStyle)} {...props} />;
return (
<PdfText style={composeStyles(headingStyle, headingRuleStyle, asStyleInput(style), safeTextStyle)} {...props} />
);
};
export const Link = ({ style, ...props }: ComponentProps<typeof PdfLink>) => {
const linkStyle = useTemplateStyle("link");
const linkRuleStyle = useSectionStyleRule("link");
return <PdfLink style={composeLinkStyles(linkStyle, asStyleInput(style), safeTextStyle)} {...props} />;
return <PdfLink style={composeLinkStyles(linkStyle, linkRuleStyle, asStyleInput(style), safeTextStyle)} {...props} />;
};
export const Small = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
const textStyle = useTemplateStyle("text");
const smallStyle = useTemplateStyle("small");
const secondaryTextRuleStyle = useSectionStyleRule("secondaryText");
return <PdfText style={composeStyles(textStyle, smallStyle, asStyleInput(style), safeTextStyle)} {...props} />;
return (
<PdfText
style={composeStyles(textStyle, smallStyle, secondaryTextRuleStyle, asStyleInput(style), safeTextStyle)}
{...props}
/>
);
};
export const Bold = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
const textStyle = useTemplateStyle("text");
const boldStyle = useTemplateStyle("bold");
const textRuleStyle = useSectionStyleRule("text");
return <PdfText style={composeStyles(textStyle, boldStyle, asStyleInput(style), safeTextStyle)} {...props} />;
return (
<PdfText
style={composeStyles(textStyle, textRuleStyle, boldStyle, asStyleInput(style), safeTextStyle)}
{...props}
/>
);
};
export const Icon = ({ style, ...props }: ComponentProps<typeof PhosphorIcon>) => {
const { style: iconStyle, ...iconProps } = useTemplateIconSlot("icon");
const iconRuleStyle = useSectionStyleRule("icon");
if (iconProps.display === "none") return null;
return <PhosphorIcon {...iconProps} {...props} style={composeStyles(asStyleInput(iconStyle), asStyleInput(style))} />;
return (
<PhosphorIcon
{...iconProps}
{...props}
style={composeStyles(asStyleInput(iconStyle), iconRuleStyle, asStyleInput(style))}
/>
);
};
@@ -26,6 +26,12 @@ describe("normalizeRichTextHtml", () => {
expect(normalizeRichTextHtml(html)).toBe(html);
});
it("unwraps single paragraph wrappers inside list items", () => {
expect(normalizeRichTextHtml("<ul><li><p>a</p></li><li><p><strong>b</strong></p></li></ul>")).toBe(
"<ul><li>a</li><li><strong>b</strong></li></ul>",
);
});
it("flushes accumulated inlines before block-level tags", () => {
expect(normalizeRichTextHtml("loose<ul><li>a</li></ul>")).toBe("<p>loose</p><ul><li>a</li></ul>");
});
@@ -1,4 +1,4 @@
import type { Node } from "node-html-parser";
import type { HTMLElement, Node } from "node-html-parser";
import { NodeType, parse } from "node-html-parser";
export const richTextMarkClassName = "rr-pdf-mark";
@@ -50,6 +50,23 @@ const normalizeMarkElements = (root: ReturnType<typeof parse>) => {
}
};
const isMeaningfulNode = (node: Node): boolean =>
node.nodeType !== NodeType.TEXT_NODE || node.toString().trim().length > 0;
const isElement = (node: Node): node is HTMLElement => node.nodeType === NodeType.ELEMENT_NODE;
const unwrapSingleParagraphListItems = (root: ReturnType<typeof parse>) => {
for (const listItem of root.querySelectorAll("li")) {
const meaningfulChildren = listItem.childNodes.filter(isMeaningfulNode);
if (meaningfulChildren.length !== 1) continue;
const child = meaningfulChildren[0];
if (!child || !isElement(child) || getTagName(child) !== "p") continue;
listItem.innerHTML = child.innerHTML;
}
};
const isInlineNode = (node: Node): boolean => {
if (node.nodeType === NodeType.TEXT_NODE || node.nodeType === NodeType.COMMENT_NODE) return true;
if (node.nodeType !== NodeType.ELEMENT_NODE) return false;
@@ -98,6 +115,7 @@ export const normalizeRichTextHtml = (html: string): string => {
let inlineNodes: string[] = [];
normalizeMarkElements(root);
unwrapSingleParagraphListItems(root);
const flushInlineNodes = () => {
const inlineHtml = inlineNodes.join("").trim();
@@ -0,0 +1,52 @@
import type { Style } from "@react-pdf/types";
import type { StyleInput } from "./styles";
import { richTextMarkClassName } from "./rich-text-html";
import { safeTextStyle } from "./safe-text-style";
import { mergeLinkStyles, mergeStyles } from "./styles";
type RichTextProseSpacing = {
paragraph: Style;
listItem: Style;
};
type CreateRichTextStylesheetOptions = {
boldStyle?: StyleInput;
linkStyle?: StyleInput;
richParagraphStyle?: StyleInput;
richParagraphRuleStyle?: StyleInput;
richListRuleStyle?: StyleInput;
richBoldRuleStyle?: StyleInput;
richLinkRuleStyle?: StyleInput;
richMarkRuleStyle?: StyleInput;
proseSpacing?: RichTextProseSpacing;
};
const richMarkStyle = {
backgroundColor: "#ffff00",
} satisfies Style;
const emptyProseSpacing: RichTextProseSpacing = {
paragraph: {},
listItem: {},
};
export const createRichTextStylesheet = ({
boldStyle,
linkStyle,
richParagraphStyle,
richParagraphRuleStyle,
richListRuleStyle,
richBoldRuleStyle,
richLinkRuleStyle,
richMarkRuleStyle,
proseSpacing = emptyProseSpacing,
}: CreateRichTextStylesheetOptions = {}) => ({
b: mergeStyles(boldStyle, richBoldRuleStyle, safeTextStyle),
strong: mergeStyles(boldStyle, richBoldRuleStyle, safeTextStyle),
ul: mergeStyles(richListRuleStyle),
ol: mergeStyles(richListRuleStyle),
li: mergeStyles(proseSpacing.listItem),
[`.${richTextMarkClassName}`]: mergeStyles(richMarkStyle, richMarkRuleStyle, safeTextStyle),
p: mergeStyles(richParagraphStyle, richParagraphRuleStyle, safeTextStyle, proseSpacing.paragraph),
a: mergeLinkStyles(linkStyle, richLinkRuleStyle, safeTextStyle),
});
@@ -5,6 +5,7 @@ import { parse } from "node-html-parser";
import { createElement } from "react";
import { normalizeRichTextHtml } from "./rich-text-html";
import { renderRichTextParagraph } from "./rich-text-renderers";
import { createRichTextStylesheet } from "./rich-text-stylesheet";
type PdfElement = ReactElement<{ children?: unknown; style?: unknown }>;
@@ -22,13 +23,13 @@ describe("normalizeRichTextHtml", () => {
it("preserves existing block rich text", () => {
expect(normalizeRichTextHtml("<p>Existing paragraph.</p><ul><li><p>Existing item.</p></li></ul>")).toBe(
"<p>Existing paragraph.</p><ul><li><p>Existing item.</p></li></ul>",
"<p>Existing paragraph.</p><ul><li>Existing item.</li></ul>",
);
});
it("wraps inline runs around top-level blocks", () => {
expect(normalizeRichTextHtml("Intro <strong>text</strong><ul><li><p>Item</p></li></ul>Outro")).toBe(
"<p>Intro <strong>text</strong></p><ul><li><p>Item</p></li></ul><p>Outro</p>",
"<p>Intro <strong>text</strong></p><ul><li>Item</li></ul><p>Outro</p>",
);
});
});
@@ -50,3 +51,19 @@ describe("renderRichTextParagraph", () => {
expect(props.children).toEqual(["Plain ", expect.any(Object), " text"]);
});
});
describe("createRichTextStylesheet", () => {
it("applies list style rules to unordered and ordered list containers", () => {
const stylesheet = createRichTextStylesheet({
richListRuleStyle: { rowGap: 8 },
proseSpacing: {
paragraph: { marginTop: 12, marginBottom: 12 },
listItem: { marginTop: 2, marginBottom: 2 },
},
});
expect(stylesheet.ul).toEqual({ rowGap: 8 });
expect(stylesheet.ol).toEqual({ rowGap: 8 });
expect(stylesheet.li).toEqual({ marginTop: 2, marginBottom: 2 });
});
});
+47 -19
View File
@@ -4,8 +4,8 @@ import { cloneElement, isValidElement } from "react";
import { Html } from "react-pdf-html";
import { useRender } from "../../context";
import { Text as PdfText, View } from "../../renderer";
import { useTemplateStyle } from "./context";
import { convertPseudoBulletParagraphs, normalizeRichTextHtml, richTextMarkClassName } from "./rich-text-html";
import { useSectionStyleRule, useTemplateStyle } from "./context";
import { convertPseudoBulletParagraphs, normalizeRichTextHtml } from "./rich-text-html";
import { renderRichTextParagraph, toRichTextStyleArray } from "./rich-text-renderers";
import {
createRichTextProseSpacing,
@@ -14,17 +14,14 @@ import {
resolveRichTextBodyLineHeight,
stripRichTextVerticalMargins,
} from "./rich-text-spacing";
import { createRichTextStylesheet } from "./rich-text-stylesheet";
import { safeTextStyle } from "./safe-text-style";
import { composeStyles, mergeLinkStyles, mergeStyles } from "./styles";
import { composeStyles } from "./styles";
const richListItemContentStackStyle = {
flexDirection: "column",
} satisfies Style;
const richMarkStyle = {
backgroundColor: "#ffff00",
} satisfies Style;
// react-pdf textkit reads BiDi base direction from each run's own `direction` attribute
// (default "ltr"), and react-pdf-html buckets inline content into styleless inner <Text>
// frames — so the rtl style has to be injected onto every descendant, not just a wrapper.
@@ -65,7 +62,19 @@ export const RichText = ({ children }: { children: string }) => {
const richListItemRowStyle = useTemplateStyle("richListItemRow");
const richListItemMarkerStyle = useTemplateStyle("richListItemMarker");
const richListItemContentStyle = useTemplateStyle("richListItemContent");
const bodyLineHeight = resolveRichTextBodyLineHeight(richParagraphStyle, richListItemContentStyle);
const richParagraphRuleStyle = useSectionStyleRule("richParagraph");
const richListRuleStyle = useSectionStyleRule("richList");
const richListItemRowRuleStyle = useSectionStyleRule("richListItemRow");
const richListItemContentRuleStyle = useSectionStyleRule("richListItemContent");
const richLinkRuleStyle = useSectionStyleRule("richLink");
const richBoldRuleStyle = useSectionStyleRule("richBold");
const richMarkRuleStyle = useSectionStyleRule("richMark");
const bodyLineHeight = resolveRichTextBodyLineHeight(
richParagraphStyle,
richParagraphRuleStyle,
richListItemContentStyle,
richListItemContentRuleStyle,
);
const proseSpacing = createRichTextProseSpacing(bodyLineHeight);
const normalized = normalizeRichTextHtml(children);
@@ -85,7 +94,9 @@ export const RichText = ({ children }: { children: string }) => {
<Html
resetStyles
renderers={{
b: ({ children }) => <PdfText style={composeStyles(boldStyle, safeTextStyle)}>{children}</PdfText>,
b: ({ children }) => (
<PdfText style={composeStyles(boldStyle, richBoldRuleStyle, safeTextStyle)}>{children}</PdfText>
),
p: (props) => {
const paragraphProps = {
...props,
@@ -111,7 +122,13 @@ export const RichText = ({ children }: { children: string }) => {
const contentNode = rtl ? (
<PdfText
key="content"
style={composeStyles(richListItemContentStyle, contentItemStyles, safeTextStyle, rtlTextWrapStyle)}
style={composeStyles(
richListItemContentStyle,
richListItemContentRuleStyle,
contentItemStyles,
safeTextStyle,
rtlTextWrapStyle,
)}
>
{applyRtlDirectionRecursively(children)}
</PdfText>
@@ -120,6 +137,7 @@ export const RichText = ({ children }: { children: string }) => {
key="content"
style={composeStyles(
richListItemContentStyle,
richListItemContentRuleStyle,
contentItemStyles,
richListItemContentStackStyle,
safeTextStyle,
@@ -132,20 +150,30 @@ export const RichText = ({ children }: { children: string }) => {
// Yoga ignores `flexDirection`/`direction` on rows inside react-pdf-html's <ul>
// (works fine for split-row/contact-list). Swap DOM order to position the marker.
return (
<View style={composeStyles(richListItemRowStyle, itemStyles, getRichTextEdgeTrimStyle(element))}>
<View
style={composeStyles(
richListItemRowStyle,
richListItemRowRuleStyle,
itemStyles,
getRichTextEdgeTrimStyle(element),
)}
>
{rtl ? [contentNode, markerNode] : [markerNode, contentNode]}
</View>
);
},
}}
stylesheet={{
b: mergeStyles(boldStyle, safeTextStyle),
strong: mergeStyles(boldStyle, safeTextStyle),
li: mergeStyles(proseSpacing.listItem),
[`.${richTextMarkClassName}`]: mergeStyles(richMarkStyle, safeTextStyle),
p: mergeStyles(richParagraphStyle, safeTextStyle, proseSpacing.paragraph),
a: mergeLinkStyles(linkStyle, safeTextStyle),
}}
stylesheet={createRichTextStylesheet({
boldStyle,
linkStyle,
richParagraphStyle,
richParagraphRuleStyle,
richListRuleStyle,
richBoldRuleStyle,
richLinkRuleStyle,
richMarkRuleStyle,
proseSpacing,
})}
>
{html}
</Html>
+31 -21
View File
@@ -27,7 +27,9 @@ import { getResumeSectionTitle } from "../../section-title";
import { getSectionItemRows, getSectionItemsLayout, shouldUseSectionTimeline } from "./columns";
import { getWebsiteDisplayText } from "./contact";
import {
SectionStyleProvider,
TemplatePlacementProvider,
useSectionStyleRule,
useTemplateFeature,
useTemplateFeatureStyle,
useTemplatePlacement,
@@ -41,6 +43,7 @@ import { RichText } from "./rich-text";
import { createRtlStyleHelpers } from "./rtl";
import { getInlineItemWebsiteUrl, shouldRenderSeparateItemWebsite } from "./section-links";
import { hasSplitRowText, promoteSplitRowRight } from "./split-row";
import { getSectionStyleRuleContext } from "./style-rules";
import { composeStyles } from "./styles";
type SectionItemsContextValue = {
@@ -88,12 +91,16 @@ const SectionShell = ({
}) => {
const data = useRender();
const sectionStyle = useTemplateStyle("section");
const sectionRuleStyle = useSectionStyleRule("section");
const sectionHeadingStyle = useTemplateStyle("sectionHeading");
const sectionHeadingRuleStyle = useSectionStyleRule("heading");
const sectionTitle = getResumeSectionTitle(data, sectionId, title);
return (
<View style={composeStyles(sectionStyle)}>
{showHeading && <Heading style={composeStyles(sectionHeadingStyle)}>{sectionTitle}</Heading>}
<View style={composeStyles(sectionStyle, sectionRuleStyle)}>
{showHeading && (
<Heading style={composeStyles(sectionHeadingStyle, sectionHeadingRuleStyle)}>{sectionTitle}</Heading>
)}
{children}
</View>
);
@@ -160,13 +167,14 @@ const SectionItems = ({ children, columns = 1 }: { children: ReactNode; columns?
const SectionItem = ({ children, style }: { children: ReactNode; style?: StyleInput }) => {
const { itemStyle: sectionItemStyle, useTimeline } = useSectionItemsContext();
const itemStyle = useTemplateStyle("item");
const itemRuleStyle = useSectionStyleRule("item");
const timelineItemStyle = useTemplateFeatureStyle("sectionTimeline", "item");
const timelineMarkerStyle = useTemplateFeatureStyle("sectionTimeline", "marker");
const timelineDotStyle = useTemplateFeatureStyle("sectionTimeline", "dot");
const timelineContentStyle = useTemplateFeatureStyle("sectionTimeline", "content");
if (!useTimeline) {
return <Div style={composeStyles(itemStyle, sectionItemStyle, style)}>{children}</Div>;
return <Div style={composeStyles(itemStyle, itemRuleStyle, sectionItemStyle, style)}>{children}</Div>;
}
return (
@@ -174,7 +182,7 @@ const SectionItem = ({ children, style }: { children: ReactNode; style?: StyleIn
<View style={composeStyles(timelineMarkerStyle)}>
<View style={composeStyles(timelineDotStyle)} />
</View>
<Div style={composeStyles(itemStyle, timelineContentStyle, style)}>{children}</Div>
<Div style={composeStyles(itemStyle, itemRuleStyle, timelineContentStyle, style)}>{children}</Div>
</View>
);
};
@@ -924,23 +932,25 @@ export const Section = ({
return (
<TemplatePlacementProvider placement={placement}>
{match(section)
.with("summary", () => <SummarySection showHeading={showHeading} />)
.with("profiles", () => <ProfileSection />)
.with("experience", () => <ExperienceSection />)
.with("education", () => <EducationSection />)
.with("projects", () => <ProjectsSection />)
.with("skills", () => <SkillsSection />)
.with("languages", () => <LanguagesSection />)
.with("interests", () => <InterestsSection />)
.with("awards", () => <AwardsSection />)
.with("certifications", () => <CertificationsSection />)
.with("publications", () => <PublicationsSection />)
.with("volunteer", () => <VolunteerSection />)
.with("references", () => <ReferencesSection />)
.otherwise(() => (
<CustomSection sectionId={section} showHeading={showHeading} />
))}
<SectionStyleProvider context={getSectionStyleRuleContext(data, section)}>
{match(section)
.with("summary", () => <SummarySection showHeading={showHeading} />)
.with("profiles", () => <ProfileSection />)
.with("experience", () => <ExperienceSection />)
.with("education", () => <EducationSection />)
.with("projects", () => <ProjectsSection />)
.with("skills", () => <SkillsSection />)
.with("languages", () => <LanguagesSection />)
.with("interests", () => <InterestsSection />)
.with("awards", () => <AwardsSection />)
.with("certifications", () => <CertificationsSection />)
.with("publications", () => <PublicationsSection />)
.with("volunteer", () => <VolunteerSection />)
.with("references", () => <ReferencesSection />)
.otherwise(() => (
<CustomSection sectionId={section} showHeading={showHeading} />
))}
</SectionStyleProvider>
</TemplatePlacementProvider>
);
};
@@ -0,0 +1,172 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { getSectionStyleRuleContext, resolveStyleRuleSlot } from "./style-rules";
const createResumeData = (styleRules: ResumeData["metadata"]["styleRules"]): ResumeData => ({
...defaultResumeData,
metadata: {
...defaultResumeData.metadata,
styleRules,
},
});
describe("resolveStyleRuleSlot", () => {
it("applies global, section-type, and section-id rules in specificity order", () => {
const data = createResumeData([
{
id: "global-heading",
label: "Global Heading",
enabled: true,
target: { scope: "global" },
slots: { heading: { color: "rgba(0, 0, 0, 1)", fontSize: 12 } },
},
{
id: "experience-heading",
label: "Experience Heading",
enabled: true,
target: { scope: "sectionType", sectionType: "experience" },
slots: { heading: { color: "rgba(220, 38, 38, 1)" } },
},
{
id: "specific-heading",
label: "Specific Heading",
enabled: true,
target: { scope: "sectionId", sectionId: "experience" },
slots: { heading: { fontSize: 15 } },
},
]);
expect(
resolveStyleRuleSlot(data, {
sectionId: "experience",
sectionType: "experience",
slot: "heading",
}),
).toEqual({ color: "#dc2626", fontSize: 15 });
});
it("ignores disabled rules and non-matching deleted section ids", () => {
const data = createResumeData([
{
id: "disabled",
label: "Disabled",
enabled: false,
target: { scope: "global" },
slots: { section: { backgroundColor: "rgba(0, 0, 0, 1)" } },
},
{
id: "missing",
label: "Missing",
enabled: true,
target: { scope: "sectionId", sectionId: "missing-section" },
slots: { section: { backgroundColor: "rgba(220, 38, 38, 1)" } },
},
]);
expect(
resolveStyleRuleSlot(data, {
sectionId: "experience",
sectionType: "experience",
slot: "section",
}),
).toEqual({});
});
it("resolves custom section types for section-type rules", () => {
const data = createResumeData([
{
id: "custom-summary-rich-text",
label: "Custom Summary Rich Text",
enabled: true,
target: { scope: "sectionType", sectionType: "summary" },
slots: { richParagraph: { color: "rgba(21, 93, 252, 1)" } },
},
]);
const customSection = {
...defaultResumeData.summary,
id: "custom-summary",
type: "summary" as const,
items: [],
};
const customData = { ...data, customSections: [customSection] };
expect(getSectionStyleRuleContext(customData, "custom-summary")).toEqual({
sectionId: "custom-summary",
sectionType: "summary",
});
expect(
resolveStyleRuleSlot(customData, {
sectionId: "custom-summary",
sectionType: "summary",
slot: "richParagraph",
}),
).toEqual({ color: "#155dfc" });
});
it("clamps unsafe spacing values before returning React PDF styles", () => {
const data = createResumeData([
{
id: "spacing",
label: "Spacing",
enabled: true,
target: { scope: "global" },
slots: { item: { padding: 1000, marginTop: -1000, rowGap: -12, borderWidth: -5 } },
},
]);
expect(
resolveStyleRuleSlot(data, {
sectionId: "skills",
sectionType: "skills",
slot: "item",
}),
).toEqual({ padding: 72, marginTop: -72, rowGap: -12, borderWidth: 0 });
});
it("resolves visible text, opacity, and border style properties", () => {
const data = createResumeData([
{
id: "rich-link-style",
label: "Rich Link Style",
enabled: true,
target: { scope: "global" },
slots: {
richLink: {
color: "rgba(21, 93, 252, 1)",
textDecoration: "underline",
textDecorationColor: "rgba(220, 38, 38, 1)",
textDecorationStyle: "dashed",
fontStyle: "italic",
lineHeight: 9,
letterSpacing: -100,
textAlign: "right",
textTransform: "uppercase",
opacity: 2,
borderStyle: "dotted",
},
},
},
]);
expect(
resolveStyleRuleSlot(data, {
sectionId: "experience",
sectionType: "experience",
slot: "richLink",
}),
).toEqual({
color: "#155dfc",
textDecoration: "underline",
textDecorationColor: "#dc2626",
textDecorationStyle: "dashed",
fontStyle: "italic",
lineHeight: 4,
letterSpacing: -16,
textAlign: "right",
textTransform: "uppercase",
opacity: 1,
borderStyle: "dotted",
});
});
});
@@ -0,0 +1,134 @@
import type { Style } from "@react-pdf/types";
import type {
CustomSectionType,
ResumeData,
SectionType,
StyleIntent,
StyleSlot,
} from "@reactive-resume/schema/resume/data";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
export type SectionStyleRuleContext = {
sectionId: string;
sectionType?: CustomSectionType | undefined;
};
export type ResolveStyleRuleSlotOptions = SectionStyleRuleContext & {
slot: StyleSlot;
};
const spacingProperties = [
"padding",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"marginTop",
"marginRight",
"marginBottom",
"marginLeft",
"rowGap",
"columnGap",
"borderWidth",
"borderRadius",
] as const;
const colorProperties = ["color", "backgroundColor", "borderColor", "textDecorationColor"] as const;
const spacingPropertyRange = (property: (typeof spacingProperties)[number]) => {
if (property === "borderWidth" || property === "borderRadius")
return { min: 0, max: property === "borderWidth" ? 24 : 72 };
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) => {
try {
return rgbaStringToHex(value);
} catch {
return value;
}
};
const toStyle = (intent: StyleIntent | undefined): Style => {
if (!intent) return {};
const style: Style = {};
for (const property of colorProperties) {
const value = intent[property];
if (value) style[property] = toPdfColor(value);
}
if (intent.opacity !== undefined) style.opacity = clamp(intent.opacity, 0, 1);
if (intent.fontSize !== undefined) style.fontSize = clamp(intent.fontSize, 6, 48);
if (intent.fontWeight !== undefined) style.fontWeight = intent.fontWeight;
if (intent.fontStyle !== undefined) style.fontStyle = intent.fontStyle;
if (intent.lineHeight !== undefined) style.lineHeight = clamp(intent.lineHeight, 0.5, 4);
if (intent.letterSpacing !== undefined) style.letterSpacing = clamp(intent.letterSpacing, -16, 16);
if (intent.textDecoration !== undefined) style.textDecoration = intent.textDecoration;
if (intent.textDecorationStyle !== undefined) style.textDecorationStyle = intent.textDecorationStyle;
if (intent.textAlign !== undefined) style.textAlign = intent.textAlign;
if (intent.textTransform !== undefined) style.textTransform = intent.textTransform;
if (intent.borderStyle !== undefined) style.borderStyle = intent.borderStyle;
for (const property of spacingProperties) {
const value = intent[property];
const range = spacingPropertyRange(property);
if (value !== undefined) style[property] = clamp(value, range.min, range.max);
}
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])));
};