mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-20 13:31:44 +10:00
485 lines
15 KiB
TypeScript
485 lines
15 KiB
TypeScript
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
|
|
import type { Template } from "@reactive-resume/schema/templates";
|
|
import {
|
|
BorderStyle,
|
|
convertMillimetersToTwip,
|
|
Document,
|
|
ExternalHyperlink,
|
|
HeadingLevel,
|
|
Paragraph,
|
|
ShadingType,
|
|
Table,
|
|
TableCell,
|
|
TableRow,
|
|
TextRun,
|
|
WidthType,
|
|
} from "docx";
|
|
import { parseColorString } from "@reactive-resume/utils/color";
|
|
import { shouldShowResumeHeader } from "./cover-letter";
|
|
import { toSafeDocxLink } from "./link-utils";
|
|
import { renderBuiltInSection, renderCustomSection, renderSummary, setRenderConfig } from "./section-renderers";
|
|
|
|
// --- Color helpers ---
|
|
|
|
function rgbToHex(r: number, g: number, b: number): string {
|
|
return [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function getColorHex(rgba: string, fallback: string): string {
|
|
const parsed = parseColorString(rgba);
|
|
if (!parsed) return fallback;
|
|
return rgbToHex(parsed.r, parsed.g, parsed.b);
|
|
}
|
|
|
|
// --- Unit conversion helpers ---
|
|
|
|
/** Convert pt font size to docx half-points (docx uses half-pt units). */
|
|
function ptToHalfPt(pt: number): number {
|
|
return Math.round(pt * 2);
|
|
}
|
|
|
|
/** Convert pt spacing to twips (1pt = 20 twips). */
|
|
function ptToTwips(pt: number): number {
|
|
return Math.round(pt * 20);
|
|
}
|
|
|
|
// DOCX has fixed pages; free-form resumes intentionally fall back to A4.
|
|
const A4_PAGE_SIZE = { width: 210, height: 297 };
|
|
const LETTER_PAGE_SIZE = { width: 215.9, height: 279.4 };
|
|
|
|
// --- Invisible border preset for table cells ---
|
|
|
|
const NO_BORDERS = {
|
|
top: { style: BorderStyle.NONE, size: 0 },
|
|
bottom: { style: BorderStyle.NONE, size: 0 },
|
|
left: { style: BorderStyle.NONE, size: 0 },
|
|
right: { style: BorderStyle.NONE, size: 0 },
|
|
} as const;
|
|
|
|
// --- Template layout config ---
|
|
|
|
interface TemplateConfig {
|
|
/** Which side the sidebar appears on */
|
|
sidebarSide: "left" | "right" | "none";
|
|
/** Sidebar background: "solid" = full primary color, "tint" = 20% opacity, "none" = no background */
|
|
sidebarBackground: "solid" | "tint" | "none";
|
|
/** Where the header is rendered */
|
|
headerPosition: "full-width" | "main-only" | "sidebar-only";
|
|
}
|
|
|
|
const TEMPLATE_CONFIGS: Record<Template, TemplateConfig> = {
|
|
azurill: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
|
|
bronzor: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
|
chikorita: { sidebarSide: "right", sidebarBackground: "solid", headerPosition: "main-only" },
|
|
ditgar: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
|
|
ditto: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
|
|
gengar: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
|
|
glalie: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
|
|
kakuna: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
|
lapras: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
|
leafish: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
|
meowth: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
|
|
onyx: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
|
pikachu: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "main-only" },
|
|
rhyhorn: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
|
scizor: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
|
|
};
|
|
|
|
/**
|
|
* Blends a hex color toward white at the given opacity (0-1).
|
|
* Used to approximate CSS `background-color: rgba(r,g,b, 0.2)` on a white background.
|
|
*/
|
|
function blendWithWhite(hex: string, opacity: number): string {
|
|
const r = Number.parseInt(hex.slice(0, 2), 16);
|
|
const g = Number.parseInt(hex.slice(2, 4), 16);
|
|
const b = Number.parseInt(hex.slice(4, 6), 16);
|
|
const blend = (c: number) => Math.round(c * opacity + 255 * (1 - opacity));
|
|
return [blend(r), blend(g), blend(b)].map((c) => c.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
// --- Section rendering dispatch ---
|
|
|
|
/** Resolves a section's (usually empty) stored title from its id — locale-aware, supplied by the caller. */
|
|
export type SectionTitleResolver = (sectionId: string) => string | undefined;
|
|
|
|
function renderSection(
|
|
sectionId: string,
|
|
data: ResumeData,
|
|
colorHex: string,
|
|
resolveTitle?: SectionTitleResolver,
|
|
): Paragraph[] {
|
|
const titled = <T extends { title: string }>(section: T): T => ({
|
|
...section,
|
|
title: resolveTitle?.(sectionId)?.trim() || section.title,
|
|
});
|
|
|
|
if (sectionId === "summary") {
|
|
return renderSummary(titled(data.summary), colorHex);
|
|
}
|
|
|
|
// ponytail: data.sections keys are the source of truth; no need to maintain a parallel Set
|
|
if (sectionId in data.sections) {
|
|
const section = data.sections[sectionId as SectionType];
|
|
if (section) {
|
|
return renderBuiltInSection(sectionId as SectionType, titled(section), colorHex);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
const customSection = data.customSections.find((cs) => cs.id === sectionId);
|
|
if (customSection) {
|
|
return renderCustomSection(titled(customSection), colorHex);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
// --- Header ---
|
|
|
|
function buildHeader(data: ResumeData, colorHex: string, textColorHex: string): Paragraph[] {
|
|
const paragraphs: Paragraph[] = [];
|
|
const { basics } = data;
|
|
const headingFont = data.metadata.typography.heading.fontFamily || "Calibri";
|
|
const bodyFont = data.metadata.typography.body.fontFamily || "Calibri";
|
|
// h2 = calc(var(--page-heading-font-size) * 1.25pt)
|
|
const nameSize = ptToHalfPt(data.metadata.typography.heading.fontSize * 1.25);
|
|
const bodySize = ptToHalfPt(data.metadata.typography.body.fontSize);
|
|
|
|
if (basics.name) {
|
|
paragraphs.push(
|
|
new Paragraph({
|
|
heading: HeadingLevel.TITLE,
|
|
spacing: { after: 60 },
|
|
children: [
|
|
new TextRun({
|
|
text: basics.name,
|
|
bold: true,
|
|
size: nameSize,
|
|
font: headingFont,
|
|
color: colorHex,
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (basics.headline) {
|
|
paragraphs.push(
|
|
new Paragraph({
|
|
spacing: { after: 60 },
|
|
children: [
|
|
new TextRun({
|
|
text: basics.headline,
|
|
italics: true,
|
|
size: bodySize,
|
|
font: bodyFont,
|
|
color: textColorHex,
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
|
|
const contactParts: (TextRun | ExternalHyperlink)[] = [];
|
|
const addSeparator = () => {
|
|
if (contactParts.length > 0) {
|
|
contactParts.push(new TextRun({ text: " | ", color: "999999", size: bodySize, font: bodyFont }));
|
|
}
|
|
};
|
|
|
|
if (basics.email) {
|
|
const mailtoLink = toSafeDocxLink(`mailto:${basics.email}`);
|
|
if (mailtoLink) {
|
|
addSeparator();
|
|
contactParts.push(
|
|
new ExternalHyperlink({
|
|
link: mailtoLink,
|
|
children: [
|
|
new TextRun({ text: basics.email, color: colorHex, underline: {}, size: bodySize, font: bodyFont }),
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (basics.phone) {
|
|
addSeparator();
|
|
contactParts.push(new TextRun({ text: basics.phone, size: bodySize, font: bodyFont, color: textColorHex }));
|
|
}
|
|
|
|
if (basics.location) {
|
|
addSeparator();
|
|
contactParts.push(new TextRun({ text: basics.location, size: bodySize, font: bodyFont, color: textColorHex }));
|
|
}
|
|
|
|
if (basics.website.url) {
|
|
const websiteLink = toSafeDocxLink(basics.website.url);
|
|
if (websiteLink) {
|
|
addSeparator();
|
|
contactParts.push(
|
|
new ExternalHyperlink({
|
|
link: websiteLink,
|
|
children: [
|
|
new TextRun({
|
|
text: basics.website.label || websiteLink,
|
|
color: colorHex,
|
|
underline: {},
|
|
size: bodySize,
|
|
font: bodyFont,
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const field of basics.customFields) {
|
|
if (!field.text) continue;
|
|
addSeparator();
|
|
if (field.link) {
|
|
const customLink = toSafeDocxLink(field.link);
|
|
if (customLink) {
|
|
contactParts.push(
|
|
new ExternalHyperlink({
|
|
link: customLink,
|
|
children: [
|
|
new TextRun({
|
|
text: field.text,
|
|
color: colorHex,
|
|
underline: {},
|
|
size: bodySize,
|
|
font: bodyFont,
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
} else {
|
|
contactParts.push(new TextRun({ text: field.text, size: bodySize, font: bodyFont, color: textColorHex }));
|
|
}
|
|
} else {
|
|
contactParts.push(new TextRun({ text: field.text, size: bodySize, font: bodyFont, color: textColorHex }));
|
|
}
|
|
}
|
|
|
|
if (contactParts.length > 0) {
|
|
paragraphs.push(
|
|
new Paragraph({
|
|
spacing: { after: 200 },
|
|
children: contactParts,
|
|
}),
|
|
);
|
|
}
|
|
|
|
return paragraphs;
|
|
}
|
|
|
|
// --- Two-column table layout ---
|
|
|
|
function buildTwoColumnTable(
|
|
mainParagraphs: Paragraph[],
|
|
sidebarParagraphs: Paragraph[],
|
|
sidebarWidthPct: number,
|
|
gapXTwips: number,
|
|
sidebarSide: "left" | "right" | "none",
|
|
sidebarShadingHex?: string,
|
|
): Table {
|
|
const mainWidthPct = 100 - sidebarWidthPct;
|
|
|
|
// DOCX table cells require at least one child
|
|
const mainChildren = mainParagraphs.length > 0 ? mainParagraphs : [new Paragraph({})];
|
|
const sidebarChildren = sidebarParagraphs.length > 0 ? sidebarParagraphs : [new Paragraph({})];
|
|
|
|
const sidebarShading = sidebarShadingHex
|
|
? { fill: sidebarShadingHex, type: ShadingType.CLEAR, color: "auto" }
|
|
: undefined;
|
|
|
|
const margins: { right?: number; left?: number } = {};
|
|
|
|
if (sidebarSide === "left") {
|
|
margins.right = gapXTwips;
|
|
} else if (sidebarSide === "right") {
|
|
margins.left = gapXTwips;
|
|
}
|
|
|
|
const sidebarCell = new TableCell({
|
|
width: { size: sidebarWidthPct, type: WidthType.PERCENTAGE },
|
|
borders: NO_BORDERS,
|
|
margins,
|
|
children: sidebarChildren,
|
|
...(sidebarShading ? { shading: sidebarShading } : {}),
|
|
});
|
|
|
|
const mainCell = new TableCell({
|
|
width: { size: mainWidthPct, type: WidthType.PERCENTAGE },
|
|
borders: NO_BORDERS,
|
|
margins,
|
|
children: mainChildren,
|
|
});
|
|
|
|
const cells = sidebarSide === "left" ? [sidebarCell, mainCell] : [mainCell, sidebarCell];
|
|
|
|
return new Table({
|
|
rows: [new TableRow({ children: cells })],
|
|
width: { size: 100, type: WidthType.PERCENTAGE },
|
|
borders: {
|
|
top: { style: BorderStyle.NONE, size: 0 },
|
|
bottom: { style: BorderStyle.NONE, size: 0 },
|
|
left: { style: BorderStyle.NONE, size: 0 },
|
|
right: { style: BorderStyle.NONE, size: 0 },
|
|
insideHorizontal: { style: BorderStyle.NONE, size: 0 },
|
|
insideVertical: { style: BorderStyle.NONE, size: 0 },
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Builds a complete docx Document from resume data.
|
|
*
|
|
* Mirrors the resume builder's visual layout:
|
|
* - Header with name, headline, and contact info (centered, full-width)
|
|
* - Two-column table layout (main + sidebar) matching `metadata.layout`
|
|
* - Typography (font family, size, line height) from `metadata.typography`
|
|
* - Page margins and fixed DOCX page format from `metadata.page`; free-form exports as A4
|
|
* - Primary, text, and background colors from `metadata.design.colors`
|
|
*/
|
|
export function buildDocument(data: ResumeData, resolveTitle?: SectionTitleResolver): Document {
|
|
const colorHex = getColorHex(data.metadata.design.colors.primary, "DC2626");
|
|
const textColorHex = getColorHex(data.metadata.design.colors.text, "000000");
|
|
const bgColorHex = getColorHex(data.metadata.design.colors.background, "FFFFFF");
|
|
|
|
const bodyFont = data.metadata.typography.body.fontFamily || "Calibri";
|
|
const bodySize = ptToHalfPt(data.metadata.typography.body.fontSize);
|
|
const lineSpacing = Math.round(data.metadata.typography.body.lineHeight * 240);
|
|
|
|
const { page } = data.metadata;
|
|
const pageSize = page.format === "letter" ? LETTER_PAGE_SIZE : A4_PAGE_SIZE;
|
|
// Margins and gaps are defined in points (pt), not mm
|
|
const marginXTwips = ptToTwips(page.marginX);
|
|
const marginYTwips = ptToTwips(page.marginY);
|
|
const gapXTwips = ptToTwips(page.gapX);
|
|
|
|
const sidebarWidth = data.metadata.layout.sidebarWidth;
|
|
|
|
// Template-aware layout config
|
|
const templateConfig = TEMPLATE_CONFIGS[data.metadata.template];
|
|
|
|
// Compute sidebar background shading hex
|
|
let sidebarShadingHex: string | undefined;
|
|
if (templateConfig.sidebarBackground === "solid") {
|
|
sidebarShadingHex = colorHex;
|
|
} else if (templateConfig.sidebarBackground === "tint") {
|
|
sidebarShadingHex = blendWithWhite(colorHex, 0.2);
|
|
}
|
|
|
|
// Determine sidebar text colors — inverted when sidebar has a solid background
|
|
const sidebarTextColorHex = templateConfig.sidebarBackground === "solid" ? bgColorHex : textColorHex;
|
|
const sidebarHeadingColorHex = templateConfig.sidebarBackground === "solid" ? bgColorHex : colorHex;
|
|
|
|
// Configure heading typography for section renderers
|
|
const headingFont = data.metadata.typography.heading.fontFamily || "Calibri";
|
|
const headingSize = ptToHalfPt(data.metadata.typography.heading.fontSize);
|
|
|
|
const documentChildren: (Paragraph | Table)[] = [];
|
|
|
|
// ponytail: hoisted once; sidebar call spreads only the two differing color keys
|
|
const mainConfig = {
|
|
headingFont,
|
|
headingSizeHalfPt: headingSize,
|
|
bodyFont,
|
|
bodySizeHalfPt: bodySize,
|
|
textColorHex,
|
|
primaryColorHex: colorHex,
|
|
};
|
|
const showHeader = shouldShowResumeHeader(data);
|
|
|
|
// Header placement depends on template
|
|
if (templateConfig.headerPosition === "full-width" && showHeader) {
|
|
setRenderConfig(mainConfig);
|
|
documentChildren.push(...buildHeader(data, colorHex, textColorHex));
|
|
}
|
|
|
|
// Process each page in the layout
|
|
for (const layoutPage of data.metadata.layout.pages) {
|
|
const isFullWidth = layoutPage.fullWidth || layoutPage.sidebar.length === 0;
|
|
|
|
if (isFullWidth) {
|
|
setRenderConfig(mainConfig);
|
|
for (const sectionId of [...layoutPage.main, ...layoutPage.sidebar]) {
|
|
documentChildren.push(...renderSection(sectionId, data, colorHex, resolveTitle));
|
|
}
|
|
} else {
|
|
// Render main sections with normal colors
|
|
setRenderConfig(mainConfig);
|
|
|
|
const mainParagraphs: Paragraph[] = [];
|
|
if (templateConfig.headerPosition === "main-only" && showHeader) {
|
|
mainParagraphs.push(...buildHeader(data, colorHex, textColorHex));
|
|
}
|
|
for (const sectionId of layoutPage.main) {
|
|
mainParagraphs.push(...renderSection(sectionId, data, colorHex, resolveTitle));
|
|
}
|
|
|
|
// Render sidebar sections with potentially inverted colors
|
|
setRenderConfig({ ...mainConfig, textColorHex: sidebarTextColorHex, primaryColorHex: sidebarHeadingColorHex });
|
|
|
|
const sidebarParagraphs: Paragraph[] = [];
|
|
if (templateConfig.headerPosition === "sidebar-only" && showHeader) {
|
|
sidebarParagraphs.push(...buildHeader(data, sidebarHeadingColorHex, sidebarTextColorHex));
|
|
}
|
|
for (const sectionId of layoutPage.sidebar) {
|
|
sidebarParagraphs.push(...renderSection(sectionId, data, sidebarHeadingColorHex, resolveTitle));
|
|
}
|
|
|
|
if (mainParagraphs.length > 0 || sidebarParagraphs.length > 0) {
|
|
documentChildren.push(
|
|
buildTwoColumnTable(
|
|
mainParagraphs,
|
|
sidebarParagraphs,
|
|
sidebarWidth,
|
|
gapXTwips,
|
|
templateConfig.sidebarSide,
|
|
sidebarShadingHex,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Document({
|
|
styles: {
|
|
default: {
|
|
document: {
|
|
run: {
|
|
font: bodyFont,
|
|
size: bodySize,
|
|
color: textColorHex,
|
|
},
|
|
paragraph: {
|
|
spacing: { line: lineSpacing },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
...(bgColorHex !== "FFFFFF" ? { background: { color: bgColorHex } } : {}),
|
|
sections: [
|
|
{
|
|
properties: {
|
|
page: {
|
|
size: {
|
|
width: convertMillimetersToTwip(pageSize.width),
|
|
height: convertMillimetersToTwip(pageSize.height),
|
|
},
|
|
margin: {
|
|
top: marginYTwips,
|
|
bottom: marginYTwips,
|
|
left: marginXTwips,
|
|
right: marginXTwips,
|
|
},
|
|
},
|
|
},
|
|
children: documentChildren,
|
|
},
|
|
],
|
|
});
|
|
}
|