CV: tuck Profil up and flow it around the photo

Drop the dead gap between the contact block and the first section: the
Profil heading and its opening lines now sit beside the photo's lower
flank (hairline stopping short of the photo) and spill to full width
once past the photo's bottom edge. No-photo layout unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 15:10:25 +02:00
co-authored by Claude Opus 4.8
parent 5090dbbeb6
commit 0bfa5683fb
+53 -10
View File
@@ -568,7 +568,7 @@ const RC = {
}; };
// Section heading: bold uppercase tracked label with a thin hairline beneath. // Section heading: bold uppercase tracked label with a thin hairline beneath.
function cvHeading(doc, S, dry, cur, title) { function cvHeading(doc, S, dry, cur, title, ruleRight) {
const pt = 11; const pt = 11;
cur.y += 3 * S; cur.y += 3 * S;
doc.setFont('Lato', 'bold'); doc.setFont('Lato', 'bold');
@@ -580,7 +580,7 @@ function cvHeading(doc, S, dry, cur, title) {
doc.setCharSpace(0); doc.setCharSpace(0);
doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]); doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]);
doc.setLineWidth(0.3 * S); doc.setLineWidth(0.3 * S);
doc.line(RV.mx, cur.y + pt * S * PT * 1.0, RV_R, cur.y + pt * S * PT * 1.0); doc.line(RV.mx, cur.y + pt * S * PT * 1.0, ruleRight || RV_R, cur.y + pt * S * PT * 1.0);
} }
cur.y += pt * S * PT + 3.4 * S; cur.y += pt * S * PT + 3.4 * S;
} }
@@ -679,7 +679,8 @@ function composeCvHeader(doc, S, dry, cur, header, titel, foto) {
} catch (e) { photoOk = false; } } catch (e) { photoOk = false; }
} }
const leftW = photoOk ? (RV_W - PHOTO_W * S - 6 * S) : RV_W * 0.62; const gapPhoto = 6 * S;
const leftW = photoOk ? (RV_W - photoW - gapPhoto) : 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 });
@@ -689,31 +690,73 @@ function composeCvHeader(doc, S, dry, cur, header, titel, foto) {
} }
const leftBottom = cur.y; const leftBottom = cur.y;
let photoBottom = leftBottom, photoLeft = RV_R;
if (photoOk) { if (photoOk) {
const px = RV_R - photoW; const px = RV_R - photoW;
const py = top; const py = top;
photoLeft = px;
photoBottom = py + photoH;
if (!dry) { if (!dry) {
doc.addImage(foto.dataUrl, foto.format || 'PNG', px, py, photoW, photoH); doc.addImage(foto.dataUrl, foto.format || 'PNG', px, py, photoW, photoH);
doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]); doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]);
doc.setLineWidth(0.3 * S); doc.setLineWidth(0.3 * S);
doc.rect(px, py, photoW, photoH); doc.rect(px, py, photoW, photoH);
} }
cur.y = Math.max(leftBottom, py + photoH) + 3.2 * S; // Tuck the first section up right under the contact block; the photo's
// lower flank is filled by the Profil (handled in cvProfil), not by a gap.
cur.y = leftBottom + 1.5 * S;
} else { } 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;
}
// No separator rule above the first section (Profil) — cleaner modern head.
cur.y += 2.5 * S; cur.y += 2.5 * S;
} }
return { photoOk, photoBottom, photoLeft, gapPhoto };
}
// Profil section that flows around an optional photo: the heading and first
// lines tuck into the empty space beside the photo's lower flank (with the
// hairline stopping short of the photo), then the text spills to full width
// once it clears the photo's bottom edge.
function cvProfil(doc, S, dry, cur, text, geo) {
const beside = geo.photoOk && (cur.y + 3 * S) < geo.photoBottom;
const ruleRight = beside ? (geo.photoLeft - geo.gapPhoto) : RV_R;
cvHeading(doc, S, dry, cur, 'Profil', ruleRight);
const pt = 10, factor = 1.45;
const lineH = pt * S * PT * factor;
const narrowW = geo.photoOk ? (geo.photoLeft - geo.gapPhoto - RV.mx) : RV_W;
doc.setFont('Lato', 'normal');
doc.setFontSize(pt * S);
const drawLine = (ln) => {
if (!dry) { doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]); doc.text(ln, RV.mx, cur.y + pt * S * PT * 0.76); }
cur.y += lineH;
};
let remaining = String(text);
if (geo.photoOk && cur.y < geo.photoBottom) {
const nBeside = Math.floor((geo.photoBottom - cur.y) / lineH);
if (nBeside > 0) {
const narrow = doc.splitTextToSize(remaining, narrowW);
const take = Math.min(nBeside, narrow.length);
for (let i = 0; i < take; i++) drawLine(narrow[i]);
remaining = narrow.slice(take).join(' ');
}
}
if (remaining) doc.splitTextToSize(remaining, RV_W).forEach(drawLine);
// Ensure the next section starts below the photo.
cur.y = Math.max(cur.y, geo.photoBottom);
}
function composeCV(doc, S, dry, cur, { cv, header, titel, foto }) { function composeCV(doc, S, dry, cur, { cv, header, titel, foto }) {
composeCvHeader(doc, S, dry, cur, header, titel, foto); const geo = composeCvHeader(doc, S, dry, cur, header, titel, foto);
if (cv.profil) { if (cv.profil) {
cvHeading(doc, S, dry, cur, 'Profil'); cvProfil(doc, S, dry, cur, cv.profil, geo);
write(doc, S, dry, cur, cv.profil, { pt: 10, color: RC.ink, x: RV.mx, width: RV_W, factor: 1.45 }); } else {
cur.y = Math.max(cur.y, geo.photoBottom);
} }
if (cv.berufserfahrung.length) { if (cv.berufserfahrung.length) {
cvHeading(doc, S, dry, cur, 'Berufserfahrung'); cvHeading(doc, S, dry, cur, 'Berufserfahrung');