feat: Carbone Word template integration with DOCX merge and LibreOffice PDF conversion

This commit is contained in:
root
2026-07-16 09:03:44 +00:00
parent ffa4655d39
commit 9dc14b0f58
11 changed files with 2696 additions and 3 deletions

View File

@@ -0,0 +1,24 @@
/**
* Carbone Express Server - Standalone microservice
* Runs on port 8771 alongside the FastAPI app on 8770
*/
const express = require('express');
const cors = require('cors');
const path = require('path');
const { router } = require('./carbone-service');
const app = express();
const PORT = 8771;
app.use(cors());
app.use(express.json({ limit: '50mb' }));
// Mount Carbone routes
app.use('/api/carbone', router);
// Health check
app.get('/health', (req, res) => res.json({ status: 'ok', service: 'carbone' }));
app.listen(PORT, '0.0.0.0', () => {
console.log(`Carbone service running on http://0.0.0.0:${PORT}`);
});

612
renderer/carbone-service.js Normal file
View File

@@ -0,0 +1,612 @@
/**
* Carbone Template Service - Node.js + Express
*
* Self-hosted Carbone (Community Edition) integration for CV template management.
* Uses LibreOffice for DOCX -> PDF conversion.
*
* Endpoints:
* GET /api/carbone/templates - List saved templates
* GET /api/carbone/master-template - Download master blank DOCX template
* POST /api/carbone/templates/upload - Upload a custom .docx template
* POST /api/carbone/templates/:id/render - Render a template with CV data -> PDF
* DELETE /api/carbone/templates/:id - Delete a template
*/
const express = require('express');
const multer = require('multer');
const carbone = require('carbone');
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
// ============================================================
// CONFIGURATION
// ============================================================
const TEMPLATES_DIR = path.join(__dirname, 'carbone-templates');
const MASTER_TEMPLATE_PATH = path.join(TEMPLATES_DIR, 'master_cv_template.docx');
// Ensure directories exist
fs.mkdirSync(TEMPLATES_DIR, { recursive: true });
// Multer config for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, TEMPLATES_DIR),
filename: (req, file, cb) => {
const id = uuidv4();
const ext = path.extname(file.originalname) || '.docx';
cb(null, `${id}${ext}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit
fileFilter: (req, file, cb) => {
const allowed = ['.docx', '.doc', '.odt', '.rtf'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowed.includes(ext)) {
cb(null, true);
} else {
cb(new Error(`File type ${ext} not supported. Use .docx, .doc, .odt, or .rtf`));
}
}
});
// Simple in-memory template registry (persisted to JSON file)
const REGISTRY_PATH = path.join(TEMPLATES_DIR, 'registry.json');
function loadRegistry() {
try {
return JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf-8'));
} catch {
return [];
}
}
function saveRegistry(registry) {
fs.writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2));
}
// ============================================================
// CARBONE TAG DOCUMENTATION
// ============================================================
/**
* CARBONE TAG REFERENCE (for use inside .docx templates)
* ============================================
*
* Carbone uses {d.property} syntax to map JSON data into the document.
* Our application's data schema maps as follows:
*
* SIMPLE FIELDS (text replacement):
* {d.firstName} -> Candidate's first name
* {d.lastName} -> Candidate's last name
* {d.email} -> Email address
* {d.phone} -> Phone number
* {d.address} -> Physical address
* {d.linkedin} -> LinkedIn URL
* {d.github} -> GitHub URL
* {d.website} -> Website URL
* {d.summary} -> Professional summary text
*
* LOOP TAGS (for arrays - repeat a section for each item):
*
* Work Experience loop:
* {d.experience[i].position} -> Job title
* {d.experience[i].company} -> Company name
* {d.experience[i].location} -> Location
* {d.experience[i].startDate} -> Start date (formatted)
* {d.experience[i].endDate} -> End date or "Present"
* {d.experience[i].description} -> Job description
* {d.experience[i].duration} -> Calculated duration (e.g., "2.5 years")
*
* To create a loop in Carbone, wrap the repeated section:
* {d.experience[i]:begin} ...content... {d.experience[i]:end}
*
* Achievements sub-loop (inside experience loop):
* {d.experience[i].achievements[j]:begin}
* • {d.experience[i].achievements[j]}
* {d.experience[i].achievements[j]:end}
*
* Skills loop:
* {d.skills[i].name} -> Skill name
* {d.skills[i].category} -> Skill category
* {d.skills[i].proficiency} -> Proficiency level
* {d.skills[i].years} -> Years of experience (dynamic)
*
* Skills grouped by category:
* {d.skillsByCategory[i].category:begin}
* {d.skillsByCategory[i].category}
* {d.skillsByCategory[i].skills[j].name:begin}
* • {d.skillsByCategory[i].skills[j].name} ({d.skillsByCategory[i].skills[j].years}y)
* {d.skillsByCategory[i].skills[j].name:end}
* {d.skillsByCategory[i].category:end}
*
* Education loop:
* {d.education[i].degree} -> Degree name
* {d.education[i].field} -> Field of study
* {d.education[i].institution} -> Institution name
* {d.education[i].startDate} -> Start date
* {d.education[i].endDate} -> End date
* {d.education[i].grade} -> Grade/Result
*
* Certifications loop:
* {d.certifications[i].name} -> Certification name
* {d.certifications[i].issuer} -> Issuing organization
* {d.certifications[i].date} -> Issue date
*
* CONDITIONALS:
* {d.summary:ifExist:begin} ...shows only if summary exists... {d.summary:ifExist:end}
* {d.linkedin:ifExist:begin} LinkedIn: {d.linkedin} {d.linkedin:ifExist:end}
*
* FORMATTING:
* {d.experience[i].startDate:formatD(MMM YYYY)} -> Formats date as "Jan 2022"
* {d.experience[i].endDate:formatD(MMM YYYY):ifEmpty(Present)}
*
* COMPOSITE (for full name):
* {d.firstName} {d.lastName}
*/
// ============================================================
// DATA TRANSFORMER
// ============================================================
/**
* Transform our application's candidate data into the flat structure
* that Carbone template tags expect. This handles date formatting,
* dynamic experience calculation, and grouping skills by category.
*
* @param {Object} candidateData - Our app's candidate data (from /api/candidates/:id)
* @param {string} referenceDate - Date for dynamic experience calc (YYYY-MM-DD)
* @returns {Object} Flattened data object for Carbone
*/
function transformCandidateData(candidateData, referenceDate) {
const refDate = referenceDate ? new Date(referenceDate) : new Date();
const candidate = candidateData.candidate || candidateData;
// Helper: format date as "Mon YYYY"
const fmtDate = (d) => {
if (!d) return '';
try {
const date = new Date(d);
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
} catch { return String(d); }
};
// Helper: calculate years between dates
const calcYears = (start, end) => {
if (!start) return 0;
const endDate = end ? new Date(end) : refDate;
const startDate = new Date(start);
const diff = (endDate - startDate) / (365.25 * 24 * 3600 * 1000);
return Math.round(diff * 10) / 10;
};
// Transform experience
const experience = (candidateData.experience || []).map(exp => {
const achievements = Array.isArray(exp.achievements) ? exp.achievements :
(typeof exp.achievements === 'string' ? safeJsonParse(exp.achievements, []) : []);
const skillsUsed = Array.isArray(exp.skills_used) ? exp.skills_used :
(typeof exp.skills_used === 'string' ? safeJsonParse(exp.skills_used, []) : []);
return {
position: exp.position || '',
company: exp.company || '',
location: exp.location || '',
startDate: fmtDate(exp.start_date),
endDate: exp.end_date ? fmtDate(exp.end_date) : 'Present',
description: exp.description || '',
duration: calcYears(exp.start_date, exp.end_date) + ' years',
achievements: achievements,
skillsUsed: skillsUsed.join(', ')
};
});
// Transform skills with dynamic years
const skills = (candidateData.skills || []).map(s => ({
name: s.skill_name || '',
category: s.skill_category || 'Other',
proficiency: s.proficiency || '',
years: s.years_experience || calcYears(s.start_date, s.end_date),
startYear: s.start_date ? new Date(s.start_date).getFullYear() : ''
}));
// Group skills by category for the grouped loop
const categoryMap = {};
for (const s of skills) {
if (!categoryMap[s.category]) categoryMap[s.category] = [];
categoryMap[s.category].push(s);
}
const skillsByCategory = Object.entries(categoryMap).map(([category, items]) => ({
category,
skills: items
}));
// Transform education
const education = (candidateData.education || []).map(e => ({
degree: e.degree || '',
field: e.field_of_study || '',
institution: e.institution || '',
startDate: fmtDate(e.start_date),
endDate: fmtDate(e.end_date),
grade: e.grade || ''
}));
// Transform certifications
const certifications = (candidateData.certifications || []).map(c => ({
name: c.name || '',
issuer: c.issuer || '',
date: fmtDate(c.issue_date),
expiryDate: fmtDate(c.expiry_date)
}));
return {
// Personal details
firstName: candidate.first_name || '',
lastName: candidate.last_name || '',
fullName: `${candidate.first_name || ''} ${candidate.last_name || ''}`.trim(),
email: candidate.email || '',
phone: candidate.phone || '',
address: candidate.address || '',
linkedin: candidate.linkedin || '',
github: candidate.github || '',
website: candidate.website || '',
summary: candidate.summary || '',
// Arrays (for loops)
experience: experience,
skills: skills,
skillsByCategory: skillsByCategory,
education: education,
certifications: certifications,
// Metadata
generationDate: refDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
referenceDate: refDate.toISOString().split('T')[0]
};
}
function safeJsonParse(str, fallback) {
try { return JSON.parse(str); } catch { return fallback; }
}
// ============================================================
// MASTER TEMPLATE GENERATOR
// ============================================================
/**
* Generate the master blank CV template (.docx) with all Carbone tags.
* This is the file users download, customize in Word, and re-upload.
*
* We build it programmatically using the docx library so we don't need
* a pre-made binary file.
*/
async function generateMasterTemplate() {
// We'll use a simple approach: create a minimal DOCX with the tags as text.
// Carbone processes the text content of the DOCX, so we just need the tags
// present in the document body.
const docxContent = createMasterDocxXML();
const templatePath = MASTER_TEMPLATE_PATH;
// Write as a .docx (which is a ZIP file with XML inside)
// For simplicity, we use the 'docx' npm package if available,
// otherwise we create a minimal valid DOCX manually.
try {
const DocxTemplater = require('docx');
// If docx package is available, use it
// But for now, we'll create a minimal DOCX manually
} catch (e) {
// docx package not available — use manual approach
}
// Create a minimal valid DOCX file (ZIP with required XML parts)
const archiver = require('archiver') ? require('archiver') : null;
if (archiver) {
// Use archiver to create the ZIP
}
// Simplest approach: use carbone's built-in template creation
// Actually, the easiest way is to create the DOCX using python-docx
// which is already installed in our venv
const { execSync } = require('child_process');
const pythonScript = path.join(__dirname, 'generate_master_template.py');
fs.writeFileSync(pythonScript, MASTER_TEMPLATE_PYTHON);
execSync(`python3 ${pythonScript} "${templatePath}"`, { timeout: 30000 });
return templatePath;
}
// Python script content for generating the master DOCX template
const MASTER_TEMPLATE_PYTHON = `
import sys
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
def create_master_template(output_path):
doc = Document()
# Set margins
for section in doc.sections:
section.top_margin = Inches(0.5)
section.bottom_margin = Inches(0.5)
section.left_margin = Inches(0.75)
section.right_margin = Inches(0.75)
# === HEADER: Name + Contact ===
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run('{d.fullName}')
run.font.size = Pt(24)
run.font.bold = True
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run('{d.email} | {d.phone} | {d.address}')
run.font.size = Pt(10)
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run('{d.linkedin:ifExist:begin}LinkedIn: {d.linkedin} | {d.linkedin:ifExist:end}{d.github:ifExist:begin}GitHub: {d.github} | {d.github:ifExist:end}{d.website:ifExist:begin}Web: {d.website}{d.website:ifExist:end}')
run.font.size = Pt(10)
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
# Divider
doc.add_paragraph('_' * 80)
# === PROFESSIONAL SUMMARY ===
h = doc.add_heading('Professional Summary', level=2)
p = doc.add_paragraph('{d.summary}')
# === WORK EXPERIENCE (loop) ===
doc.add_heading('Work Experience', level=2)
# Carbone loop: {d.experience[i]:begin} ... {d.experience[i]:end}
p = doc.add_paragraph('{d.experience[i]:begin}')
p = doc.add_paragraph()
run = p.add_run('{d.experience[i].position}')
run.font.bold = True
run.font.size = Pt(12)
run = p.add_run(' at {d.experience[i].company}')
run.font.size = Pt(12)
p = doc.add_paragraph('{d.experience[i].startDate} - {d.experience[i].endDate} ({d.experience[i].duration})')
p.runs[0].font.size = Pt(10)
p.runs[0].font.italic = True
p.runs[0].font.color.rgb = RGBColor(0x88, 0x88, 0x88)
p = doc.add_paragraph('{d.experience[i].description}')
# Achievements sub-loop
p = doc.add_paragraph('{d.experience[i].achievements[j]:begin}')
p = doc.add_paragraph('{d.experience[i].achievements[j]}', style='List Bullet')
p = doc.add_paragraph('{d.experience[i].achievements[j]:end}')
p = doc.add_paragraph('Skills: {d.experience[i].skillsUsed}')
p.runs[0].font.size = Pt(10)
p.runs[0].font.italic = True
p = doc.add_paragraph('{d.experience[i]:end}')
# === SKILLS (grouped by category) ===
doc.add_heading('Skills', level=2)
p = doc.add_paragraph('{d.skillsByCategory[i]:begin}')
p = doc.add_paragraph()
run = p.add_run('{d.skillsByCategory[i].category}: ')
run.font.bold = True
run.font.size = Pt(11)
p = doc.add_paragraph('{d.skillsByCategory[i].skills[j]:begin}')
p = doc.add_paragraph('{d.skillsByCategory[i].skills[j].name} ({d.skillsByCategory[i].skills[j].years}y){d.skillsByCategory[i].skills[j]:end}', style='List Bullet')
p = doc.add_paragraph('{d.skillsByCategory[i].end}')
# === EDUCATION (loop) ===
doc.add_heading('Education', level=2)
p = doc.add_paragraph('{d.education[i]:begin}')
p = doc.add_paragraph()
run = p.add_run('{d.education[i].degree}')
run.font.bold = True
run = p.add_run(' in {d.education[i].field}')
p = doc.add_paragraph('{d.education[i].institution}')
p.runs[0].font.size = Pt(11)
p = doc.add_paragraph('{d.education[i].startDate} - {d.education[i].endDate}')
p.runs[0].font.size = Pt(10)
p.runs[0].font.color.rgb = RGBColor(0x88, 0x88, 0x88)
p = doc.add_paragraph('{d.education[i].grade:ifExist:begin}Grade: {d.education[i].grade}{d.education[i].grade:ifExist:end}')
p = doc.add_paragraph('{d.education[i]:end}')
# === CERTIFICATIONS (loop) ===
doc.add_heading('Certifications', level=2)
p = doc.add_paragraph('{d.certifications[i]:begin}')
p = doc.add_paragraph('{d.certifications[i].name} - {d.certifications[i].issuer} ({d.certifications[i].date})', style='List Bullet')
p = doc.add_paragraph('{d.certifications[i]:end}')
# Footer
doc.add_paragraph()
p = doc.add_paragraph('Generated on {d.generationDate}')
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.runs[0].font.size = Pt(8)
p.runs[0].font.color.rgb = RGBColor(0xAA, 0xAA, 0xAA)
doc.save(output_path)
print(f'Master template saved to {output_path}')
if __name__ == '__main__':
create_master_template(sys.argv[1])
`;
// ============================================================
// API ROUTES
// ============================================================
// GET /api/carbone/templates - List all saved templates
router.get('/templates', (req, res) => {
const registry = loadRegistry();
res.json({ templates: registry });
});
// GET /api/carbone/master-template - Download master template
router.get('/master-template', async (req, res) => {
try {
// Generate master template if it doesn't exist
if (!fs.existsSync(MASTER_TEMPLATE_PATH)) {
await generateMasterTemplate();
}
res.download(MASTER_TEMPLATE_PATH, 'master_cv_template.docx');
} catch (err) {
console.error('Master template error:', err);
res.status(500).json({ error: 'Failed to generate master template: ' + err.message });
}
});
// POST /api/carbone/templates/upload - Upload a custom .docx template
router.post('/templates/upload', upload.single('template'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const name = req.body.name || path.basename(req.file.originalname, path.extname(req.file.originalname));
const id = path.basename(req.file.filename, path.extname(req.file.filename));
const registry = loadRegistry();
const entry = {
id: id,
name: name,
filename: req.file.filename,
originalName: req.file.originalname,
path: req.file.path,
uploadedAt: new Date().toISOString()
};
registry.push(entry);
saveRegistry(registry);
res.json({ success: true, template: entry });
});
// POST /api/carbone/templates/:id/render - Render template with data -> PDF
router.post('/templates/:id/render', async (req, res) => {
const templateId = req.params.id;
const registry = loadRegistry();
const template = registry.find(t => t.id === templateId);
if (!template) {
return res.status(404).json({ error: 'Template not found' });
}
if (!fs.existsSync(template.path)) {
return res.status(404).json({ error: 'Template file missing from disk' });
}
// Get candidate data from request body
const candidateData = req.body.candidateData || req.body;
const referenceDate = req.body.referenceDate || req.body.generation_date;
// Transform data for Carbone
const carboneData = transformCandidateData(candidateData, referenceDate);
// Step 1: Use Carbone to merge data into the DOCX template (no PDF conversion)
const mergePromise = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Carbone merge timed out')), 30000);
carbone.render(template.path, carboneData, {}, (err, result) => {
clearTimeout(timer);
if (err) reject(err);
else resolve(result);
});
});
try {
const docxBuffer = await mergePromise;
// Step 2: Convert merged DOCX to PDF using LibreOffice directly
// (Carbone's built-in converter has issues with pipe communication)
const tmpDir = path.join(__dirname, 'output');
fs.mkdirSync(tmpDir, { recursive: true });
const tmpId = uuidv4();
const tmpDocx = path.join(tmpDir, `${tmpId}.docx`);
const tmpPdf = path.join(tmpDir, `${tmpId}.pdf`);
fs.writeFileSync(tmpDocx, docxBuffer);
// Call LibreOffice headless for PDF conversion
const { execFile } = require('child_process');
const convertPromise = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('LibreOffice conversion timed out after 60s')), 60000);
execFile('soffice', [
'--headless', '--invisible', '--nocrashreport', '--nodefault',
'--nologo', '--nofirststartwizard', '--norestore',
'--convert-to', 'pdf',
'--outdir', tmpDir,
tmpDocx
], { timeout: 60000 }, (err, stdout, stderr) => {
clearTimeout(timer);
if (err) reject(err);
else if (!fs.existsSync(tmpPdf)) reject(new Error('PDF was not generated'));
else resolve(tmpPdf);
});
});
const pdfPath = await convertPromise;
const pdfBuffer = fs.readFileSync(pdfPath);
// Clean up temp files
try { fs.unlinkSync(tmpDocx); } catch {}
try { fs.unlinkSync(tmpPdf); } catch {}
// Set headers for PDF download
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${template.name.replace(/\s+/g, '_')}_CV.pdf"`);
res.setHeader('Content-Length', pdfBuffer.length);
res.send(pdfBuffer);
} catch (err) {
console.error('Carbone render error:', err);
if (err.message.includes('timed out')) {
res.status(504).json({ error: 'PDF generation timed out. LibreOffice may be busy. Try again.' });
} else if (err.message.includes('corrupt') || err.message.includes('invalid')) {
res.status(400).json({ error: 'Template file appears to be corrupted or invalid' });
} else {
res.status(500).json({ error: 'PDF generation failed: ' + err.message });
}
}
});
// DELETE /api/carbone/templates/:id - Delete a template
router.delete('/templates/:id', (req, res) => {
const templateId = req.params.id;
const registry = loadRegistry();
const template = registry.find(t => t.id === templateId);
if (!template) {
return res.status(404).json({ error: 'Template not found' });
}
// Delete file from disk
if (fs.existsSync(template.path)) {
fs.unlinkSync(template.path);
}
// Remove from registry
const newRegistry = registry.filter(t => t.id !== templateId);
saveRegistry(newRegistry);
res.json({ success: true, message: 'Template deleted' });
});
module.exports = { router, transformCandidateData, generateMasterTemplate };

Binary file not shown.

View File

@@ -0,0 +1,10 @@
[
{
"id": "433a0593-d3cf-414e-8763-ab5f87cbc75c",
"name": "Default Master Template",
"filename": "433a0593-d3cf-414e-8763-ab5f87cbc75c.docx",
"originalName": "master_cv_template.docx",
"path": "/root/workspace/cv-app/renderer/carbone-templates/433a0593-d3cf-414e-8763-ab5f87cbc75c.docx",
"uploadedAt": "2026-07-16T09:03:29.601Z"
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,12 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"puppeteer": "^25.3.0" "archiver": "^8.0.0",
"carbone": "^3.8.2",
"cors": "^2.8.6",
"express": "^5.2.1",
"multer": "^2.2.0",
"puppeteer": "^25.3.0",
"uuid": "^14.0.1"
} }
} }

44
renderer/test_carbone.js Normal file
View File

@@ -0,0 +1,44 @@
// Test Carbone render with master template + sample data
const carbone = require('carbone');
const { transformCandidateData } = require('./carbone-service');
const fs = require('fs');
const candidateData = {
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',
github: 'github.com/jsmith',
summary: 'Experienced software engineer with 8+ years in full-stack development specializing in .NET technologies and cloud architecture.'
},
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', skill_category: 'Frontend', proficiency: 'Advanced', years_experience: 5.5 }
],
experience: [
{ position: 'Senior Software Engineer', company: 'TechCorp', location: 'Cape Town', start_date: '2022-01-01', end_date: null, description: 'Led team building cloud-native microservices.', achievements: ['Reduced deployment time 70%', 'Architected event-driven system'], skills_used: ['C#','Azure','Docker'] },
{ position: 'Software Engineer', company: 'DataSoft', location: 'Johannesburg', start_date: '2019-03-01', end_date: '2021-12-01', description: 'Developed ASP.NET Core APIs.', achievements: ['Built React dashboard'], skills_used: ['C#','React'] }
],
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' }],
certifications: [{ name: 'Azure Developer Associate', issuer: 'Microsoft', issue_date: '2021-06-01' }]
};
const data = transformCandidateData(candidateData, '2026-07-16');
console.log('Transformed data keys:', Object.keys(data));
console.log('Experience count:', data.experience.length);
console.log('Skills count:', data.skills.length);
const templatePath = './carbone-templates/master_cv_template.docx';
const options = { convertTo: 'pdf', hardRefreshCache: true };
carbone.render(templatePath, data, options, function(err, result) {
if (err) {
console.error('ERROR:', err.message);
process.exit(1);
}
const outPath = './output/carbone_test.pdf';
fs.writeFileSync(outPath, result);
console.log('PDF saved:', outPath, 'Size:', result.length, 'bytes');
});

26
renderer/test_merge.js Normal file
View File

@@ -0,0 +1,26 @@
// Simple Carbone DOCX merge test (no PDF conversion)
const carbone = require('carbone');
const fs = require('fs');
const data = {
fullName: 'John Smith', email: 'john@test.com', phone: '+1-555-123-4567',
address: 'Cape Town', linkedin: 'linkedin.com/in/john', github: 'github.com/john',
website: '', summary: 'Software engineer with 8 years experience.',
experience: [
{ position: 'Senior Engineer', company: 'TechCorp', startDate: 'Jan 2022', endDate: 'Present', duration: '4.5 years', description: 'Led team.', achievements: ['Reduced time 70%'], skillsUsed: 'C#, Azure' },
{ position: 'Software Engineer', company: 'DataSoft', startDate: 'Mar 2019', endDate: 'Dec 2021', duration: '2.8 years', description: 'Built APIs.', achievements: ['Built dashboard'], skillsUsed: 'C#, React' }
],
skillsByCategory: [
{ category: 'Programming', skills: [{ name: 'C#', years: 8.5 }, { name: 'React', years: 5.5 }] },
{ category: 'Cloud', skills: [{ name: 'Azure', years: 6.5 }] }
],
education: [{ degree: 'BSc', field: 'CS', institution: 'UCT', startDate: '2014', endDate: '2017', grade: 'First Class' }],
certifications: [{ name: 'Azure Dev', issuer: 'Microsoft', date: 'Jun 2021' }],
generationDate: 'July 16, 2026'
};
carbone.render('./carbone-templates/master_cv_template.docx', data, {}, function(err, result) {
if (err) { console.error('Merge error:', err.message || err); process.exit(1); }
fs.writeFileSync('./output/carbone_merged.docx', result);
console.log('DOCX merged:', result.length, 'bytes');
});

151
static/carbone.js Normal file
View File

@@ -0,0 +1,151 @@
// Carbone Word Template Manager - Frontend
const CARBONE_API = 'http://localhost:8771/api/carbone';
// ============================================================
// NAVIGATION
// ============================================================
// Load carbone data when the page is shown via a MutationObserver
const carboneNavObserver = new MutationObserver(() => {
if (document.getElementById('page-carbone') && document.getElementById('page-carbone').classList.contains('active')) {
loadCarboneTemplates();
}
});
document.addEventListener('DOMContentLoaded', () => {
const carbonePage = document.getElementById('page-carbone');
if (carbonePage) carboneNavObserver.observe(carbonePage, { attributes: true, attributeFilter: ['class'] });
});
// ============================================================
// DOWNLOAD MASTER TEMPLATE
// ============================================================
function downloadMasterTemplate() {
window.open(CARBONE_API + '/master-template', '_blank');
}
// ============================================================
// UPLOAD CUSTOM TEMPLATE
// ============================================================
const carboneUploadZone = document.getElementById('carbone-upload-zone');
if (carboneUploadZone) {
carboneUploadZone.addEventListener('dragover', e => { e.preventDefault(); carboneUploadZone.classList.add('dragover'); });
carboneUploadZone.addEventListener('dragleave', () => carboneUploadZone.classList.remove('dragover'));
carboneUploadZone.addEventListener('drop', e => {
e.preventDefault();
carboneUploadZone.classList.remove('dragover');
if (e.dataTransfer.files.length) uploadCarboneTemplate(e.dataTransfer.files[0]);
});
}
async function uploadCarboneTemplate(file) {
if (!file) return;
const name = document.getElementById('carbone-template-name').value || file.name.replace(/\.[^.]+$/, '');
const status = document.getElementById('carbone-upload-status');
status.innerHTML = '<div class="loading"><span class="spinner"></span> Uploading template...</div>';
const formData = new FormData();
formData.append('template', file);
formData.append('name', name);
try {
const resp = await fetch(CARBONE_API + '/templates/upload', { method: 'POST', body: formData });
const data = await resp.json();
if (data.success) {
status.innerHTML = '<div class="toast success" style="position:relative">Template uploaded successfully!</div>';
toast('Template uploaded: ' + name);
document.getElementById('carbone-template-name').value = '';
loadCarboneTemplates();
} else {
status.innerHTML = '<div class="toast error" style="position:relative">Upload failed: ' + (data.error || 'Unknown error') + '</div>';
}
} catch (e) {
status.innerHTML = '<div class="toast error" style="position:relative">Upload failed: ' + e.message + '</div>';
}
}
// ============================================================
// LIST TEMPLATES
// ============================================================
async function loadCarboneTemplates() {
const list = document.getElementById('carbone-templates-list');
if (!list) return;
list.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await fetch(CARBONE_API + '/templates');
const data = await resp.json();
if (!data.templates || !data.templates.length) {
list.innerHTML = '<p class="text-muted">No custom templates uploaded yet. Download the master template, customize it in Word, and upload it here.</p>';
return;
}
list.innerHTML = data.templates.map(t => `
<div class="skill-row">
<div>
<strong>${t.name}</strong>
<div class="text-muted text-sm">Uploaded: ${new Date(t.uploadedAt).toLocaleDateString()} · ${t.originalName}</div>
</div>
<div class="flex gap-8">
<button class="btn btn-sm btn-outline" onclick="renderSampleCV('${t.id}')">Generate Sample CV</button>
<button class="btn btn-sm btn-danger" onclick="deleteCarboneTemplate('${t.id}')">Delete</button>
</div>
</div>
`).join('');
} catch (e) {
list.innerHTML = '<p class="text-muted">Error loading templates: ' + e.message + '</p>';
}
}
// ============================================================
// RENDER SAMPLE CV
// ============================================================
async function renderSampleCV(templateId) {
toast('Generating sample CV PDF...');
// Get first candidate for sample data
try {
const candidatesResp = await fetch('/api/candidates?limit=1');
const candidatesData = await candidatesResp.json();
let cvData = null;
if (candidatesData.candidates && candidatesData.candidates.length > 0) {
const fullResp = await fetch('/api/candidates/' + candidatesData.candidates[0].id);
cvData = await fullResp.json();
}
if (!cvData) {
toast('Upload a CV first to generate a sample', 'error');
return;
}
const resp = await fetch(CARBONE_API + '/templates/' + templateId + '/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ candidateData: cvData })
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || 'Render failed');
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
toast('Sample CV generated');
} catch (e) {
toast('Render failed: ' + e.message, 'error');
}
}
// ============================================================
// DELETE TEMPLATE
// ============================================================
async function deleteCarboneTemplate(templateId) {
if (!confirm('Delete this template?')) return;
try {
await fetch(CARBONE_API + '/templates/' + templateId, { method: 'DELETE' });
toast('Template deleted');
loadCarboneTemplates();
} catch (e) {
toast('Delete failed: ' + e.message, 'error');
}
}

View File

@@ -23,6 +23,7 @@
<li><a href="#" class="nav-link" data-page="candidates">Candidates</a></li> <li><a href="#" class="nav-link" data-page="candidates">Candidates</a></li>
<li><a href="#" class="nav-link" data-page="upload">Upload CV</a></li> <li><a href="#" class="nav-link" data-page="upload">Upload CV</a></li>
<li><a href="#" class="nav-link" data-page="templates">Templates</a></li> <li><a href="#" class="nav-link" data-page="templates">Templates</a></li>
<li><a href="#" class="nav-link" data-page="carbone">Word Templates</a></li>
<li><a href="#" class="nav-link" data-page="requirements">Requirements</a></li> <li><a href="#" class="nav-link" data-page="requirements">Requirements</a></li>
<li><a href="#" class="nav-link" data-page="generated">Generated CVs</a></li> <li><a href="#" class="nav-link" data-page="generated">Generated CVs</a></li>
<li><a href="#" class="nav-link" data-page="chat">AI Chat</a></li> <li><a href="#" class="nav-link" data-page="chat">AI Chat</a></li>
@@ -76,7 +77,38 @@
<div id="templates-list"></div> <div id="templates-list"></div>
</div> </div>
<!-- REQUIREMENTS --> <!-- CARBONE WORD TEMPLATES -->
<div id="page-carbone" class="page">
<h2 style="margin-bottom:20px">Word Templates (Carbone)</h2>
<div class="card mb-16">
<div class="card-header"><span class="card-title">How It Works</span></div>
<div class="text-sm text-muted">
1. Download the master template (.docx) with all data tags<br>
2. Open in Microsoft Word, customize fonts/colors/layout, save<br>
3. Upload your customized .docx back here<br>
4. Generate CVs by merging candidate data with your template -> PDF
</div>
<button class="btn mt-16" onclick="downloadMasterTemplate()">Download Master Template (.docx)</button>
</div>
<div class="card mb-16">
<div class="card-header"><span class="card-title">Upload Custom Template</span></div>
<div class="form-group">
<label>Template Name</label>
<input type="text" id="carbone-template-name" placeholder="e.g. Corporate Blue, Minimalist Professional">
</div>
<div class="upload-zone" id="carbone-upload-zone" onclick="document.getElementById('carbone-file-input').click()">
<p style="font-size:16px; margin-bottom:8px">Drop your .docx file here or click to browse</p>
<p class="text-muted text-sm">Must be a .docx file with Carbone tags from the master template</p>
</div>
<input type="file" id="carbone-file-input" accept=".docx,.doc,.odt,.rtf" style="display:none" onchange="uploadCarboneTemplate(this.files[0])">
<div id="carbone-upload-status" style="margin-top:12px"></div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Saved Templates</span></div>
<div id="carbone-templates-list"></div>
</div>
</div>
<div id="page-requirements" class="page"> <div id="page-requirements" class="page">
<h2 style="margin-bottom:20px">Requirement Requests</h2> <h2 style="margin-bottom:20px">Requirement Requests</h2>
<div class="flex mb-16 gap-8"> <div class="flex mb-16 gap-8">
@@ -113,5 +145,6 @@
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
<script src="/static/builder.js"></script> <script src="/static/builder.js"></script>
<script src="/static/designer.js"></script> <script src="/static/designer.js"></script>
<script src="/static/carbone.js"></script>
</body> </body>
</html> </html>