Files
Reactive-Resume/packages/pdf/src/semantic/legacy-converter.ts
T
2026-07-30 12:39:15 +02:00

267 lines
9.5 KiB
TypeScript

import type { Style } from "@react-pdf/types";
import type { ResumeData, StyleIntent, StyleRule, StyleSlot } from "@reactive-resume/schema/resume/data";
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import { escapeCssComment, escapeCssString, serializeGeneratedStylesheet } from "@reactive-resume/resume/stylesheet";
import { styleRulesSchema } from "@reactive-resume/schema/resume/data";
import { getSectionStyleRuleContext } from "@reactive-resume/schema/resume/style-rules";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { resolveStyleRuleSlot } from "../templates/shared/style-rules";
export type LegacyStyleConversion = {
source: StylesheetSource;
sanitizedRules: readonly StyleRule[];
};
const styleSlots = [
"section",
"heading",
"item",
"text",
"secondaryText",
"link",
"icon",
"level",
"richParagraph",
"richList",
"richListItemRow",
"richListItemContent",
"richLink",
"richBold",
"richMark",
] as const satisfies readonly StyleSlot[];
const lengthProperties = new Set<keyof StyleIntent>([
"fontSize",
"letterSpacing",
"padding",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"marginTop",
"marginRight",
"marginBottom",
"marginLeft",
"rowGap",
"columnGap",
"borderWidth",
"borderRadius",
]);
const slotSelectors = {
section: "",
heading: " > section-heading",
item: " > section-items > item",
text: ' field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])',
secondaryText: ' field[name="keywords"]',
link: " item > link",
icon: " icon",
level: " level",
richParagraph: " rich-text paragraph",
richList: " rich-text list",
richListItemRow: " rich-text list-item",
richListItemContent: " rich-text list-item-content",
richLink: " rich-text link",
richBold: " rich-text strong",
richMark: " rich-text mark",
} as const satisfies Record<StyleSlot, string>;
const selectorForSlot = (base: string, slot: StyleSlot): string => {
if (slot === "link") {
return [
`${base} item > link`,
`${base} item > template-part > link`,
`${base} item-header > link`,
`${base} item-header > template-part > link`,
].join(",\n");
}
return `${base}${slotSelectors[slot]}`;
};
const ruleBaseSelector = (rule: StyleRule): string => {
if (rule.target.scope === "sectionType") return `section[type=${escapeCssString(rule.target.sectionType)}]`;
if (rule.target.scope === "sectionId") return `section[id=${escapeCssString(rule.target.sectionId)}]`;
return "section";
};
const resolvedRuleStyle = (data: ResumeData, rule: StyleRule, slot: StyleSlot): Style => {
const isolated = {
...data,
metadata: { ...data.metadata, styleRules: [rule] },
};
const options =
rule.target.scope === "sectionType"
? { slot, sectionId: "", sectionType: rule.target.sectionType }
: rule.target.scope === "sectionId"
? { slot, ...getSectionStyleRuleContext(data, rule.target.sectionId) }
: { slot, sectionId: "" };
return resolveStyleRuleSlot(isolated, options);
};
const declarationsFromStyle = (style: Style): Record<string, string | number> =>
Object.fromEntries(
Object.entries(style).flatMap(([property, value]) => {
if (value === undefined) return [];
const converted =
typeof value === "number" && lengthProperties.has(property as keyof StyleIntent) ? `${value}pt` : value;
return [[property, converted]];
}),
);
const serializeBlock = (
selector: string,
declarations: Readonly<Record<string, string | number>>,
comment?: string,
): string =>
serializeGeneratedStylesheet({
languageVersion: 1,
blocks: [{ selector, declarations, ...(comment === undefined ? {} : { comment }) }],
})
.replace(/^@version 1;\n\n/, "")
.trimEnd();
const appliesToAwards = (data: ResumeData, rule: StyleRule): boolean => {
if (rule.target.scope === "global") return true;
if (rule.target.scope === "sectionType") return rule.target.sectionType === "awards";
return getSectionStyleRuleContext(data, rule.target.sectionId).sectionType === "awards";
};
const textWeightSelector = (data: ResumeData, rule: StyleRule, base: string): string => {
const selectors = [
`${base} field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"]):not([role~="primary-text"])`,
`${base} item[role~="nested-role"] > item-header > field[name="position"]`,
];
if (appliesToAwards(data, rule)) selectors.push(`${base} field[name="title"]`);
return selectors.join(",\n");
};
const scizorBoldColorSelector = (data: ResumeData, rule: StyleRule, base: string): string | undefined => {
const sectionType =
rule.target.scope === "sectionType"
? rule.target.sectionType
: rule.target.scope === "sectionId"
? getSectionStyleRuleContext(data, rule.target.sectionId).sectionType
: undefined;
if (sectionType === "awards") return;
const scopedBase =
rule.target.scope === "global"
? 'resume[template="scizor"] section:not([type="awards"])'
: `resume[template="scizor"] ${base}`;
return ["company", "school", "name", "language", "network", "organization", "title"]
.map((name) => `${scopedBase}${slotSelectors.text}[role~="primary-text"][name=${escapeCssString(name)}]`)
.join(",\n");
};
const levelDecorationDeclarations = (
data: ResumeData,
fontSize: string | number | undefined,
): Record<string, string | number> => {
if (fontSize === undefined) return {};
const type = data.metadata.design.level.type;
if (type === "progress-bar" || type === "rectangle" || type === "rectangle-full") return { height: fontSize };
return { width: fontSize, height: fontSize };
};
const activeSlotChunks = (data: ResumeData, rule: StyleRule, slot: StyleSlot): string[] => {
const base = ruleBaseSelector(rule);
const selector = selectorForSlot(base, slot);
const declarations = declarationsFromStyle(resolvedRuleStyle(data, rule, slot));
const combinedTextDeclarations = slot === "text" ? { ...declarations } : undefined;
const chunks: string[] = [];
const comments: string[] = [];
if ((slot === "link" || slot === "richLink") && "textDecoration" in declarations) {
delete declarations.textDecoration;
comments.push("No effect in legacy rendering: text-decoration is owned by the builder link preference.");
}
if (slot === "text" && "fontWeight" in declarations) {
const { fontWeight } = declarations;
delete declarations.fontWeight;
if (Object.keys(declarations).length > 0)
chunks.push(serializeBlock(selector, declarations, rule.label || rule.id));
chunks.push(serializeBlock(textWeightSelector(data, rule, base), { fontWeight }, rule.label || rule.id));
comments.push(
"Legacy Bold hosts keep the template bold weight; award titles remain the explicit unbold exception.",
);
} else {
if (slot === "icon" && declarations.fontSize !== undefined) {
declarations.width = declarations.fontSize;
declarations.height = declarations.fontSize;
}
if (Object.keys(declarations).length > 0)
chunks.push(serializeBlock(selector, declarations, rule.label || rule.id));
}
if (combinedTextDeclarations && Object.keys(combinedTextDeclarations).length > 0) {
chunks.push(
serializeBlock(`${base} combined-text`, combinedTextDeclarations, `${rule.label || rule.id}: combined text host`),
);
}
if (slot === "text" && declarations.color !== undefined) {
const restorationSelector = scizorBoldColorSelector(data, rule, base);
if (restorationSelector) {
chunks.push(
serializeBlock(
restorationSelector,
{ color: rgbaStringToHex(data.metadata.design.colors.text) },
`${rule.label || rule.id}: Scizor Bold final color`,
),
);
}
}
if (slot === "level" && declarations.fontSize !== undefined) {
const geometry = levelDecorationDeclarations(data, declarations.fontSize);
if (Object.keys(geometry).length > 0) {
chunks.push(serializeBlock(`${base} level > icon`, geometry, `${rule.label || rule.id}: level decorations`));
}
}
return [...chunks, ...comments.map((comment) => `/* ${escapeCssComment(comment)} */`)];
};
const disabledRuleChunk = (data: ResumeData, rule: StyleRule): string => {
const blocks = styleSlots.flatMap((slot) => {
if (!rule.slots[slot]) return [];
const declarations = declarationsFromStyle(resolvedRuleStyle(data, { ...rule, enabled: true }, slot));
if (Object.keys(declarations).length === 0) return [];
const base = ruleBaseSelector(rule);
return [
serializeBlock(selectorForSlot(base, slot), declarations),
...(slot === "text" ? [serializeBlock(`${base} combined-text`, declarations)] : []),
];
});
const label = escapeCssComment(rule.label || rule.id);
const body = escapeCssComment(blocks.join("\n\n"));
return `/* Disabled legacy rule: ${label}${body ? `\n${body}` : ""}\n*/`;
};
export function convertLegacyStyleRules(data: ResumeData): LegacyStyleConversion {
const sanitizedRules = styleRulesSchema.parse(data.metadata.styleRules ?? []);
const sanitizedData = {
...data,
metadata: { ...data.metadata, styleRules: sanitizedRules },
};
const scopePrecedence = { global: 0, sectionType: 1, sectionId: 2 } as const;
const orderedRules = sanitizedRules
.map((rule, index) => ({ rule, index }))
.sort(
(left, right) =>
scopePrecedence[left.rule.target.scope] - scopePrecedence[right.rule.target.scope] || left.index - right.index,
)
.map(({ rule }) => rule);
const chunks = orderedRules.flatMap((rule) => {
if (!rule.enabled) return [disabledRuleChunk(sanitizedData, rule)];
return styleSlots.flatMap((slot) => (rule.slots[slot] ? activeSlotChunks(sanitizedData, rule, slot) : []));
});
const text = `@version 1;\n${chunks.length > 0 ? `\n${chunks.join("\n\n")}\n` : ""}`;
return {
source: { languageVersion: 1, text },
sanitizedRules,
};
}