Optional applicant photo in CV header

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-03 14:39:13 +02:00
co-authored by Claude
parent e914dee855
commit 990a663cdd
3 changed files with 166 additions and 17 deletions
+51 -17
View File
@@ -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),
});
}