feat: A4 Gridstack.js template designer + Puppeteer PDF renderer

This commit is contained in:
root
2026-07-16 07:57:16 +00:00
parent 56abeb9e4d
commit 70735502f2
10 changed files with 1802 additions and 3 deletions

4
renderer/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
output/
*.pdf
*.html

331
renderer/package-lock.json generated Normal file
View File

@@ -0,0 +1,331 @@
{
"name": "renderer",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "renderer",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"puppeteer": "^25.3.0"
}
},
"node_modules/@puppeteer/browsers": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.6.tgz",
"integrity": "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA==",
"license": "Apache-2.0",
"dependencies": {
"modern-tar": "^0.7.6",
"yargs": "^18.0.0"
},
"bin": {
"browsers": "lib/main-cli.js"
},
"engines": {
"node": ">=22.12.0"
},
"peerDependencies": {
"proxy-agent": ">=8.0.1",
"yauzl": "^2.10.0 || ^3.4.0"
},
"peerDependenciesMeta": {
"proxy-agent": {
"optional": true
},
"yauzl": {
"optional": true
}
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chromium-bidi": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz",
"integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==",
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
},
"peerDependencies": {
"devtools-protocol": "*"
}
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/devtools-protocol": {
"version": "0.0.1638949",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1638949.tgz",
"integrity": "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA==",
"license": "BSD-3-Clause"
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/modern-tar": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz",
"integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/puppeteer": {
"version": "25.3.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.3.0.tgz",
"integrity": "sha512-O1tx8S315aw8eI99HZ5ZNcVEzJ9+jKF//eO5UvfZ3cXJ6okZ5sX3Y50u7DJaM+ewEK4LqXP068tBhfRaWikj+g==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.0.6",
"chromium-bidi": "16.0.1",
"devtools-protocol": "0.0.1638949",
"lilconfig": "^3.1.3",
"puppeteer-core": "25.3.0",
"typed-query-selector": "^2.12.2"
},
"bin": {
"puppeteer": "lib/puppeteer/node/cli.js"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/puppeteer-core": {
"version": "25.3.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.3.0.tgz",
"integrity": "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.0.6",
"chromium-bidi": "16.0.1",
"devtools-protocol": "0.0.1638949",
"typed-query-selector": "^2.12.2",
"webdriver-bidi-protocol": "0.4.2",
"ws": "^8.21.0"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/typed-query-selector": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
"license": "MIT"
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz",
"integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==",
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^7.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

15
renderer/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "renderer",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"puppeteer": "^25.3.0"
}
}

641
renderer/render.js Normal file
View File

@@ -0,0 +1,641 @@
/**
* 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 };