diff --git a/lib/documents.js b/lib/documents.js index a07af68..103198f 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -653,27 +653,61 @@ function cvEducation(doc, S, dry, cur, e) { cur.y += 2.6 * S; } -// Header: name + position (top-left) and a right-aligned contact block -// (top-right), separated by a confident rule — the canonical modern CV head. -function composeCvHeader(doc, S, dry, cur, header, titel) { +// Header: name + position (top-left) and a contact block, separated by a +// confident rule — the canonical modern CV head. With an applicant photo +// (`foto`), the photo sits top-right and the contact details move to the +// left column under the name; without one, the contact block is right-aligned +// top-right as before. +function composeCvHeader(doc, S, dry, cur, header, titel, foto) { const top = cur.y; - const leftW = RV_W * 0.62; + const contactLines = [header.email, header.telefon, cityName(header) || header.ort, header.webseite].filter(Boolean); + + // Optional applicant photo (top-right). Fit a portrait frame preserving + // aspect ratio; a thin hairline border gives it a clean edge on white. + let photoOk = false, photoW = 0, photoH = 0; + if (foto && foto.dataUrl) { + try { + const props = doc.getImageProperties(foto.dataUrl); + const maxW = 30 * S, maxH = 40 * S; + photoW = maxW; + photoH = photoW * props.height / props.width; + if (photoH > maxH) { photoH = maxH; photoW = photoH * props.width / props.height; } + photoOk = props.width > 0 && props.height > 0; + } catch (e) { photoOk = false; } + } + + const leftW = photoOk ? (RV_W - 30 * S - 6 * S) : RV_W * 0.62; write(doc, S, dry, cur, header.name, { pt: 22, style: 'bold', color: RC.ink, x: RV.mx, width: leftW, factor: 1.05 }); if (titel) write(doc, S, dry, cur, titel, { pt: 12, color: RC.sub, x: RV.mx, width: leftW, factor: 1.15 }); if (header.geburtsdatum) write(doc, S, dry, cur, `Geburtsdatum: ${header.geburtsdatum}`, { pt: 9, color: RC.sub, x: RV.mx, width: leftW, factor: 1.3 }); + if (photoOk) { + // contact details left-aligned under the name/title (photo takes the right) + contactLines.forEach((line) => write(doc, S, dry, cur, line, { pt: 9, color: RC.sub, x: RV.mx, width: leftW, factor: 1.34 })); + } const leftBottom = cur.y; - const contactLines = [header.email, header.telefon, cityName(header) || header.ort, header.webseite].filter(Boolean); - const rc = { y: top }; - contactLines.forEach((line) => write(doc, S, dry, rc, line, { pt: 9, color: RC.sub, x: RV.mx, width: RV_W, align: 'right', right: RV_R, factor: 1.34 })); + if (photoOk) { + const px = RV_R - photoW; + const py = top; + if (!dry) { + doc.addImage(foto.dataUrl, foto.format || 'PNG', px, py, photoW, photoH); + doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]); + doc.setLineWidth(0.3 * S); + doc.rect(px, py, photoW, photoH); + } + cur.y = Math.max(leftBottom, py + photoH) + 3.2 * S; + } else { + const rc = { y: top }; + contactLines.forEach((line) => write(doc, S, dry, rc, line, { pt: 9, color: RC.sub, x: RV.mx, width: RV_W, align: 'right', right: RV_R, factor: 1.34 })); + cur.y = Math.max(leftBottom, rc.y) + 3.2 * S; + } - cur.y = Math.max(leftBottom, rc.y) + 3.2 * S; rule(doc, S, dry, cur.y, RV.mx, RV_R, RC.ink, 0.6); cur.y += 5 * S; } -function composeCV(doc, S, dry, cur, { cv, header, titel }) { - composeCvHeader(doc, S, dry, cur, header, titel); +function composeCV(doc, S, dry, cur, { cv, header, titel, foto }) { + composeCvHeader(doc, S, dry, cur, header, titel, foto); if (cv.profil) { cvHeading(doc, S, dry, cur, 'Profil'); write(doc, S, dry, cur, cv.profil, { pt: 10, color: RC.ink, x: RV.mx, width: RV_W, factor: 1.45 }); @@ -701,14 +735,14 @@ function composeCV(doc, S, dry, cur, { cv, header, titel }) { } } -function renderCV(cv, header, titel) { +function renderCV(cv, header, titel, foto) { const doc = makeDoc(); - const m = { y: RV.top }; composeCV(doc, 1, true, m, { cv, header, titel }); + const m = { y: RV.top }; composeCV(doc, 1, true, m, { cv, header, titel, foto }); const need = m.y - RV.top; const avail = PAGE.h - RV.top - RV.bottom; let S = 1; if (need > avail) S = Math.max(MIN_SCALE, (avail / need) * 0.99); - composeCV(doc, S, false, { y: RV.top }, { cv, header, titel }); + composeCV(doc, S, false, { y: RV.top }, { cv, header, titel, foto }); return Buffer.from(doc.output('arraybuffer')); } @@ -841,9 +875,9 @@ function buildHeader(settings, kontakt, headline) { }; } -function renderLebenslaufPdf(cv, header) { +function renderLebenslaufPdf(cv, header, foto) { // Title at the top = the position (headline), falling back to the name. - return renderCV(cv, header, header.headline || ''); + return renderCV(cv, header, header.headline || '', foto); } function renderAnschreibenPdf(letter, header, job, anlagen, signatur) { @@ -860,7 +894,7 @@ function hasLebenslauf(l) { || l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length); } -async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null }) { +async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null }) { const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen }); const headline = data.headline || job.stelle || ''; const header = buildHeader(settings, data.kontakt, headline); @@ -891,7 +925,7 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus name: `Lebenslauf - ${label}`.trim(), filename: `Lebenslauf_${suffix}.pdf`, mime: 'application/pdf', - buffer: renderLebenslaufPdf(data.lebenslauf, header), + buffer: renderLebenslaufPdf(data.lebenslauf, header, bewerbungsfoto), }); } diff --git a/server.js b/server.js index db413e4..c236b47 100644 --- a/server.js +++ b/server.js @@ -137,6 +137,48 @@ function loadSignatur() { } } +// Directory + uploader for the applicant's portrait photo (used in the CV) +const fotoDir = path.join(dataDir, 'bewerberfoto'); +if (!fs.existsSync(fotoDir)) { + fs.mkdirSync(fotoDir, { recursive: true }); +} +const uploadFoto = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => cb(null, fotoDir), + filename: (req, file, cb) => { + const ext = (path.extname(file.originalname) || '.png').toLowerCase(); + cb(null, `foto_${Date.now()}${ext}`); + }, + }), + limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB + fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)), +}).single('foto'); + +// The single stored photo file, if any. +function currentFotoFile() { + try { + const files = fs.readdirSync(fotoDir).filter((f) => !f.startsWith('.')); + return files.length ? path.join(fotoDir, files[0]) : null; + } catch (e) { + return null; + } +} + +// Read the photo as a data URL + jsPDF format, for embedding in the CV. +function loadFoto() { + const file = currentFotoFile(); + if (!file) return null; + try { + const ext = path.extname(file).toLowerCase(); + const format = (ext === '.jpg' || ext === '.jpeg') ? 'JPEG' : 'PNG'; + const mime = format === 'JPEG' ? 'image/jpeg' : 'image/png'; + const b64 = fs.readFileSync(file).toString('base64'); + return { dataUrl: `data:${mime};base64,${b64}`, format }; + } catch (e) { + return null; + } +} + // Database setup const dbPath = path.join(dataDir, 'bewerbungen.db'); const db = new sqlite3.Database(dbPath); @@ -228,6 +270,8 @@ async function runGeneration(bewerbungId) { llmNotizen: bewerbung.llm_notizen || '', // Signature image placed under the closing salutation (instead of the typed name). signatur: loadSignatur(), + // Applicant photo placed in the CV header (top-right), optional. + bewerbungsfoto: loadFoto(), }); let seq = 0; @@ -794,6 +838,7 @@ initializeDatabase().then(() => { basisDokumente, basisAnhaenge, hasSignatur: Boolean(currentSignaturFile()), + hasFoto: Boolean(currentFotoFile()), basisTypOptions: BASIS_TYP_OPTIONS, hasApiKey: Boolean(process.env.OLLAMA_API_KEY), hideSettings: true, @@ -941,6 +986,42 @@ initializeDatabase().then(() => { res.redirect('/vorlagen'); }); + // ----- Applicant photo (Bewerberfoto, used in the CV) ----- + + // Serve the current photo (for the preview on the Vorlagen page) + app.get('/bewerbungsfoto', (req, res) => { + const file = currentFotoFile(); + if (!file) return res.status(404).send('Kein Bewerberfoto'); + res.sendFile(file); + }); + + // Upload / replace the photo + app.post('/bewerbungsfoto', (req, res) => { + uploadFoto(req, res, (err) => { + try { + if (err) console.error('Photo upload error:', err.message); + if (req.file) { + // keep only the newly uploaded file + fs.readdirSync(fotoDir).forEach((f) => { + if (f !== req.file.filename) fs.promises.unlink(path.join(fotoDir, f)).catch(() => {}); + }); + } + res.redirect('/vorlagen'); + } catch (error) { + console.error('Error saving photo:', error); + res.status(500).send('Serverfehler'); + } + }); + }); + + // Delete the photo + app.post('/bewerbungsfoto/delete', (req, res) => { + try { + fs.readdirSync(fotoDir).forEach((f) => fs.promises.unlink(path.join(fotoDir, f)).catch(() => {})); + } catch (e) { /* ignore */ } + res.redirect('/vorlagen'); + }); + // Download a generated attachment app.get('/anhaenge/:id/download', async (req, res) => { try { diff --git a/views/vorlagen.ejs b/views/vorlagen.ejs index 54c6180..7e4ffa1 100644 --- a/views/vorlagen.ejs +++ b/views/vorlagen.ejs @@ -217,6 +217,40 @@ + + +

Bewerberfoto

+

+ Lade optional dein Bewerbungsfoto (PNG/JPG, Hochformat) hoch. Es wird oben rechts im + Lebenslauf platziert; ohne Foto bleibt der Lebenslauf textlich wie bisher. +

+ +
+ <% if (hasFoto) { %> +
+
+ Bewerberfoto +
+
+ +
+
+ <% } else { %> +

Noch kein Bewerberfoto hinterlegt.

+ <% } %> + +
+
+ + +
+ +
+
<%- include('partials/footer') %>