From 390f9a643be7e36c22abb94601f9bfda67038024 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Sat, 4 Jul 2026 12:12:48 +0200 Subject: [PATCH] Redesign the Lebenslauf as a monochrome two-column layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/documents.js | 479 +++++++++++++++++++++++++++-------------------- 1 file changed, 280 insertions(+), 199 deletions(-) diff --git a/lib/documents.js b/lib/documents.js index 032235f..95335f1 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -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_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 +const RV = { top: 20, bottom: 14 }; +// --- 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 = { - ink: [26, 26, 26], - sub: [92, 98, 106], - hair: [206, 208, 212], - // One deep, professional navy-slate accent — used sparingly for structure - // (name, section labels, the short header rule, bullet markers). It signals - // care and confidence without ever reading as flashy; body text stays near - // black so the résumé remains crisp and ATS-safe. - accent: [31, 64, 104], + ink: [33, 33, 33], + sub: [92, 96, 100], + hair: [208, 210, 214], + accent: [23, 23, 23], + // Neutral tint for the sidebar band and the experience timeline track. + sidebarBg: [242, 243, 245], + track: [199, 202, 208], + 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 -// motif that gives every section a confident, consistent anchor. -function cvHeading(doc, S, dry, cur, title, ruleRight) { - const pt = 10.5; - const cs = 0.9 * S; - cur.y += 3.6 * S; +// --------------------------------------------------------------------------- +// Sidebar +// --------------------------------------------------------------------------- + +// Fit an applicant photo into a portrait frame, preserving aspect ratio. The +// frame size is fixed (does not scale with the page-fit S) so the sidebar +// 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.setFontSize(pt * S); 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) { doc.setCharSpace(cs); 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); - 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.setLineWidth(1.0 * S); - doc.line(RV.mx, lineY, Math.min(RV.mx + wordW, right), lineY); + doc.setLineWidth(0.9 * S); + 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. -// `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('Lato', leftStyle); - doc.setFontSize(leftPt * S); - const lines = doc.splitTextToSize(String(leftText || ''), leftW); - const lineH = leftPt * S * PT * 1.18; +function sbBullets(doc, S, dry, cur, items, pt = 8.7) { + const tx = SB_X + 3.4; + const tw = SB_CW - 3.4; + for (const it of items) { + doc.setFont('Lato', 'normal'); + doc.setFontSize(pt * S); + const lines = doc.splitTextToSize(String(it), tw); + lines.forEach((ln, i) => { + if (!dry) { + if (i === 0) { + const sz = 1.1 * S; + 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.setFontSize(pt * S); if (!dry) { - doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]); - lines.forEach((ln, i) => doc.text(ln, RV.mx, cur.y + i * lineH + leftPt * S * PT * 0.76)); - if (rightText) { - doc.setFont('Lato', 'normal'); - doc.setFontSize(rightPt * S); - doc.setTextColor(RC.sub[0], RC.sub[1], RC.sub[2]); - 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) { - const tx = x + 3.6 * S; - const tw = w - 3.6 * S; +function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.2) { + const tx = x + 3.8; + const tw = w - 3.8; for (const it of items) { doc.setFont('Lato', 'normal'); doc.setFontSize(pt * S); @@ -697,182 +822,138 @@ function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.3) { lines.forEach((ln, i) => { if (!dry) { if (i === 0) { - // Small accent square marker (echoes the section-label motif). const sz = 1.15 * S; 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.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 -// 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); +// One experience entry: an accent node on the timeline, bold role title with a +// right-aligned period, the company in accent, then the achievement bullets. +function cvExperience(doc, S, dry, cur, e) { + const cx = MAIN_X + 1.4; // node / track centre x + const titlePt = 11, datePt = 9.3; + const dateW = 30; + 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; } -function cvExperience(doc, S, dry, cur, e) { - 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 += 1 * S; cvBullets(doc, S, dry, cur, e.punkte, RV.mx, RV_W); } - cur.y += 3 * 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) { - 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.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'); + const pt = 10.5, datePt = 9.3, dateW = 30; + doc.setFont('Lato', 'bold'); 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(' '); + const lines = doc.splitTextToSize(String(e.abschluss || ''), BODY_W - dateW); + const lineH = pt * S * PT * 1.16; + if (!dry) { + 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) { + 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' }); } } - 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); + 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 }); + 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 += 4 * S; } -function composeCV(doc, S, dry, cur, { cv, header, titel, foto }) { - const geo = composeCvHeader(doc, S, dry, cur, header, titel, foto); - if (cv.profil) { - cvProfil(doc, S, dry, cur, cv.profil, geo); - } else { - cur.y = Math.max(cur.y, geo.photoBottom); +// Big name + target role, closed by a short accent rule — the confident head +// 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); } - // 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); + 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) { + 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) { - cvHeading(doc, S, dry, cur, 'Berufserfahrung'); - cv.berufserfahrung.forEach((e) => cvExperience(doc, S, dry, cur, e)); + mHeading(doc, S, dry, cur, 'Berufserfahrung'); + composeTimeline(doc, S, dry, cur, cv.berufserfahrung); } [['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Weiterbildungen', cv.weiterbildungen]].forEach(([t, entries]) => { 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)); }); - if (cv.sprachen.length) { - 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'); - write(doc, S, dry, cur, cv.hobbys.join(', '), { pt: 9.7, color: RC.ink, x: RV.mx, width: RV_W, factor: 1.3 }); + return cur.y; +} + +// Draw the sidebar band first, then the main column, then the sidebar text — +// 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) { const doc = makeDoc(); - const m = { y: RV.top }; composeCV(doc, 1, true, m, { cv, header, titel, foto }); - const need = m.y - RV.top; + const ctx = { cv, header, titel, foto }; + const need = composeCV(doc, 1, true, ctx) - 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, foto }); + composeCV(doc, S, false, ctx); 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_W = LET_R - LET.mx; // 165 -// Grayscale body with the same deep navy-slate accent as the résumé, so the -// cover letter and CV read as one deliberately designed application set. +// Strictly monochrome, matching the résumé, so the cover letter and CV read as +// one deliberately designed black-and-white application set. const LC = { ink: [26, 26, 26], - muted: [92, 98, 106], + muted: [92, 96, 100], 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