feat: Carbone Word template integration with DOCX merge and LibreOffice PDF conversion
This commit is contained in:
24
renderer/carbone-server.js
Normal file
24
renderer/carbone-server.js
Normal 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
612
renderer/carbone-service.js
Normal 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.
BIN
renderer/carbone-templates/master_cv_template.docx
Normal file
BIN
renderer/carbone-templates/master_cv_template.docx
Normal file
Binary file not shown.
10
renderer/carbone-templates/registry.json
Normal file
10
renderer/carbone-templates/registry.json
Normal 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"
|
||||
}
|
||||
]
|
||||
1789
renderer/package-lock.json
generated
1789
renderer/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,12 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"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
44
renderer/test_carbone.js
Normal 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
26
renderer/test_merge.js
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user