/** * 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
    ${body}
    `; } function generatePreviewHtml(resumeData, design, templateHtml, templateCss) { return buildHtml(resumeData, design, templateHtml, templateCss); } module.exports = { generatePreviewHtml, buildHtml, normalizeResumeData };