Optional applicant photo in CV header
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+49
-15
@@ -653,27 +653,61 @@ function cvEducation(doc, S, dry, cur, e) {
|
|||||||
cur.y += 2.6 * S;
|
cur.y += 2.6 * S;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header: name + position (top-left) and a right-aligned contact block
|
// Header: name + position (top-left) and a contact block, separated by a
|
||||||
// (top-right), separated by a confident rule — the canonical modern CV head.
|
// confident rule — the canonical modern CV head. With an applicant photo
|
||||||
function composeCvHeader(doc, S, dry, cur, header, titel) {
|
// (`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 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 });
|
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 (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 (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 leftBottom = cur.y;
|
||||||
|
|
||||||
const contactLines = [header.email, header.telefon, cityName(header) || header.ort, header.webseite].filter(Boolean);
|
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 };
|
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 }));
|
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);
|
rule(doc, S, dry, cur.y, RV.mx, RV_R, RC.ink, 0.6);
|
||||||
cur.y += 5 * S;
|
cur.y += 5 * S;
|
||||||
}
|
}
|
||||||
|
|
||||||
function composeCV(doc, S, dry, cur, { cv, header, titel }) {
|
function composeCV(doc, S, dry, cur, { cv, header, titel, foto }) {
|
||||||
composeCvHeader(doc, S, dry, cur, header, titel);
|
composeCvHeader(doc, S, dry, cur, header, titel, foto);
|
||||||
if (cv.profil) {
|
if (cv.profil) {
|
||||||
cvHeading(doc, S, dry, cur, '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 });
|
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 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 need = m.y - RV.top;
|
||||||
const avail = PAGE.h - RV.top - RV.bottom;
|
const avail = PAGE.h - RV.top - RV.bottom;
|
||||||
let S = 1;
|
let S = 1;
|
||||||
if (need > avail) S = Math.max(MIN_SCALE, (avail / need) * 0.99);
|
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'));
|
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.
|
// 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) {
|
function renderAnschreibenPdf(letter, header, job, anlagen, signatur) {
|
||||||
@@ -860,7 +894,7 @@ function hasLebenslauf(l) {
|
|||||||
|| l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length);
|
|| 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 data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen });
|
||||||
const headline = data.headline || job.stelle || '';
|
const headline = data.headline || job.stelle || '';
|
||||||
const header = buildHeader(settings, data.kontakt, headline);
|
const header = buildHeader(settings, data.kontakt, headline);
|
||||||
@@ -891,7 +925,7 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus
|
|||||||
name: `Lebenslauf - ${label}`.trim(),
|
name: `Lebenslauf - ${label}`.trim(),
|
||||||
filename: `Lebenslauf_${suffix}.pdf`,
|
filename: `Lebenslauf_${suffix}.pdf`,
|
||||||
mime: 'application/pdf',
|
mime: 'application/pdf',
|
||||||
buffer: renderLebenslaufPdf(data.lebenslauf, header),
|
buffer: renderLebenslaufPdf(data.lebenslauf, header, bewerbungsfoto),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Database setup
|
||||||
const dbPath = path.join(dataDir, 'bewerbungen.db');
|
const dbPath = path.join(dataDir, 'bewerbungen.db');
|
||||||
const db = new sqlite3.Database(dbPath);
|
const db = new sqlite3.Database(dbPath);
|
||||||
@@ -228,6 +270,8 @@ async function runGeneration(bewerbungId) {
|
|||||||
llmNotizen: bewerbung.llm_notizen || '',
|
llmNotizen: bewerbung.llm_notizen || '',
|
||||||
// Signature image placed under the closing salutation (instead of the typed name).
|
// Signature image placed under the closing salutation (instead of the typed name).
|
||||||
signatur: loadSignatur(),
|
signatur: loadSignatur(),
|
||||||
|
// Applicant photo placed in the CV header (top-right), optional.
|
||||||
|
bewerbungsfoto: loadFoto(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
@@ -794,6 +838,7 @@ initializeDatabase().then(() => {
|
|||||||
basisDokumente,
|
basisDokumente,
|
||||||
basisAnhaenge,
|
basisAnhaenge,
|
||||||
hasSignatur: Boolean(currentSignaturFile()),
|
hasSignatur: Boolean(currentSignaturFile()),
|
||||||
|
hasFoto: Boolean(currentFotoFile()),
|
||||||
basisTypOptions: BASIS_TYP_OPTIONS,
|
basisTypOptions: BASIS_TYP_OPTIONS,
|
||||||
hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
|
hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
|
||||||
hideSettings: true,
|
hideSettings: true,
|
||||||
@@ -941,6 +986,42 @@ initializeDatabase().then(() => {
|
|||||||
res.redirect('/vorlagen');
|
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
|
// Download a generated attachment
|
||||||
app.get('/anhaenge/:id/download', async (req, res) => {
|
app.get('/anhaenge/:id/download', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -217,6 +217,40 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Applicant photo (Bewerberfoto) -->
|
||||||
|
<h2 class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2">Bewerberfoto</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
Lade optional dein Bewerbungsfoto (PNG/JPG, Hochformat) hoch. Es wird oben rechts im
|
||||||
|
Lebenslauf platziert; ohne Foto bleibt der Lebenslauf textlich wie bisher.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<% if (hasFoto) { %>
|
||||||
|
<div class="flex items-center justify-between gap-4 mb-4">
|
||||||
|
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 p-3">
|
||||||
|
<img src="/bewerbungsfoto?t=<%= Date.now() %>" alt="Bewerberfoto" class="max-h-40 max-w-[120px] object-contain">
|
||||||
|
</div>
|
||||||
|
<form action="/bewerbungsfoto/delete" method="POST" onsubmit="return confirm('Bewerberfoto entfernen?');">
|
||||||
|
<button type="submit" class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 hover:underline">Entfernen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Noch kein Bewerberfoto hinterlegt.</p>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<form action="/bewerbungsfoto" method="POST" enctype="multipart/form-data"
|
||||||
|
class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-2 flex flex-wrap items-end gap-3">
|
||||||
|
<div class="flex-1 min-w-[220px]">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bewerberfoto (PNG/JPG, Hochformat)</label>
|
||||||
|
<input type="file" name="foto" accept="image/png,image/jpeg" required
|
||||||
|
class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
|
||||||
|
<%= hasFoto ? 'Ersetzen' : 'Hochladen' %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<%- include('partials/footer') %>
|
<%- include('partials/footer') %>
|
||||||
|
|||||||
Reference in New Issue
Block a user