diff --git a/lib/documents.js b/lib/documents.js index 3843d14..1bb4375 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -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 }); } }