Redesign the Lebenslauf as a monochrome two-column layout

Replace the single-column résumé with a two-column layout: a tinted
grey sidebar (photo, contact, Kernkompetenzen, Sprachen, Interessen)
and a wide main column with a large name header, Profil, a timeline
Berufserfahrung, and education. Strictly black-and-white — the former
navy accent becomes near-black across both the résumé and the cover
letter so the set stays consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 12:12:48 +02:00
co-authored by Claude Opus 4.8
parent 35b60bc2ca
commit 390f9a643b
+279 -198
View File
@@ -613,83 +613,208 @@ function rule(doc, S, dry, y, x1, x2, color, weight) {
} }
// =========================================================================== // ===========================================================================
// Resume (Lebenslauf) — monochrome single column, reverse-chronological // Resume (Lebenslauf) — modern two-column layout
//
// A tinted left sidebar (photo, contact, competencies, languages, interests)
// and a wide main column (large name header, profile, timeline experience,
// education). This is the canonical premium German Lebenslauf: the recruiter
// reads the whole person in the first glance — face, role, core skills — and
// the career story reads top-to-bottom on a single, deliberate axis.
// =========================================================================== // ===========================================================================
const RV = { mx: 20, top: 18, bottom: 14 }; const RV = { top: 20, bottom: 14 };
const RV_W = PAGE.w - RV.mx * 2; // 170 content width
const RV_R = PAGE.w - RV.mx; // 190 right edge (right-aligned dates)
const RV_DATE_W = 34; // reserved right zone for the date column
// --- Horizontal grid (fixed; only vertical rhythm scales to fit one page) ---
const SB_W = 68; // sidebar width
const SB_X = 10; // sidebar text left
const SB_R = SB_W - 10; // 58 sidebar text right
const SB_CW = SB_R - SB_X; // 48 sidebar content width
const MAIN_X = SB_W + 12; // 80 main column left (section labels)
const MAIN_R = PAGE.w - 15; // 195 main column right (right-aligned dates)
const BODY_X = MAIN_X + 8; // 88 body text left (hanging under labels)
const BODY_W = MAIN_R - BODY_X; // 107
const MAIN_W = MAIN_R - MAIN_X; // 115 full main width (name / heading rules)
// Strictly monochrome — no colour anywhere. Hierarchy comes from weight, size,
// tracking and a single neutral grey scale. The near-black "accent" carries the
// name, section labels, company names, markers and the photo frame; body text
// sits just below it; everything structural is grey.
const RC = { const RC = {
ink: [26, 26, 26], ink: [33, 33, 33],
sub: [92, 98, 106], sub: [92, 96, 100],
hair: [206, 208, 212], hair: [208, 210, 214],
// One deep, professional navy-slate accent — used sparingly for structure accent: [23, 23, 23],
// (name, section labels, the short header rule, bullet markers). It signals // Neutral tint for the sidebar band and the experience timeline track.
// care and confidence without ever reading as flashy; body text stays near sidebarBg: [242, 243, 245],
// black so the résumé remains crisp and ATS-safe. track: [199, 202, 208],
accent: [31, 64, 104], onSide: [38, 40, 44],
onSideSub: [96, 100, 106],
}; };
// Section heading: an accent-coloured, tracked uppercase label over a full-width // ---------------------------------------------------------------------------
// hairline, with a short heavier accent segment at the start — a small, precise // Sidebar
// motif that gives every section a confident, consistent anchor. // ---------------------------------------------------------------------------
function cvHeading(doc, S, dry, cur, title, ruleRight) {
const pt = 10.5; // Fit an applicant photo into a portrait frame, preserving aspect ratio. The
const cs = 0.9 * S; // frame size is fixed (does not scale with the page-fit S) so the sidebar
cur.y += 3.6 * S; // keeps a stable, confident anchor regardless of how much text is below it.
function fitPhoto(doc, foto) {
const MAXW = 42, MAXH = 52;
if (!foto || !foto.dataUrl) return { ok: false, w: 0, h: 0 };
try {
const p = doc.getImageProperties(foto.dataUrl);
if (!(p.width > 0 && p.height > 0)) return { ok: false, w: 0, h: 0 };
let w = MAXW, h = w * p.height / p.width;
if (h > MAXH) { h = MAXH; w = h * p.width / p.height; }
return { ok: true, w, h };
} catch (e) { return { ok: false, w: 0, h: 0 }; }
}
function buildContactLines(header) {
const lines = [];
if (header.email) lines.push(header.email);
if (header.telefon) lines.push(header.telefon);
const ort = cityName(header) || header.ort;
if (ort) lines.push(ort);
if (header.webseite) lines.push(header.webseite);
if (header.geburtsdatum) lines.push(`Geb. ${header.geburtsdatum}`);
if (header.fuehrerschein) lines.push(`Führerschein ${header.fuehrerschein}`);
return lines;
}
// Sidebar section label: a compact tracked uppercase accent word underlined by
// a short accent segment — the same structural motif as the main column, sized
// down for the narrow column.
function sbHeading(doc, S, dry, cur, title) {
const pt = 9;
const cs = 1.0 * S;
cur.y += 5 * S;
doc.setFont('Lato', 'bold'); doc.setFont('Lato', 'bold');
doc.setFontSize(pt * S); doc.setFontSize(pt * S);
const label = String(title).toUpperCase(); const label = String(title).toUpperCase();
const lineY = cur.y + pt * S * PT * 1.08; const lineY = cur.y + pt * S * PT * 1.24;
if (!dry) { if (!dry) {
doc.setCharSpace(cs); doc.setCharSpace(cs);
doc.setTextColor(RC.accent[0], RC.accent[1], RC.accent[2]); doc.setTextColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.text(label, RV.mx, cur.y + pt * S * PT * 0.76); doc.text(label, SB_X, cur.y + pt * S * PT * 0.76);
doc.setCharSpace(0); doc.setCharSpace(0);
const right = ruleRight || RV_R;
// Thick accent segment runs exactly as wide as the heading word (charSpace
// adds one gap after every glyph), the thin hairline continues to the edge.
const wordW = doc.getTextWidth(label) + cs * label.length;
doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]);
doc.setLineWidth(0.3 * S);
doc.line(RV.mx, lineY, right, lineY);
doc.setDrawColor(RC.accent[0], RC.accent[1], RC.accent[2]); doc.setDrawColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.setLineWidth(1.0 * S); doc.setLineWidth(0.9 * S);
doc.line(RV.mx, lineY, Math.min(RV.mx + wordW, right), lineY); doc.line(SB_X, lineY, SB_X + 9, lineY);
} }
cur.y += pt * S * PT + 4.0 * S; cur.y += pt * S * PT + 4.6 * S;
} }
// One row: title (left) + muted date (right-aligned) on a single baseline. function sbBullets(doc, S, dry, cur, items, pt = 8.7) {
// `leftStyle` controls the title weight ('bold' for experience/education, const tx = SB_X + 3.4;
// 'normal' for the language list). const tw = SB_CW - 3.4;
function cvRow(doc, S, dry, cur, leftText, rightText, leftPt, leftStyle) { for (const it of items) {
leftPt = leftPt || 11; doc.setFont('Lato', 'normal');
leftStyle = leftStyle || 'bold'; doc.setFontSize(pt * S);
const rightPt = 9.5; const lines = doc.splitTextToSize(String(it), tw);
const leftW = RV_W - RV_DATE_W; lines.forEach((ln, i) => {
doc.setFont('Lato', leftStyle);
doc.setFontSize(leftPt * S);
const lines = doc.splitTextToSize(String(leftText || ''), leftW);
const lineH = leftPt * S * PT * 1.18;
if (!dry) { if (!dry) {
doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]); if (i === 0) {
lines.forEach((ln, i) => doc.text(ln, RV.mx, cur.y + i * lineH + leftPt * S * PT * 0.76)); const sz = 1.1 * S;
if (rightText) { doc.setFillColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.rect(SB_X + 0.2, cur.y + pt * S * PT * 0.42 - sz / 2, sz, sz, 'F');
}
doc.setTextColor(RC.onSide[0], RC.onSide[1], RC.onSide[2]);
doc.text(ln, tx, cur.y + pt * S * PT * 0.76);
}
cur.y += pt * S * PT * 1.32;
});
cur.y += 1.1 * S;
}
}
// Language row: name (left) + level (right-aligned in the sidebar column).
function sbLangRow(doc, S, dry, cur, s) {
const pt = 8.7;
doc.setFont('Lato', 'normal'); doc.setFont('Lato', 'normal');
doc.setFontSize(rightPt * S); doc.setFontSize(pt * S);
doc.setTextColor(RC.sub[0], RC.sub[1], RC.sub[2]); if (!dry) {
doc.text(String(rightText), RV_R, cur.y + rightPt * S * PT * 0.76, { align: 'right' }); doc.setTextColor(RC.onSide[0], RC.onSide[1], RC.onSide[2]);
doc.text(String(s.sprache), SB_X, cur.y + pt * S * PT * 0.76);
if (s.niveau) {
doc.setTextColor(RC.onSideSub[0], RC.onSideSub[1], RC.onSideSub[2]);
doc.text(String(s.niveau), SB_R, cur.y + pt * S * PT * 0.76, { align: 'right' });
}
}
cur.y += pt * S * PT * 1.62;
} }
function composeSidebar(doc, S, dry, { cv, header, foto }) {
const cur = { y: RV.top };
const photo = fitPhoto(doc, foto);
if (photo.ok) {
const px = (SB_W - photo.w) / 2;
const py = RV.top - 2;
if (!dry) {
doc.addImage(foto.dataUrl, foto.format || 'PNG', px, py, photo.w, photo.h);
doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]);
doc.setLineWidth(0.3 * S);
doc.rect(px, py, photo.w, photo.h);
}
cur.y = py + photo.h + 7 * S;
}
const contact = buildContactLines(header);
if (contact.length) {
sbHeading(doc, S, dry, cur, 'Kontakt');
contact.forEach((line) => write(doc, S, dry, cur, line, { pt: 8.6, color: RC.onSideSub, x: SB_X, width: SB_CW, factor: 1.5 }));
}
if (cv.kenntnisse.length) {
sbHeading(doc, S, dry, cur, 'Kernkompetenzen');
sbBullets(doc, S, dry, cur, cv.kenntnisse);
}
if (cv.sprachen.length) {
sbHeading(doc, S, dry, cur, 'Sprachen');
cv.sprachen.forEach((s) => sbLangRow(doc, S, dry, cur, s));
}
if (cv.hobbys.length) {
sbHeading(doc, S, dry, cur, 'Interessen');
write(doc, S, dry, cur, cv.hobbys.join(' · '), { pt: 8.6, color: RC.onSide, x: SB_X, width: SB_CW, factor: 1.55 });
}
return cur.y;
}
// ---------------------------------------------------------------------------
// Main column
// ---------------------------------------------------------------------------
// Main section heading: tracked uppercase accent label, outdented over a
// full-width hairline whose first stretch is a heavier accent segment as wide
// as the label word — a precise, repeating anchor down the page.
function mHeading(doc, S, dry, cur, title) {
const pt = 11;
const cs = 0.9 * S;
cur.y += 5.5 * S;
doc.setFont('Lato', 'bold');
doc.setFontSize(pt * S);
const label = String(title).toUpperCase();
const lineY = cur.y + pt * S * PT * 1.1;
if (!dry) {
doc.setCharSpace(cs);
doc.setTextColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.text(label, MAIN_X, cur.y + pt * S * PT * 0.76);
doc.setCharSpace(0);
const wordW = doc.getTextWidth(label) + cs * label.length;
doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]);
doc.setLineWidth(0.3 * S);
doc.line(MAIN_X, lineY, MAIN_R, lineY);
doc.setDrawColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.setLineWidth(1.1 * S);
doc.line(MAIN_X, lineY, Math.min(MAIN_X + wordW, MAIN_R), lineY);
} }
cur.y += lineH * Math.max(1, lines.length); cur.y += pt * S * PT + 4.6 * S;
} }
function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.3) { function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.2) {
const tx = x + 3.6 * S; const tx = x + 3.8;
const tw = w - 3.6 * S; const tw = w - 3.8;
for (const it of items) { for (const it of items) {
doc.setFont('Lato', 'normal'); doc.setFont('Lato', 'normal');
doc.setFontSize(pt * S); doc.setFontSize(pt * S);
@@ -697,182 +822,138 @@ function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.3) {
lines.forEach((ln, i) => { lines.forEach((ln, i) => {
if (!dry) { if (!dry) {
if (i === 0) { if (i === 0) {
// Small accent square marker (echoes the section-label motif).
const sz = 1.15 * S; const sz = 1.15 * S;
doc.setFillColor(RC.accent[0], RC.accent[1], RC.accent[2]); doc.setFillColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.rect(x + 0.2 * S, cur.y + pt * S * PT * 0.42 - sz / 2, sz, sz, 'F'); doc.rect(x + 0.2, cur.y + pt * S * PT * 0.42 - sz / 2, sz, sz, 'F');
} }
doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]); doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]);
doc.text(ln, tx, cur.y + pt * S * PT * 0.76); doc.text(ln, tx, cur.y + pt * S * PT * 0.76);
} }
cur.y += pt * S * PT * 1.3; cur.y += pt * S * PT * 1.32;
}); });
cur.y += 0.9 * S; cur.y += 1.0 * S;
} }
} }
// Two-column bullet grid — used for the skills list so a dozen short items // One experience entry: an accent node on the timeline, bold role title with a
// stay compact and scannable instead of one long vertical column. // right-aligned period, the company in accent, then the achievement bullets.
function cvTwoColBullets(doc, S, dry, cur, items, x, w, pt = 9.3) {
const gap = 8 * S;
const colW = (w - gap) / 2;
const half = Math.ceil(items.length / 2);
const lc = { y: cur.y }, rc = { y: cur.y };
cvBullets(doc, S, dry, lc, items.slice(0, half), x, colW, pt);
cvBullets(doc, S, dry, rc, items.slice(half), x + colW + gap, colW, pt);
cur.y = Math.max(lc.y, rc.y);
}
function cvExperience(doc, S, dry, cur, e) { function cvExperience(doc, S, dry, cur, e) {
cvRow(doc, S, dry, cur, e.titel, e.zeitraum, 11, 'bold'); const cx = MAIN_X + 1.4; // node / track centre x
if (e.firma) write(doc, S, dry, cur, e.firma, { pt: 9.7, color: RC.sub, x: RV.mx, width: RV_W, factor: 1.18 }); const titlePt = 11, datePt = 9.3;
if (e.punkte && e.punkte.length) { cur.y += 1 * S; cvBullets(doc, S, dry, cur, e.punkte, RV.mx, RV_W); } const dateW = 30;
cur.y += 3 * S; doc.setFont('Lato', 'bold');
doc.setFontSize(titlePt * S);
const titleLines = doc.splitTextToSize(String(e.titel || ''), BODY_W - dateW);
const titleLineH = titlePt * S * PT * 1.16;
if (!dry) {
doc.setFillColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.circle(cx, cur.y + titlePt * S * PT * 0.44, 1.5 * S, 'F');
doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]);
titleLines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * titleLineH + titlePt * S * PT * 0.76));
if (e.zeitraum) {
doc.setFont('Lato', 'normal');
doc.setFontSize(datePt * S);
doc.setTextColor(RC.sub[0], RC.sub[1], RC.sub[2]);
doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' });
}
}
cur.y += titleLineH * Math.max(1, titleLines.length);
if (e.firma) write(doc, S, dry, cur, e.firma, { pt: 9.6, color: RC.accent, x: BODY_X, width: BODY_W, factor: 1.24 });
if (e.punkte && e.punkte.length) { cur.y += 1.4 * S; cvBullets(doc, S, dry, cur, e.punkte, BODY_X, BODY_W); }
cur.y += 5 * S;
}
// The full Berufserfahrung section, threaded on a subtle vertical timeline.
// The track is measured with a dry pass so it can be drawn behind the nodes in
// one clean stroke, then the entries render on top.
function composeTimeline(doc, S, dry, cur, entries) {
const startY = cur.y;
if (!dry) {
const probe = { y: cur.y };
entries.forEach((e) => cvExperience(doc, S, true, probe, e));
doc.setDrawColor(RC.track[0], RC.track[1], RC.track[2]);
doc.setLineWidth(0.5 * S);
doc.line(MAIN_X + 1.4, startY + 2 * S, MAIN_X + 1.4, probe.y - 5 * S);
}
entries.forEach((e) => cvExperience(doc, S, dry, cur, e));
} }
function cvEducation(doc, S, dry, cur, e) { function cvEducation(doc, S, dry, cur, e) {
cvRow(doc, S, dry, cur, e.abschluss, e.zeitraum, 11, 'bold'); const pt = 10.5, datePt = 9.3, dateW = 30;
if (e.institution) write(doc, S, dry, cur, e.institution, { pt: 9.7, color: RC.sub, x: RV.mx, width: RV_W, factor: 1.18 }); doc.setFont('Lato', 'bold');
if (e.zusatz) write(doc, S, dry, cur, e.zusatz, { pt: 9.5, color: RC.sub, x: RV.mx, width: RV_W, factor: 1.2 });
cur.y += 2.6 * S;
}
// 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 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.
const PHOTO_W = 36, PHOTO_H = 40; // mm (frame cap; 20% larger than 30x40)
let photoOk = false, photoW = 0, photoH = 0;
if (foto && foto.dataUrl) {
try {
const props = doc.getImageProperties(foto.dataUrl);
const maxW = PHOTO_W * S, maxH = PHOTO_H * 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 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.accent, x: RV.mx, width: leftW, factor: 1.08 });
if (titel) write(doc, S, dry, cur, titel, { pt: 11.5, color: RC.sub, x: RV.mx, width: leftW, factor: 1.32, charSpace: 0.2 });
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;
let photoBottom = leftBottom, photoLeft = RV_R;
if (photoOk) {
const px = RV_R - photoW;
const py = top;
photoLeft = px;
photoBottom = py + photoH;
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);
}
// 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 {
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 += 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); doc.setFontSize(pt * S);
const lines = doc.splitTextToSize(String(e.abschluss || ''), BODY_W - dateW);
const drawLine = (ln) => { const lineH = pt * S * PT * 1.16;
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); } if (!dry) {
cur.y += lineH; doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]);
}; lines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * lineH + pt * S * PT * 0.76));
if (e.zeitraum) {
let remaining = String(text); doc.setFont('Lato', 'normal');
if (geo.photoOk && cur.y < geo.photoBottom) { doc.setFontSize(datePt * S);
const nBeside = Math.floor((geo.photoBottom - cur.y) / lineH); doc.setTextColor(RC.sub[0], RC.sub[1], RC.sub[2]);
if (nBeside > 0) { doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' });
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); cur.y += lineH * Math.max(1, lines.length);
if (e.institution) write(doc, S, dry, cur, e.institution, { pt: 9.5, color: RC.accent, x: BODY_X, width: BODY_W, factor: 1.2 });
// Ensure the next section starts below the photo. if (e.zusatz) write(doc, S, dry, cur, e.zusatz, { pt: 9.3, color: RC.sub, x: BODY_X, width: BODY_W, factor: 1.24 });
cur.y = Math.max(cur.y, geo.photoBottom); cur.y += 4 * S;
} }
function composeCV(doc, S, dry, cur, { cv, header, titel, foto }) { // Big name + target role, closed by a short accent rule — the confident head
const geo = composeCvHeader(doc, S, dry, cur, header, titel, foto); // the recruiter's eye lands on first.
function composeMainHeader(doc, S, dry, cur, header, titel) {
write(doc, S, dry, cur, header.name, { pt: 25, style: 'bold', color: RC.accent, x: MAIN_X, width: MAIN_W, factor: 1.06 });
if (titel) write(doc, S, dry, cur, titel, { pt: 12, color: RC.sub, x: MAIN_X, width: MAIN_W, factor: 1.36, charSpace: 0.3 });
cur.y += 2.6 * S;
if (!dry) {
doc.setDrawColor(RC.accent[0], RC.accent[1], RC.accent[2]);
doc.setLineWidth(1.2 * S);
doc.line(MAIN_X, cur.y, MAIN_X + 20, cur.y);
}
cur.y += 2.2 * S;
}
function composeMain(doc, S, dry, { cv, header, titel }) {
const cur = { y: RV.top };
composeMainHeader(doc, S, dry, cur, header, titel);
if (cv.profil) { if (cv.profil) {
cvProfil(doc, S, dry, cur, cv.profil, geo); mHeading(doc, S, dry, cur, 'Profil');
} else { write(doc, S, dry, cur, cv.profil, { pt: 9.8, color: RC.ink, x: BODY_X, width: BODY_W, factor: 1.5 });
cur.y = Math.max(cur.y, geo.photoBottom);
}
// Kernkompetenzen right after the Profil: the recruiter sees the role-match
// in the first seconds, before diving into the experience below.
if (cv.kenntnisse.length) {
cvHeading(doc, S, dry, cur, 'Kernkompetenzen');
cvTwoColBullets(doc, S, dry, cur, cv.kenntnisse, RV.mx, RV_W);
} }
if (cv.berufserfahrung.length) { if (cv.berufserfahrung.length) {
cvHeading(doc, S, dry, cur, 'Berufserfahrung'); mHeading(doc, S, dry, cur, 'Berufserfahrung');
cv.berufserfahrung.forEach((e) => cvExperience(doc, S, dry, cur, e)); composeTimeline(doc, S, dry, cur, cv.berufserfahrung);
} }
[['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Weiterbildungen', cv.weiterbildungen]].forEach(([t, entries]) => { [['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Weiterbildungen', cv.weiterbildungen]].forEach(([t, entries]) => {
if (!entries || !entries.length) return; if (!entries || !entries.length) return;
cvHeading(doc, S, dry, cur, t); mHeading(doc, S, dry, cur, t);
entries.forEach((e) => cvEducation(doc, S, dry, cur, e)); entries.forEach((e) => cvEducation(doc, S, dry, cur, e));
}); });
if (cv.sprachen.length) { return cur.y;
cvHeading(doc, S, dry, cur, 'Sprachen');
cv.sprachen.forEach((s) => cvRow(doc, S, dry, cur, s.sprache, s.niveau, 9.7, 'normal'));
} }
if (cv.hobbys.length) {
cvHeading(doc, S, dry, cur, 'Interessen'); // Draw the sidebar band first, then the main column, then the sidebar text —
write(doc, S, dry, cur, cv.hobbys.join(', '), { pt: 9.7, color: RC.ink, x: RV.mx, width: RV_W, factor: 1.3 }); // so the tint sits underneath, and the PDF text stream leads with the name and
// career story (better for résumé parsers) while positions stay absolute.
function composeCV(doc, S, dry, ctx) {
if (!dry) {
doc.setFillColor(RC.sidebarBg[0], RC.sidebarBg[1], RC.sidebarBg[2]);
doc.rect(0, 0, SB_W, PAGE.h, 'F');
} }
const mainBottom = composeMain(doc, S, dry, ctx);
const sideBottom = composeSidebar(doc, S, dry, ctx);
return Math.max(mainBottom, sideBottom);
} }
function renderCV(cv, header, titel, foto) { 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, foto }); const ctx = { cv, header, titel, foto };
const need = m.y - RV.top; const need = composeCV(doc, 1, true, ctx) - 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, foto }); composeCV(doc, S, false, ctx);
return Buffer.from(doc.output('arraybuffer')); return Buffer.from(doc.output('arraybuffer'));
} }
@@ -884,13 +965,13 @@ const LET = { mx: 25, mr: 20, top: 24, bottom: 18 }; // DIN-ish margins
const LET_R = PAGE.w - LET.mr; // 190 const LET_R = PAGE.w - LET.mr; // 190
const LET_W = LET_R - LET.mx; // 165 const LET_W = LET_R - LET.mx; // 165
// Grayscale body with the same deep navy-slate accent as the résumé, so the // Strictly monochrome, matching the résumé, so the cover letter and CV read as
// cover letter and CV read as one deliberately designed application set. // one deliberately designed black-and-white application set.
const LC = { const LC = {
ink: [26, 26, 26], ink: [26, 26, 26],
muted: [92, 98, 106], muted: [92, 96, 100],
hair: [206, 208, 212], hair: [206, 208, 212],
accent: [31, 64, 104], accent: [23, 23, 23],
}; };
// Extract just the town from a possibly-full address ("Feldstraße 76, 45968 // Extract just the town from a possibly-full address ("Feldstraße 76, 45968