641 lines
20 KiB
JavaScript
641 lines
20 KiB
JavaScript
/**
|
|
* 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(`<span>📧 ${c.email}</span>`);
|
|
if (c.phone) contactParts.push(`<span>📞 ${c.phone}</span>`);
|
|
if (c.address) contactParts.push(`<span>📍 ${c.address}</span>`);
|
|
if (c.linkedin) contactParts.push(`<span>🔗 ${c.linkedin}</span>`);
|
|
if (c.github) contactParts.push(`<span>💻 ${c.github}</span>`);
|
|
if (c.website) contactParts.push(`<span>🌐 ${c.website}</span>`);
|
|
return `
|
|
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
|
<h1 class="cv-name">${c.first_name || ''} ${c.last_name || ''}</h1>
|
|
${c.summary ? `<div class="cv-title">${c.summary.split('.')[0]}.</div>` : ''}
|
|
<div class="cv-contact">${contactParts.join('')}</div>
|
|
</div>
|
|
`;
|
|
|
|
case 'ProfessionalSummary':
|
|
const summary = cvData.candidate?.summary || cvData.summary || '';
|
|
return `
|
|
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
|
<div class="cv-section-title">${block.title || 'Professional Summary'}</div>
|
|
<p>${summary}</p>
|
|
</div>
|
|
`;
|
|
|
|
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 `
|
|
<div class="exp-entry">
|
|
<div class="exp-role">${e.position || ''}</div>
|
|
<div class="exp-company">${e.company || ''}${e.location ? ' — ' + e.location : ''}</div>
|
|
<div class="exp-date">${startStr} — ${endStr}</div>
|
|
${e.description ? `<div class="exp-desc">${e.description}</div>` : ''}
|
|
${showAch && ach.length ? `<ul class="exp-achievements">${ach.map(a => `<li>${a}</li>`).join('')}</ul>` : ''}
|
|
${showSkills && skillsUsed.length ? `<div class="exp-desc"><em>Skills: ${skillsUsed.join(', ')}</em></div>` : ''}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
return `
|
|
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
|
<div class="cv-section-title">${block.title || 'Work Experience'}</div>
|
|
${expHTML || '<p style="color:#999">No experience data</p>'}
|
|
</div>
|
|
`;
|
|
|
|
case 'Education':
|
|
const educations = cvData.education || [];
|
|
let eduHTML = educations.map(e => `
|
|
<div class="edu-entry">
|
|
<div class="edu-degree">${e.degree || ''}${e.field_of_study ? ' in ' + e.field_of_study : ''}</div>
|
|
<div class="edu-school">${e.institution || ''}</div>
|
|
<div class="edu-date">${e.start_date ? fmtDate(e.start_date) : ''} — ${e.end_date ? fmtDate(e.end_date) : ''}</div>
|
|
${e.grade ? `<div class="edu-school">Grade: ${e.grade}</div>` : ''}
|
|
</div>
|
|
`).join('');
|
|
return `
|
|
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
|
<div class="cv-section-title">${block.title || 'Education'}</div>
|
|
${eduHTML || '<p style="color:#999">No education data</p>'}
|
|
</div>
|
|
`;
|
|
|
|
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]) => `
|
|
<div class="skill-group">
|
|
<div class="skill-cat">${cat}</div>
|
|
<div class="skill-items">
|
|
${items.map(s => {
|
|
let txt = `<span class="skill-item">${s.skill_name}`;
|
|
if (showProf && s.proficiency) txt += ` (${s.proficiency})`;
|
|
if (showYears && s.years_experience) txt += ` <span class="skill-years">${s.years_experience}y</span>`;
|
|
txt += '</span>';
|
|
return txt;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
return `
|
|
<div id="block-${blockId}" class="cv-block cv-block-${type} ${block.config?.sidebar ? 'cv-sidebar' : ''}">
|
|
<div class="cv-section-title">${block.title || 'Skills'}</div>
|
|
${skillsHTML || '<p style="color:#999">No skills data</p>'}
|
|
</div>
|
|
`;
|
|
} else {
|
|
let skillsHTML = skills.map(s => {
|
|
let txt = `<span class="skill-item">${s.skill_name}`;
|
|
if (showProf && s.proficiency) txt += ` (${s.proficiency})`;
|
|
if (showYears && s.years_experience) txt += ` <span class="skill-years">${s.years_experience}y</span>`;
|
|
txt += '</span>';
|
|
return txt;
|
|
}).join('');
|
|
return `
|
|
<div id="block-${blockId}" class="cv-block cv-block-${type} ${block.config?.sidebar ? 'cv-sidebar' : ''}">
|
|
<div class="cv-section-title">${block.title || 'Skills'}</div>
|
|
<div class="skill-items">${skillsHTML}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
case 'Certifications':
|
|
const certs = cvData.certifications || [];
|
|
let certHTML = certs.map(c => `
|
|
<div class="cert-entry">
|
|
<div class="cert-name">${c.name || ''}</div>
|
|
${c.issuer ? `<div class="cert-issuer">${c.issuer}</div>` : ''}
|
|
${c.issue_date ? `<div class="cert-issuer">${fmtDate(c.issue_date)}${c.expiry_date ? ' — ' + fmtDate(c.expiry_date) : ''}</div>` : ''}
|
|
</div>
|
|
`).join('');
|
|
return `
|
|
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
|
<div class="cv-section-title">${block.title || 'Certifications'}</div>
|
|
${certHTML || '<p style="color:#999">No certifications</p>'}
|
|
</div>
|
|
`;
|
|
|
|
case 'CustomText':
|
|
return `
|
|
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
|
${block.config?.title ? `<div class="cv-section-title">${block.config.title}</div>` : ''}
|
|
<div>${block.config?.content || ''}</div>
|
|
</div>
|
|
`;
|
|
|
|
case 'Footer':
|
|
return `
|
|
<div id="block-${blockId}" class="cv-block cv-block-${type}">
|
|
${block.config?.content || 'Generated on ' + genDate}
|
|
</div>
|
|
`;
|
|
|
|
default:
|
|
return `<div id="block-${blockId}" class="cv-block">Unknown block type: ${type}</div>`;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 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 `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>CV - ${cvData.candidate?.first_name || ''} ${cvData.candidate?.last_name || ''}</title>
|
|
<style>${css}</style>
|
|
</head>
|
|
<body>
|
|
<div class="cv-page" id="cv-page">
|
|
${blocksHTML}
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
// ============================================================
|
|
// 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 }; |