/** * CV Template Renderer - Node.js + Puppeteer * * Merges a JSON CV Template (layout positions) with User CV Data (actual candidate info) * and renders a pixel-perfect A4 PDF using absolute CSS positioning. */ const puppeteer = require('puppeteer'); const fs = require('fs'); const path = require('path'); // ============================================================ // CSS GENERATION // ============================================================ /** * Generate CSS for the A4 canvas and all positioned blocks. * Gridstack uses a 12-column grid. We convert x,y,w,h into * absolute pixel positions on a 794x1123 canvas. */ function generateCanvasCSS(template) { const { canvas, blocks } = template; const colWidth = canvas.width / canvas.columns; const rowHeight = 10; // Gridstack default cell height in px (we use compact mode) let css = ` /* A4 Canvas */ .cv-page { width: ${canvas.width}px; height: ${canvas.height}px; background: white; position: relative; overflow: hidden; font-family: 'Georgia', 'Times New Roman', serif; color: #1a1a1a; font-size: 13px; line-height: 1.5; box-sizing: border-box; padding: 0; margin: 0 auto; } /* Each block is absolutely positioned using the grid coordinates */ .cv-block { position: absolute; box-sizing: border-box; padding: 12px 16px; overflow: hidden; } /* Block type-specific styling */ .cv-block-PersonalDetails { border-bottom: 2px solid #2c3e50; padding-bottom: 16px; } .cv-block-PersonalDetails .cv-name { font-size: 26px; font-weight: 700; color: #2c3e50; margin: 0 0 4px 0; } .cv-block-PersonalDetails .cv-title { font-size: 15px; color: #555; margin: 0 0 6px 0; } .cv-block-PersonalDetails .cv-contact { font-size: 12px; color: #777; display: flex; flex-wrap: wrap; gap: 12px; } .cv-block-PersonalDetails .cv-contact span { display: inline-flex; align-items: center; gap: 4px; } /* Section headers */ .cv-section-title { font-size: 14px; font-weight: 700; color: #2c3e50; text-transform: uppercase; letter-spacing: 1.5px; border-bottom: 1px solid #ddd; padding-bottom: 4px; margin-bottom: 8px; } /* Work Experience */ .cv-block-WorkExperience .exp-entry { margin-bottom: 10px; } .cv-block-WorkExperience .exp-role { font-size: 14px; font-weight: 600; color: #333; } .cv-block-WorkExperience .exp-company { font-size: 13px; font-weight: 500; color: #555; } .cv-block-WorkExperience .exp-date { font-size: 11px; color: #888; } .cv-block-WorkExperience .exp-desc { font-size: 12px; color: #444; margin-top: 4px; } .cv-block-WorkExperience .exp-achievements { margin: 4px 0 0 16px; padding: 0; font-size: 12px; color: #444; } .cv-block-WorkExperience .exp-achievements li { margin-bottom: 2px; } /* Education */ .cv-block-Education .edu-entry { margin-bottom: 8px; } .cv-block-Education .edu-degree { font-size: 13px; font-weight: 600; color: #333; } .cv-block-Education .edu-school { font-size: 12px; color: #555; } .cv-block-Education .edu-date { font-size: 11px; color: #888; } /* Skills */ .cv-block-SkillsList .skill-group { margin-bottom: 6px; } .cv-block-SkillsList .skill-cat { font-size: 12px; font-weight: 600; color: #2c3e50; margin-bottom: 2px; } .cv-block-SkillsList .skill-items { font-size: 12px; color: #444; } .cv-block-SkillsList .skill-item { display: inline; } .cv-block-SkillsList .skill-item::after { content: " • "; color: #aaa; } .cv-block-SkillsList .skill-item:last-child::after { content: ""; } .cv-block-SkillsList .skill-years { font-size: 10px; color: #999; } /* Certifications */ .cv-block-Certifications .cert-entry { margin-bottom: 6px; } .cv-block-Certifications .cert-name { font-size: 13px; font-weight: 600; color: #333; } .cv-block-Certifications .cert-issuer { font-size: 12px; color: #777; } /* Summary */ .cv-block-ProfessionalSummary { font-size: 13px; color: #444; text-align: justify; } /* Custom Text */ .cv-block-CustomText { font-size: 13px; color: #333; white-space: pre-wrap; } /* Footer */ .cv-block-Footer { font-size: 10px; color: #aaa; text-align: center; border-top: 1px solid #eee; padding-top: 8px; } /* Two-column layouts */ .cv-sidebar { background: #f8f9fa; border-left: 2px solid #2c3e50; } /* Print reset */ @media print { body { margin: 0; padding: 0; } @page { size: A4; margin: 0; } } `; // Generate position CSS for each block for (const block of blocks) { const left = block.x * colWidth; const width = block.w * colWidth; const top = block.y * rowHeight; const height = block.h * rowHeight; css += ` /* Block: ${block.blockId} (${block.type}) */ #block-${block.blockId} { left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px; } `; } return css; } // ============================================================ // HTML GENERATION // ============================================================ /** * Build the HTML content for a single block by injecting CV data * into the block based on its type. */ function renderBlockHTML(block, cvData, options) { const { type, blockId } = block; const opts = options || {}; const genDate = opts.generationDate || new Date().toISOString().split('T')[0]; // Helper: format date range const fmtDate = (d) => { if (!d) return ''; try { const date = new Date(d); return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short' }); } catch { return d; } }; // Helper: calculate years between dates const calcYears = (start, end, refDate) => { if (!start) return 0; const endDate = end ? new Date(end) : new Date(refDate); const startDate = new Date(start); const diff = (endDate - startDate) / (365.25 * 24 * 3600 * 1000); return Math.round(diff * 10) / 10; }; switch (type) { case 'PersonalDetails': const c = cvData.candidate || cvData; const contactParts = []; if (c.email) contactParts.push(`📧 ${c.email}`); if (c.phone) contactParts.push(`📞 ${c.phone}`); if (c.address) contactParts.push(`📍 ${c.address}`); if (c.linkedin) contactParts.push(`🔗 ${c.linkedin}`); if (c.github) contactParts.push(`💻 ${c.github}`); if (c.website) contactParts.push(`🌐 ${c.website}`); return `

${c.first_name || ''} ${c.last_name || ''}

${c.summary ? `
${c.summary.split('.')[0]}.
` : ''}
${contactParts.join('')}
`; case 'ProfessionalSummary': const summary = cvData.candidate?.summary || cvData.summary || ''; return `
${block.title || 'Professional Summary'}

${summary}

`; case 'WorkExperience': const experiences = cvData.experience || []; const showAch = block.config?.showAchievements !== false; const showSkills = block.config?.showSkillsUsed === true; const maxItems = block.config?.maxItems; let expHTML = experiences.slice(0, maxItems || experiences.length).map(e => { const ach = Array.isArray(e.achievements) ? e.achievements : (typeof e.achievements === 'string' ? JSON.parse(e.achievements || '[]') : []); const skillsUsed = Array.isArray(e.skills_used) ? e.skills_used : (typeof e.skills_used === 'string' ? JSON.parse(e.skills_used || '[]') : []); const startStr = fmtDate(e.start_date); const endStr = e.end_date ? fmtDate(e.end_date) : 'Present'; return `
${e.position || ''}
${e.company || ''}${e.location ? ' — ' + e.location : ''}
${startStr} — ${endStr}
${e.description ? `
${e.description}
` : ''} ${showAch && ach.length ? `` : ''} ${showSkills && skillsUsed.length ? `
Skills: ${skillsUsed.join(', ')}
` : ''}
`; }).join(''); return `
${block.title || 'Work Experience'}
${expHTML || '

No experience data

'}
`; case 'Education': const educations = cvData.education || []; let eduHTML = educations.map(e => `
${e.degree || ''}${e.field_of_study ? ' in ' + e.field_of_study : ''}
${e.institution || ''}
${e.start_date ? fmtDate(e.start_date) : ''} — ${e.end_date ? fmtDate(e.end_date) : ''}
${e.grade ? `
Grade: ${e.grade}
` : ''}
`).join(''); return `
${block.title || 'Education'}
${eduHTML || '

No education data

'}
`; case 'SkillsList': const skills = cvData.skills || []; const groupByCat = block.config?.groupByCategory !== false; const showYears = block.config?.showYears === true; const showProf = block.config?.showProficiency === true; if (groupByCat) { const groups = {}; for (const s of skills) { const cat = s.skill_category || 'Other'; if (!groups[cat]) groups[cat] = []; groups[cat].push(s); } let skillsHTML = Object.entries(groups).map(([cat, items]) => `
${cat}
${items.map(s => { let txt = `${s.skill_name}`; if (showProf && s.proficiency) txt += ` (${s.proficiency})`; if (showYears && s.years_experience) txt += ` ${s.years_experience}y`; txt += ''; return txt; }).join('')}
`).join(''); return `
${block.title || 'Skills'}
${skillsHTML || '

No skills data

'}
`; } else { let skillsHTML = skills.map(s => { let txt = `${s.skill_name}`; if (showProf && s.proficiency) txt += ` (${s.proficiency})`; if (showYears && s.years_experience) txt += ` ${s.years_experience}y`; txt += ''; return txt; }).join(''); return `
${block.title || 'Skills'}
${skillsHTML}
`; } case 'Certifications': const certs = cvData.certifications || []; let certHTML = certs.map(c => `
${c.name || ''}
${c.issuer ? `
${c.issuer}
` : ''} ${c.issue_date ? `
${fmtDate(c.issue_date)}${c.expiry_date ? ' — ' + fmtDate(c.expiry_date) : ''}
` : ''}
`).join(''); return `
${block.title || 'Certifications'}
${certHTML || '

No certifications

'}
`; case 'CustomText': return `
${block.config?.title ? `
${block.config.title}
` : ''}
${block.config?.content || ''}
`; case 'Footer': return `
${block.config?.content || 'Generated on ' + genDate}
`; default: return `
Unknown block type: ${type}
`; } } // ============================================================ // FULL HTML PAGE ASSEMBLY // ============================================================ /** * Build a complete HTML document from a template + CV data. */ function buildHTML(template, cvData, options) { const css = generateCanvasCSS(template); const blocksHTML = template.blocks.map(block => renderBlockHTML(block, cvData, options)).join('\n'); return ` CV - ${cvData.candidate?.first_name || ''} ${cvData.candidate?.last_name || ''}
${blocksHTML}
`; } // ============================================================ // PUPPETEER PDF RENDERER // ============================================================ /** * Render a CV Template + CV Data to a pixel-perfect A4 PDF. * * @param {Object} template - The JSON CV Template (layout definition) * @param {Object} cvData - The candidate's CV data * @param {Object} options - { generationDate, outputDir } * @returns {Promise<{html: string, pdfPath: string}>} */ async function renderToPDF(template, cvData, options = {}) { const html = buildHTML(template, cvData, options); const outputDir = options.outputDir || path.join(__dirname, 'output'); fs.mkdirSync(outputDir, { recursive: true }); const candidateName = `${cvData.candidate?.first_name || 'candidate'}_${cvData.candidate?.last_name || ''}`.trim().replace(/\s+/g, '_'); const templateName = (template.templateName || 'cv').replace(/\s+/g, '_'); const timestamp = Date.now(); const htmlPath = path.join(outputDir, `${candidateName}_${templateName}_${timestamp}.html`); const pdfPath = path.join(outputDir, `${candidateName}_${templateName}_${timestamp}.pdf`); // Save HTML for debugging fs.writeFileSync(htmlPath, html); // Auto-detect puppeteer chrome path const chromePaths = [ path.join(process.env.HOME || '/root', '.cache', 'puppeteer', 'chrome', 'linux-150.0.7871.24', 'chrome-linux64', 'chrome'), ]; // Also search for any chrome version in the puppeteer cache const pptrCache = path.join(process.env.HOME || '/root', '.cache', 'puppeteer', 'chrome'); if (fs.existsSync(pptrCache)) { for (const dir of fs.readdirSync(pptrCache)) { const candidate = path.join(pptrCache, dir, 'chrome-linux64', 'chrome'); if (fs.existsSync(candidate)) { chromePaths.unshift(candidate); break; } } } const chromePath = chromePaths.find(p => fs.existsSync(p)); // Launch Puppeteer and render const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], executablePath: chromePath, }); try { const page = await browser.newPage(); // Set the viewport to exact A4 size at 96 DPI await page.setViewport({ width: template.canvas.width, height: template.canvas.height, deviceScaleFactor: 2, // Retina quality }); // Load the HTML await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 60000 }); // Wait a bit for any font loading await new Promise(r => setTimeout(r, 500)); // Generate PDF with exact A4 dimensions, no margins await page.pdf({ path: pdfPath, width: `${template.canvas.width}px`, height: `${template.canvas.height}px`, printBackground: true, margin: { top: 0, right: 0, bottom: 0, left: 0 }, preferCSSPageSize: false, }); return { html, htmlPath, pdfPath }; } finally { await browser.close(); } } // ============================================================ // CLI ENTRY POINT (for testing) // ============================================================ if (require.main === module) { // Example template const exampleTemplate = { templateId: 'template_modern_01', templateName: 'Modern Two-Column Layout', canvas: { width: 794, height: 1123, columns: 12 }, blocks: [ { blockId: 'header', type: 'PersonalDetails', x: 0, y: 0, w: 12, h: 10 }, { blockId: 'summary', type: 'ProfessionalSummary', x: 0, y: 10, w: 12, h: 5 }, { blockId: 'experience', type: 'WorkExperience', x: 0, y: 15, w: 8, h: 50, config: { showAchievements: true } }, { blockId: 'skills', type: 'SkillsList', x: 8, y: 15, w: 4, h: 30, config: { groupByCategory: true, showYears: true, sidebar: true } }, { blockId: 'education', type: 'Education', x: 8, y: 45, w: 4, h: 20 }, { blockId: 'footer', type: 'Footer', x: 0, y: 100, w: 12, h: 5, config: { content: 'Confidential — Generated by CV Application' } } ] }; // Example CV data const exampleCVData = { candidate: { first_name: 'John', last_name: 'Smith', email: 'john.smith@email.com', phone: '+1-555-123-4567', address: 'Cape Town, South Africa', linkedin: 'linkedin.com/in/johnsmith', summary: 'Experienced software engineer with 8+ years in full-stack development specializing in .NET technologies and cloud architecture.' }, experience: [ { position: 'Senior Software Engineer', company: 'TechCorp International', location: 'Cape Town', start_date: '2022-01-01', end_date: null, description: 'Led a team building cloud-native microservices on Azure.', achievements: ['Reduced deployment time by 70%', 'Architected event-driven system with Azure Service Bus'], skills_used: ['C#', 'Azure', 'Docker', 'Kubernetes'] }, { position: 'Software Engineer', company: 'DataSoft Solutions', location: 'Johannesburg', start_date: '2019-03-01', end_date: '2021-12-01', description: 'Developed ASP.NET Core APIs serving 100k+ daily requests.', achievements: ['Built React admin dashboard', 'Implemented Entity Framework data layer'], skills_used: ['C#', 'ASP.NET Core', 'React', 'TypeScript'] } ], education: [ { degree: 'BSc Computer Science', field_of_study: 'Computer Science', institution: 'University of Cape Town', start_date: '2014-01-01', end_date: '2017-12-01', grade: 'First Class Honours' } ], skills: [ { skill_name: 'C# / .NET', skill_category: 'Programming', proficiency: 'Expert', years_experience: 8.5 }, { skill_name: 'Azure Cloud', skill_category: 'Cloud', proficiency: 'Advanced', years_experience: 6.5 }, { skill_name: 'React / TypeScript', skill_category: 'Frontend', proficiency: 'Advanced', years_experience: 5.5 }, { skill_name: 'SQL Server', skill_category: 'Database', proficiency: 'Expert', years_experience: 8.5 }, { skill_name: 'Docker / Kubernetes', skill_category: 'DevOps', proficiency: 'Advanced', years_experience: 4.5 } ], certifications: [ { name: 'Azure Developer Associate', issuer: 'Microsoft', issue_date: '2021-06-01' }, { name: 'CKAD', issuer: 'CNCF', issue_date: '2022-03-01' } ] }; renderToPDF(exampleTemplate, exampleCVData, { generationDate: '2026-07-15' }) .then(result => { console.log('PDF generated successfully:'); console.log(' HTML:', result.htmlPath); console.log(' PDF:', result.pdfPath); }) .catch(err => { console.error('Render failed:', err); process.exit(1); }); } module.exports = { renderToPDF, buildHTML, generateCanvasCSS };