Redesign Lebenslauf: monochrome single-column HR layout

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-03 12:55:34 +02:00
co-authored by Claude
parent 6cf4fb58b0
commit 7d46032378
+52 -36
View File
@@ -1,7 +1,8 @@
// Document generation.
//
// Architecture: the *design* lives here as a fixed, high-quality two-column
// template (dark sidebar + white main column). The LLM only supplies the TEXT
// Architecture: the *design* lives here as a fixed, high-quality template
// (clean monochrome single-column layout for both cover letter and résumé,
// reverse-chronological, DIN-ish margins). The LLM only supplies the TEXT
// content (tailored to the job) as structured data — it never touches layout.
// The text stays grounded in the user's own base documents; nothing is invented.
@@ -477,10 +478,10 @@ function rule(doc, S, dry, y, x1, x2, color, weight) {
}
// ===========================================================================
// Resume (Lebenslauf) — clean single-column layout
// Resume (Lebenslauf) — monochrome single column, reverse-chronological
// ===========================================================================
const RV = { mx: 20, top: 16, bottom: 14 };
const RV = { mx: 20, top: 18, 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
@@ -493,28 +494,31 @@ const RC = {
// Section heading: bold uppercase tracked label with a thin hairline beneath.
function cvHeading(doc, S, dry, cur, title) {
const pt = 11.5;
cur.y += 2.5 * S;
const pt = 11;
cur.y += 3 * S;
doc.setFont('helvetica', 'bold');
doc.setFontSize(pt * S);
if (!dry) {
doc.setCharSpace(0.4 * S);
doc.setCharSpace(0.6 * S);
doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]);
doc.text(String(title).toUpperCase(), RV.mx, cur.y + pt * S * PT * 0.76);
doc.setCharSpace(0);
doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]);
doc.setLineWidth(0.3 * S);
doc.line(RV.mx, cur.y + pt * S * PT * 0.98, RV_R, cur.y + pt * S * PT * 0.98);
doc.line(RV.mx, cur.y + pt * S * PT * 1.0, RV_R, cur.y + pt * S * PT * 1.0);
}
cur.y += pt * S * PT + 3.2 * S;
cur.y += pt * S * PT + 3.4 * S;
}
// One row: bold title (left) + muted date (right-aligned) on a single baseline.
function cvRow(doc, S, dry, cur, leftText, rightText, leftPt) {
// One row: title (left) + muted date (right-aligned) on a single baseline.
// `leftStyle` controls the title weight ('bold' for experience/education,
// 'normal' for the language list).
function cvRow(doc, S, dry, cur, leftText, rightText, leftPt, leftStyle) {
leftPt = leftPt || 11;
leftStyle = leftStyle || 'bold';
const rightPt = 9.5;
const leftW = RV_W - RV_DATE_W;
doc.setFont('helvetica', 'bold');
doc.setFont('helvetica', leftStyle);
doc.setFontSize(leftPt * S);
const lines = doc.splitTextToSize(String(leftText || ''), leftW);
const lineH = leftPt * S * PT * 1.18;
@@ -546,36 +550,51 @@ function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.3) {
}
cur.y += pt * S * PT * 1.3;
});
cur.y += 0.8 * S;
cur.y += 0.9 * S;
}
}
// Two-column bullet grid — used for the skills list so a dozen short items
// stay compact and scannable instead of one long vertical column.
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) {
cvRow(doc, S, dry, cur, e.titel, e.zeitraum, 11);
cvRow(doc, S, dry, cur, e.titel, e.zeitraum, 11, 'bold');
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 });
if (e.punkte && e.punkte.length) { cur.y += 0.8 * S; cvBullets(doc, S, dry, cur, e.punkte, RV.mx, RV_W); }
cur.y += 2.6 * S;
if (e.punkte && e.punkte.length) { cur.y += 1 * S; cvBullets(doc, S, dry, cur, e.punkte, RV.mx, RV_W); }
cur.y += 3 * S;
}
function cvEducation(doc, S, dry, cur, e) {
cvRow(doc, S, dry, cur, e.abschluss, e.zeitraum, 11);
cvRow(doc, S, dry, cur, e.abschluss, e.zeitraum, 11, 'bold');
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 });
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.4 * S;
cur.y += 2.6 * S;
}
// Header: name, position subtitle, single muted contact line, thin rule.
// 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) {
write(doc, S, dry, cur, header.name, { pt: 20, style: 'bold', color: RC.ink, x: RV.mx, width: RV_W, factor: 1.1 });
if (titel) write(doc, S, dry, cur, titel, { pt: 11, color: RC.sub, x: RV.mx, width: RV_W, factor: 1.2 });
const contact = [header.email, header.telefon, cityName(header) || header.ort, header.webseite]
.filter(Boolean).join(' · ');
if (contact) {
cur.y += 1.4 * S;
write(doc, S, dry, cur, contact, { pt: 9, color: RC.sub, x: RV.mx, width: RV_W, factor: 1.2 });
}
cur.y += 3 * S;
rule(doc, S, dry, cur.y, RV.mx, RV_R, RC.ink, 0.5);
const top = cur.y;
const leftW = 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 });
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 }));
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;
}
@@ -583,7 +602,7 @@ function composeCV(doc, S, dry, cur, { cv, header, titel }) {
composeCvHeader(doc, S, dry, cur, header, titel);
if (cv.profil) {
cvHeading(doc, S, dry, cur, 'Profil');
write(doc, S, dry, cur, cv.profil, { pt: 9.7, color: RC.ink, x: RV.mx, width: RV_W, factor: 1.42 });
write(doc, S, dry, cur, cv.profil, { pt: 10, color: RC.ink, x: RV.mx, width: RV_W, factor: 1.45 });
}
if (cv.berufserfahrung.length) {
cvHeading(doc, S, dry, cur, 'Berufserfahrung');
@@ -596,18 +615,15 @@ function composeCV(doc, S, dry, cur, { cv, header, titel }) {
});
if (cv.kenntnisse.length) {
cvHeading(doc, S, dry, cur, 'Fähigkeiten');
cvBullets(doc, S, dry, cur, cv.kenntnisse, RV.mx, RV_W);
cvTwoColBullets(doc, S, dry, cur, cv.kenntnisse, RV.mx, RV_W);
}
if (cv.sprachen.length) {
cvHeading(doc, S, dry, cur, 'Sprachen');
cv.sprachen.forEach((s) => {
const t = s.niveau ? `${s.sprache} ${s.niveau}` : s.sprache;
write(doc, S, dry, cur, t, { pt: 9.3, color: RC.ink, x: RV.mx, width: RV_W, factor: 1.3 });
});
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');
cvBullets(doc, S, dry, cur, cv.hobbys, RV.mx, RV_W);
write(doc, S, dry, cur, cv.hobbys.join(', '), { pt: 9.7, color: RC.ink, x: RV.mx, width: RV_W, factor: 1.3 });
}
}