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:
+279
-198
@@ -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) {
|
function cvExperience(doc, S, dry, cur, e) {
|
||||||
const gap = 8 * S;
|
const cx = MAIN_X + 1.4; // node / track centre x
|
||||||
const colW = (w - gap) / 2;
|
const titlePt = 11, datePt = 9.3;
|
||||||
const half = Math.ceil(items.length / 2);
|
const dateW = 30;
|
||||||
const lc = { y: cur.y }, rc = { y: cur.y };
|
doc.setFont('Lato', 'bold');
|
||||||
cvBullets(doc, S, dry, lc, items.slice(0, half), x, colW, pt);
|
doc.setFontSize(titlePt * S);
|
||||||
cvBullets(doc, S, dry, rc, items.slice(half), x + colW + gap, colW, pt);
|
const titleLines = doc.splitTextToSize(String(e.titel || ''), BODY_W - dateW);
|
||||||
cur.y = Math.max(lc.y, rc.y);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cvExperience(doc, S, dry, cur, e) {
|
// The full Berufserfahrung section, threaded on a subtle vertical timeline.
|
||||||
cvRow(doc, S, dry, cur, e.titel, e.zeitraum, 11, 'bold');
|
// The track is measured with a dry pass so it can be drawn behind the nodes in
|
||||||
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 });
|
// one clean stroke, then the entries render on top.
|
||||||
if (e.punkte && e.punkte.length) { cur.y += 1 * S; cvBullets(doc, S, dry, cur, e.punkte, RV.mx, RV_W); }
|
function composeTimeline(doc, S, dry, cur, entries) {
|
||||||
cur.y += 3 * S;
|
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.
|
||||||
if (cv.profil) {
|
function composeMainHeader(doc, S, dry, cur, header, titel) {
|
||||||
cvProfil(doc, S, dry, cur, cv.profil, geo);
|
write(doc, S, dry, cur, header.name, { pt: 25, style: 'bold', color: RC.accent, x: MAIN_X, width: MAIN_W, factor: 1.06 });
|
||||||
} else {
|
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 = Math.max(cur.y, geo.photoBottom);
|
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);
|
||||||
}
|
}
|
||||||
// Kernkompetenzen right after the Profil: the recruiter sees the role-match
|
cur.y += 2.2 * S;
|
||||||
// in the first seconds, before diving into the experience below.
|
}
|
||||||
if (cv.kenntnisse.length) {
|
|
||||||
cvHeading(doc, S, dry, cur, 'Kernkompetenzen');
|
function composeMain(doc, S, dry, { cv, header, titel }) {
|
||||||
cvTwoColBullets(doc, S, dry, cur, cv.kenntnisse, RV.mx, RV_W);
|
const cur = { y: RV.top };
|
||||||
|
composeMainHeader(doc, S, dry, cur, header, titel);
|
||||||
|
if (cv.profil) {
|
||||||
|
mHeading(doc, S, dry, cur, 'Profil');
|
||||||
|
write(doc, S, dry, cur, cv.profil, { pt: 9.8, color: RC.ink, x: BODY_X, width: BODY_W, factor: 1.5 });
|
||||||
}
|
}
|
||||||
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'));
|
|
||||||
}
|
// Draw the sidebar band first, then the main column, then the sidebar text —
|
||||||
if (cv.hobbys.length) {
|
// so the tint sits underneath, and the PDF text stream leads with the name and
|
||||||
cvHeading(doc, S, dry, cur, 'Interessen');
|
// career story (better for résumé parsers) while positions stay absolute.
|
||||||
write(doc, S, dry, cur, cv.hobbys.join(', '), { pt: 9.7, color: RC.ink, x: RV.mx, width: RV_W, factor: 1.3 });
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user