From 250d5496ece93ba80433282fe8a7ae29a4c3e60b Mon Sep 17 00:00:00 2001 From: root Date: Sun, 19 Jul 2026 07:44:10 +0000 Subject: [PATCH] feat: add HTML preview pane for templates via Carbone+LibreOffice --- renderer/carbone-service.js | 61 ++++++++++++++++++ .../433a0593-d3cf-414e-8763-ab5f87cbc75c.docx | Bin 40675 -> 40675 bytes renderer/carbone-templates/registry.json | 2 +- static/carbone.js | 48 +++++++++++++- static/index.html | 2 +- 5 files changed, 109 insertions(+), 4 deletions(-) diff --git a/renderer/carbone-service.js b/renderer/carbone-service.js index fdb8541..f61f920 100644 --- a/renderer/carbone-service.js +++ b/renderer/carbone-service.js @@ -709,6 +709,67 @@ router.post('/templates/:id/render', async (req, res) => { } }); +// POST /api/carbone/templates/:id/preview - Render template with data -> HTML preview +router.post('/templates/:id/preview', 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' }); + + const candidateData = req.body.candidateData || req.body; + const referenceDate = req.body.referenceDate || req.body.generation_date; + const carboneData = transformCandidateData(candidateData, referenceDate); + + try { + // Step 1: Merge data into DOCX with Carbone + const docxBuffer = await 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); + }); + }); + + // Step 2: Convert to HTML with LibreOffice + const tmpDir = path.join(__dirname, 'output'); + fs.mkdirSync(tmpDir, { recursive: true }); + const tmpId = uuidv4(); + const tmpDocx = path.join(tmpDir, `${tmpId}.docx`); + const tmpHtml = path.join(tmpDir, `${tmpId}.html`); + + fs.writeFileSync(tmpDocx, docxBuffer); + + const { execFile } = require('child_process'); + const htmlPath = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('LibreOffice conversion timed out')), 60000); + execFile('soffice', [ + '--headless', '--invisible', '--nocrashreport', '--nodefault', + '--nologo', '--nofirststartwizard', '--norestore', + '--convert-to', 'html', '--outdir', tmpDir, tmpDocx + ], { timeout: 60000 }, (err) => { + clearTimeout(timer); + if (err) reject(err); + else if (!fs.existsSync(tmpHtml)) reject(new Error('HTML was not generated')); + else resolve(tmpHtml); + }); + }); + + const htmlBuffer = fs.readFileSync(htmlPath); + + // Clean up temp files + try { fs.unlinkSync(tmpDocx); } catch {} + try { fs.unlinkSync(tmpHtml); } catch {} + + res.setHeader('Content-Type', 'text/html'); + res.send(htmlBuffer); + } catch (err) { + console.error('Preview error:', err.message); + res.status(500).json({ error: 'Preview failed: ' + err.message }); + } +}); + // DELETE /api/carbone/templates/:id - Delete a template router.delete('/templates/:id', (req, res) => { const templateId = req.params.id; diff --git a/renderer/carbone-templates/433a0593-d3cf-414e-8763-ab5f87cbc75c.docx b/renderer/carbone-templates/433a0593-d3cf-414e-8763-ab5f87cbc75c.docx index 0b2d90bf7113bb91064faa35e15242f062baeb32..c55c03a8e7933af0014c4fb68eef5f038d89d67c 100644 GIT binary patch delta 809 zcmaF7m+A3dCY}IqW)=|!5ZINmktdsjX;;SNN{&N7YO^ipcSaCx#vPptq9<4U0`vQ; zL|8a>Wqgb&yUtlXd3Bu{4@d1SyCa);)xWk_$!@+|r_03nbuxG3Z%I=Rm8st%lfQmG zc;t~X>$y4%n={&HI@_X4kcMgZbQ@aUgoKLbvSX=sALuXLhwh zxSK)L)$T1I#oaySOkf9S<+k}u=IU3d&%2`_&>DaA(Y@}CvzIuNqRwQV9aH{fUM3*eFyI7yKHjL+N$t3J43x8OeE^XfutolM_&YX3+(!l zcR0i{u#AnJ|GgLgQP;iQX@cKs_rF^0IqO;am+yzS%U=#IPIg}|A`qB;^kM?{qGiVo zTt9w&sjo4of4a(1tp*qI7WsnLIqT-$@||_~=ZhcQJbUZ8t(LvptaH-DMYQMmm+nQ3 z+>S~w-ejK8t6Y9);>}YX{4aOU=Y4U$c}MjbyUO{sSJh&zVv7|es+so8ER1&v^_!%+ z{`=Qh4V9(BdkPb0^zJ!v?po9zW>~V~04A%=M<%p_Bh`D-ZBX=S zOy&S{ji!78an+_avVbCY66FgN{&N7YO^ipcSaCx#vPptq9<4U0`vQ; zL|8aJd3=nS%sR7S^6EM@9*f#rc1Jexs()>bVPM3-C-&#+9E`z0EAVZol}mb+dfa9I#n?oA)z=%&u$i2J^W)<3RLeg>Ko&(Q^bR&+KZ2 za5saftKC~bio1KtnZOQsCGo*|GFQJsz26-Jf!27{Pqo+llx`%YHaARLtrYr7EZ}{Q zZ3=H^?oH;Kdur#VnEzJ3vRm@}%=3>QYp&m(JGrKb|JE$glVUw82}?q6UD5uOUOT@| z+0!9$O2(Q-(U*!7Uay+c=l%NO^zD2$Ssb2EW*k@=J&||O<0))A8^c;$LuN$OY~3d+ z5gcD1to~&8ipFO>v6sXGG#Z=u4qQ3wvdLM?Qu%IszFWg25vh!W2@7s2EoRqk%0E1v zH~Fc@&8#=FHJW$D)4!U%h`dpHcagSR<{bOQ=lSjH`~7{lO3s<&uqw#2PfB`)R~1LH zh+W)0hestg>W0(gSfb*0%9&qVa_ZFE6*uSe_w9GSUmv;N>5bjVMLv!b;;ei2M_f%f zz|Ass&zF;y?;|V>qy;}H)_q^iR(dbIw{P1$QZ%a?DcRM*} zy7m9%D<3mlG`HKiXT6PR`TgtPmz;_WTM{|%*- z%mL;aP5A`ks!eTV0Y&c0nf4&+)l3Hvr8CPGL}kyi22m?!*@37Rv+NKFPj9w4NGyFe zSmCnSU=`11dw>KD=0GW#$ 0) { + const fullResp = await fetch('/api/candidates/' + candidatesData.candidates[0].id); + cvData = await fullResp.json(); + } + + if (!cvData) { + toast('Upload a CV first to preview', 'error'); + return; + } + + const resp = await fetch(CARBONE_API + '/templates/' + templateId + '/preview', { + 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 || 'Preview failed'); + } + + const html = await resp.text(); + // Show the HTML in a modal with an iframe + const body = ''; + showModal(body, 'Template Preview', 'large'); + toast('Preview generated'); + } catch (e) { + toast('Preview failed: ' + e.message, 'error'); + } +} + // ============================================================ // DELETE TEMPLATE // ============================================================ @@ -250,7 +293,8 @@ async function loadOnlyofficeTemplates() {
- + +
diff --git a/static/index.html b/static/index.html index f54510d..b7ed968 100644 --- a/static/index.html +++ b/static/index.html @@ -140,6 +140,6 @@ - + \ No newline at end of file