/**
* HTML Preview Service
* Renders a resume to HTML using Handlebars templates.
*/
const Handlebars = require('handlebars');
// ─── Handlebars Helpers ───────────────────────────────────────────────────────
Handlebars.registerHelper('ifCond', function (v1, operator, v2, options) {
switch (operator) {
case '==': return v1 == v2 ? options.fn(this) : options.inverse(this);
case '!=': return v1 != v2 ? options.fn(this) : options.inverse(this);
case '>': return v1 > v2 ? options.fn(this) : options.inverse(this);
case '<': return v1 < v2 ? options.fn(this) : options.inverse(this);
default: return options.inverse(this);
}
});
Handlebars.registerHelper('join', (arr, sep) =>
Array.isArray(arr) ? arr.join(typeof sep === 'string' ? sep : ', ') : ''
);
Handlebars.registerHelper('nl2br', (text) =>
new Handlebars.SafeString(
(text || '').replace(/\n/g, '
')
)
);
Handlebars.registerHelper('nl2li', (text) => {
if (!text) return '';
const items = text.split('\n').filter(line => line.trim().length > 0);
const listItems = items.map(item => `
${Handlebars.escapeExpression(item.trim())}`).join('');
return new Handlebars.SafeString(``);
});
// ─── HTML Builder ─────────────────────────────────────────────────────────────
function normalizeResumeData(resumeData) {
const normalized = { ...resumeData };
// Ensure all array fields are real arrays (they might come as null or strings)
const arrayFields = ['experience', 'education', 'skills', 'certifications', 'projects', 'languages', 'awards', 'customSections', 'references'];
for (const field of arrayFields) {
if (!Array.isArray(normalized[field])) {
if (typeof normalized[field] === 'string' && normalized[field].trim()) {
try { normalized[field] = JSON.parse(normalized[field]); } catch { normalized[field] = []; }
} else {
normalized[field] = [];
}
}
}
// Filter out completely empty objects from list sections
const listFields = ['experience', 'education', 'certifications', 'projects', 'languages', 'awards', 'references'];
for (const field of listFields) {
normalized[field] = normalized[field].filter(item =>
item && typeof item === 'object' && Object.values(item).some(v => v !== '' && v !== null && v !== undefined)
);
}
// Ensure skills is an array of non-empty strings
if (Array.isArray(normalized.skills)) {
normalized.skills = normalized.skills.filter(s => typeof s === 'string' && s.trim());
}
// Normalize education aliases
if (Array.isArray(normalized.education)) {
normalized.education = normalized.education.map((item) => ({
...item,
institution: item.institution || item.school || '',
endDate: item.endDate || item.year || '',
}));
}
// Ensure contact is an object
if (!normalized.contact || typeof normalized.contact !== 'object') {
normalized.contact = {};
}
return normalized;
}
function mmToPx(mm) {
return Math.round((mm / 25.4) * 96);
}
function buildHtml(resumeData, design, templateHtml, templateCss) {
const normalizedResume = normalizeResumeData(resumeData);
const {
primaryColor = '#2563EB',
secondaryColor = '#1e293b',
fontFamily = 'Inter, sans-serif',
fontSize = 11,
lineHeight = 1.5,
margins = { top: 10, right: 12.7, bottom: 10, left: 12.7 },
} = design || {};
const mTop = mmToPx(margins.top ?? 10);
const mRight = mmToPx(margins.right ?? 12.7);
const mBottom = mmToPx(margins.bottom ?? 10);
const mLeft = mmToPx(margins.left ?? 12.7);
const cssVars = `
:root {
--primary: ${primaryColor};
--secondary: ${secondaryColor};
--font-family: ${fontFamily};
--font-size: ${fontSize}pt;
--line-height: ${lineHeight};
--margin-top: ${mTop}px;
--margin-right: ${mRight}px;
--margin-bottom: ${mBottom}px;
--margin-left: ${mLeft}px;
}
`;
const baseStyles = `
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Merriweather:wght@300;400;700&family=Playfair+Display:wght@400;500;700&family=Source+Sans+Pro:wght@300;400;600;700&display=swap');
body {
font-family: var(--font-family) !important;
font-size: var(--font-size) !important;
line-height: var(--line-height) !important;
color: #1e293b;
margin: 0 !important;
padding: 0 !important;
-webkit-print-color-adjust: exact;
}
@media screen {
html, body {
background-color: #cbd5e1 !important;
margin: 0 !important;
padding: 0 !important;
overflow-y: auto !important;
overflow-x: hidden !important;
width: 100% !important;
}
#preview-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
padding: 24px 0;
width: 100%;
box-sizing: border-box;
background: #cbd5e1;
}
.page {
background: #fff;
box-shadow: 0 8px 32px rgba(0,0,0,0.22), 0 2px 8px rgba(0,0,0,0.10);
border-radius: 2px;
flex-shrink: 0;
}
}
@media print {
html, body {
background: #fff !important;
overflow: visible !important;
padding: 0 !important;
margin: 0 !important;
display: block !important;
}
#preview-content {
display: block !important;
padding: 0 !important;
gap: 0 !important;
}
.page {
box-shadow: none !important;
border-radius: 0 !important;
margin: 0 !important;
page-break-inside: avoid;
break-inside: avoid;
}
.page:not(:last-child) {
page-break-after: always !important;
break-after: page !important;
}
.page:last-child {
page-break-after: avoid !important;
break-after: avoid !important;
}
}
.page {
width: 794px;
height: 1123px;
overflow: hidden;
position: relative;
}
.page-inner {
padding: ${mTop}px ${mRight}px ${mBottom}px ${mLeft}px !important;
box-sizing: border-box;
height: 100%;
}
.bleed-header {
margin-top: -${mTop}px !important;
margin-right: -${mRight}px !important;
margin-left: -${mLeft}px !important;
}
section, .item, .section {
page-break-inside: avoid;
break-inside: avoid;
}
h1, h2, h3, h4, h5, h6 { line-height: 1.25 !important; }
h2 { margin-top: 6pt; margin-bottom: 4pt; }
p, li { margin-bottom: 2pt; }
a { color: var(--primary); text-decoration: none; }
ul { padding-left: 1.2em; }
li { margin-bottom: 2px; }
/* Kill any extra top margin/padding on the very first element inside the page so
templates don't double-stack spacing on top of page-inner's own padding. */
.page-inner > *:first-child {
margin-top: 0 !important;
padding-top: 0 !important;
}
/* Exception: bleed-header is intentional and should NOT have this override */
.page-inner > .bleed-header:first-child {
padding-top: 0 !important;
margin-top: -${mTop}px !important;
}
`;
const compiled = Handlebars.compile(templateHtml);
const body = compiled({ ...normalizedResume, design });
let compiledCss = templateCss || '';
if (templateCss) {
try {
const compileCss = Handlebars.compile(templateCss);
compiledCss = compileCss({ ...normalizedResume, design });
} catch (err) {
console.error('Error compiling template CSS:', err);
}
}
return `
Resume
`;
}
function generatePreviewHtml(resumeData, design, templateHtml, templateCss) {
return buildHtml(resumeData, design, templateHtml, templateCss);
}
module.exports = { generatePreviewHtml, buildHtml, normalizeResumeData };