Redesign résumé: white single-page layout (centered header + two columns)
Replaces the navy-sidebar CV with a white layout modelled on the reference: centered position title (soft highlight), name, contact row, a tailored Profil section, then two columns — experience (with bullet points) and education on the left; skills, languages, interests and personal details on the right. Restores the profil field and switches experience back to bullet points. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+180
-161
@@ -67,6 +67,7 @@ const OUTPUT_SCHEMA = {
|
|||||||
lebenslauf: {
|
lebenslauf: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
profil: { type: 'string' },
|
||||||
berufserfahrung: {
|
berufserfahrung: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
@@ -75,9 +76,9 @@ const OUTPUT_SCHEMA = {
|
|||||||
zeitraum: { type: 'string' },
|
zeitraum: { type: 'string' },
|
||||||
titel: { type: 'string' },
|
titel: { type: 'string' },
|
||||||
firma: { type: 'string' },
|
firma: { type: 'string' },
|
||||||
beschreibung: { type: 'string' },
|
punkte: { type: 'array', items: { type: 'string' } },
|
||||||
},
|
},
|
||||||
required: ['zeitraum', 'titel', 'firma', 'beschreibung'],
|
required: ['zeitraum', 'titel', 'firma', 'punkte'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
studium: { type: 'array', items: EDU_ITEM },
|
studium: { type: 'array', items: EDU_ITEM },
|
||||||
@@ -95,7 +96,7 @@ const OUTPUT_SCHEMA = {
|
|||||||
},
|
},
|
||||||
hobbys: { type: 'array', items: { type: 'string' } },
|
hobbys: { type: 'array', items: { type: 'string' } },
|
||||||
},
|
},
|
||||||
required: ['berufserfahrung', 'studium', 'berufsausbildung', 'schulbildung', 'weiterbildungen', 'kenntnisse', 'sprachen', 'hobbys'],
|
required: ['profil', 'berufserfahrung', 'studium', 'berufsausbildung', 'schulbildung', 'weiterbildungen', 'kenntnisse', 'sprachen', 'hobbys'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['headline', 'kontakt', 'anschreiben', 'lebenslauf'],
|
required: ['headline', 'kontakt', 'anschreiben', 'lebenslauf'],
|
||||||
@@ -157,8 +158,9 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
|||||||
` "gruss": "Mit freundlichen Grüßen"\n` +
|
` "gruss": "Mit freundlichen Grüßen"\n` +
|
||||||
` },\n` +
|
` },\n` +
|
||||||
` "lebenslauf": {\n` +
|
` "lebenslauf": {\n` +
|
||||||
|
` "profil": "2–3 Sätze Kurzprofil, zugeschnitten auf die Zielstelle",\n` +
|
||||||
` "berufserfahrung": [\n` +
|
` "berufserfahrung": [\n` +
|
||||||
` { "zeitraum": "02.2025 – heute", "titel": "Jobtitel", "firma": "Arbeitgeber, Ort", "beschreibung": "1 kurzer Satz (optional)" }\n` +
|
` { "zeitraum": "02.2025 – heute", "titel": "Jobtitel", "firma": "Arbeitgeber, Ort", "punkte": ["Aufgabe/Erfolg 1", "Aufgabe/Erfolg 2"] }\n` +
|
||||||
` ],\n` +
|
` ],\n` +
|
||||||
` "studium": [\n` +
|
` "studium": [\n` +
|
||||||
` { "zeitraum": "10.2010 – 03.2017", "abschluss": "Studiengang", "institution": "Hochschule", "zusatz": "" }\n` +
|
` { "zeitraum": "10.2010 – 03.2017", "abschluss": "Studiengang", "institution": "Hochschule", "zusatz": "" }\n` +
|
||||||
@@ -210,8 +212,9 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
|||||||
`Nur der ERSTE Absatz beginnt mit einem KLEINbuchstaben (die Anrede endet mit Komma, der Satz wird ` +
|
`Nur der ERSTE Absatz beginnt mit einem KLEINbuchstaben (die Anrede endet mit Komma, der Satz wird ` +
|
||||||
`fortgesetzt), z. B. "mit …" oder "als …" — außer das erste Wort ist ein Substantiv, ein Eigenname oder die ` +
|
`fortgesetzt), z. B. "mit …" oder "als …" — außer das erste Wort ist ein Substantiv, ein Eigenname oder die ` +
|
||||||
`Höflichkeitsform "Ihre/Ihr/Ihnen". Alle WEITEREN Absätze sind neue Sätze und beginnen normal mit GROSSbuchstaben.\n` +
|
`Höflichkeitsform "Ihre/Ihr/Ihnen". Alle WEITEREN Absätze sind neue Sätze und beginnen normal mit GROSSbuchstaben.\n` +
|
||||||
|
`- profil: 2–3 Sätze Kurzprofil, konkret auf die Zielstelle zugeschnitten (der individuelle Text zur Stelle).\n` +
|
||||||
`- berufserfahrung: ALLE Stationen aus den Unterlagen, neueste zuerst. "firma" = "Arbeitgeber, Ort". ` +
|
`- berufserfahrung: ALLE Stationen aus den Unterlagen, neueste zuerst. "firma" = "Arbeitgeber, Ort". ` +
|
||||||
`Nur die 2–3 jüngsten/relevantesten erhalten EINEN kurzen "beschreibung"-Satz, ältere lässt du leer.\n` +
|
`Nur die 2–3 jüngsten/relevantesten erhalten je 2–3 knappe Stichpunkte ("punkte"), ältere bekommen ein leeres Array.\n` +
|
||||||
`- Bildung korrekt einordnen: Hochschulstudium → studium; Berufsausbildung/Ausbildungsberuf → ` +
|
`- Bildung korrekt einordnen: Hochschulstudium → studium; Berufsausbildung/Ausbildungsberuf → ` +
|
||||||
`berufsausbildung; schulische Abschlüsse (Mittlere Reife/Fachoberschulreife, Fachhochschulreife, Abitur) → schulbildung; ` +
|
`berufsausbildung; schulische Abschlüsse (Mittlere Reife/Fachoberschulreife, Fachhochschulreife, Abitur) → schulbildung; ` +
|
||||||
`Zertifikate/Fortbildungen → weiterbildungen. Grundschule weglassen. ` +
|
`Zertifikate/Fortbildungen → weiterbildungen. Grundschule weglassen. ` +
|
||||||
@@ -408,12 +411,14 @@ function normalizeResult(parsed) {
|
|||||||
};
|
};
|
||||||
})(),
|
})(),
|
||||||
lebenslauf: {
|
lebenslauf: {
|
||||||
|
profil: str(pick(l, ['profil', 'kurzprofil', 'zusammenfassung', 'summary', 'ueberblick', 'über_mich'])),
|
||||||
berufserfahrung: pickArray(l, ['berufserfahrung', 'erfahrung', 'work_experience', 'experience', 'stationen'])
|
berufserfahrung: pickArray(l, ['berufserfahrung', 'erfahrung', 'work_experience', 'experience', 'stationen'])
|
||||||
.map((e) => ({
|
.map((e) => ({
|
||||||
zeitraum: toZeitraum(pick(e, ['zeitraum', 'zeit', 'dauer', 'period']) || e.zeitraum),
|
zeitraum: toZeitraum(pick(e, ['zeitraum', 'zeit', 'dauer', 'period']) || e.zeitraum),
|
||||||
titel: str(pick(e, ['titel', 'position', 'rolle', 'taetigkeit', 'jobtitel', 'bezeichnung', 'job'])),
|
titel: str(pick(e, ['titel', 'position', 'rolle', 'taetigkeit', 'jobtitel', 'bezeichnung', 'job'])),
|
||||||
firma: str(pick(e, ['firma', 'arbeitgeber', 'unternehmen', 'company', 'organisation'])),
|
firma: str(pick(e, ['firma', 'arbeitgeber', 'unternehmen', 'company', 'organisation'])),
|
||||||
beschreibung: safeNote(str(pick(e, ['beschreibung', 'description', 'details', 'text']))),
|
punkte: pickArray(e, ['punkte', 'aufgaben', 'taetigkeiten', 'highlights', 'bullets', 'details'])
|
||||||
|
.map((p) => safeNote(str(p))).filter(Boolean),
|
||||||
}))
|
}))
|
||||||
.filter((e) => e.titel || e.firma),
|
.filter((e) => e.titel || e.firma),
|
||||||
studium,
|
studium,
|
||||||
@@ -436,38 +441,13 @@ function normalizeResult(parsed) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// 2. PDF template — fixed two-column design (sidebar + main column)
|
// 2. Shared PDF primitives
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
const PT = 0.352778; // pt → mm
|
const PT = 0.352778; // pt -> mm
|
||||||
|
|
||||||
const PAGE = { w: 210, h: 297 };
|
const PAGE = { w: 210, h: 297 };
|
||||||
const SIDEBAR_W = 68;
|
|
||||||
const SIDE_PAD = 8;
|
|
||||||
const SIDE_X = SIDE_PAD;
|
|
||||||
const SIDE_W = SIDEBAR_W - SIDE_PAD * 2;
|
|
||||||
const MAIN_X = SIDEBAR_W + 10;
|
|
||||||
const MAIN_R = PAGE.w - 14;
|
|
||||||
const MAIN_W = MAIN_R - MAIN_X;
|
|
||||||
const DATE_W = 25; // date gutter inside the main column
|
|
||||||
const ENTRY_X = MAIN_X + DATE_W + 4;
|
|
||||||
const ENTRY_W = MAIN_R - ENTRY_X;
|
|
||||||
const TOP = 20;
|
|
||||||
const BOTTOM = 16;
|
|
||||||
const MIN_SCALE = 0.6;
|
const MIN_SCALE = 0.6;
|
||||||
|
|
||||||
const C = {
|
|
||||||
navy: [29, 40, 60], // sidebar background
|
|
||||||
sideText: [231, 237, 244],
|
|
||||||
sideLabel: [143, 158, 181],
|
|
||||||
sideRole: [150, 178, 204],
|
|
||||||
sideRule: [58, 72, 98],
|
|
||||||
accent: [122, 197, 230], // cyan — headings, underlines, bullets
|
|
||||||
company: [77, 141, 201], // main-column company / institution
|
|
||||||
ink: [33, 43, 61],
|
|
||||||
gray: [110, 122, 140],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Low-level text writer. `cur` is a {y} cursor; advances it. `dry` = measure only.
|
// Low-level text writer. `cur` is a {y} cursor; advances it. `dry` = measure only.
|
||||||
function write(doc, S, dry, cur, text, o) {
|
function write(doc, S, dry, cur, text, o) {
|
||||||
const pt = o.pt;
|
const pt = o.pt;
|
||||||
@@ -496,152 +476,193 @@ function rule(doc, S, dry, y, x1, x2, color, weight) {
|
|||||||
doc.line(x1, y, x2, y);
|
doc.line(x1, y, x2, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- sidebar -------------------------------------------------------------
|
// ===========================================================================
|
||||||
|
// Resume (Lebenslauf) — white page: centered header + two columns below
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
function composeSidebar(doc, S, dry, cur, { header, cv }) {
|
const RV = { mx: 18, top: 15, bottom: 13 };
|
||||||
// Name (split into two lines on the first space, like the reference)
|
const RV_FULL = PAGE.w - RV.mx * 2; // 174
|
||||||
const nameParts = (header.name || '').trim().split(/\s+/);
|
const RV_CENTER = PAGE.w / 2; // 105
|
||||||
const nameLines = nameParts.length > 1
|
const RV_LEFT_X = RV.mx; // 18
|
||||||
? [nameParts.slice(0, -1).join(' '), nameParts[nameParts.length - 1]]
|
const RV_LEFT_W = 104;
|
||||||
: [header.name || ''];
|
const RV_GAP = 8;
|
||||||
for (const nl of nameLines) {
|
const RV_RIGHT_X = RV.mx + RV_LEFT_W + RV_GAP; // 130
|
||||||
write(doc, S, dry, cur, nl, { pt: 25, style: 'bold', color: [255, 255, 255], x: SIDE_X, width: SIDE_W, factor: 1.02 });
|
const RV_RIGHT_W = (PAGE.w - RV.mx) - RV_RIGHT_X; // 62
|
||||||
}
|
|
||||||
if (header.headline) {
|
|
||||||
cur.y += 1.5 * S;
|
|
||||||
write(doc, S, dry, cur, header.headline, { pt: 11.5, color: C.sideRole, x: SIDE_X, width: SIDE_W, factor: 1.22 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const section = (title) => {
|
const RC = {
|
||||||
cur.y += 6 * S;
|
ink: [26, 26, 28],
|
||||||
write(doc, S, dry, cur, title, { pt: 10, style: 'bold', color: C.accent, x: SIDE_X, width: SIDE_W, charSpace: 0.6, upper: true, factor: 1.0 });
|
sub: [92, 98, 106],
|
||||||
cur.y += 1.8 * S;
|
line: [30, 30, 32], // dark separators (header / profile)
|
||||||
rule(doc, S, dry, cur.y, SIDE_X, SIDEBAR_W - SIDE_PAD, C.sideRule, 0.35);
|
hair: [208, 210, 214], // light section underlines
|
||||||
cur.y += 4.2 * S;
|
highlight: [200, 226, 200], // soft green behind the title
|
||||||
};
|
};
|
||||||
|
|
||||||
// KONTAKT
|
function centerText(doc, S, dry, cur, text, { pt, style = 'normal', color = RC.ink, factor = 1.2 }) {
|
||||||
const kontaktItems = [
|
if (!text) return;
|
||||||
['Adresse', header.adresse],
|
doc.setFont('helvetica', style);
|
||||||
['Telefon', header.telefon],
|
doc.setFontSize(pt * S);
|
||||||
['E-Mail', header.email],
|
const lines = doc.splitTextToSize(String(text), RV_FULL);
|
||||||
['Geboren', header.geburtsdatum],
|
for (const ln of lines) {
|
||||||
['Führerschein', header.fuehrerschein],
|
if (!dry) { doc.setTextColor(color[0], color[1], color[2]); doc.text(ln, RV_CENTER, cur.y + pt * S * PT * 0.76, { align: 'center' }); }
|
||||||
].filter(([, v]) => v);
|
cur.y += pt * S * PT * factor;
|
||||||
if (kontaktItems.length) {
|
|
||||||
section('Kontakt');
|
|
||||||
kontaktItems.forEach(([label, value]) => {
|
|
||||||
write(doc, S, dry, cur, label, { pt: 7.5, color: C.sideLabel, x: SIDE_X, width: SIDE_W, charSpace: 0.3, upper: true, factor: 1.25 });
|
|
||||||
write(doc, S, dry, cur, value, { pt: 9.5, color: C.sideText, x: SIDE_X, width: SIDE_W, factor: 1.25 });
|
|
||||||
cur.y += 2.4 * S;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cv && cv.kenntnisse.length) {
|
|
||||||
section('Fähigkeiten');
|
|
||||||
cv.kenntnisse.forEach((skill) => {
|
|
||||||
const dotY = cur.y + 9 * S * PT * 0.42;
|
|
||||||
if (!dry) { doc.setFillColor(C.accent[0], C.accent[1], C.accent[2]); doc.circle(SIDE_X + 0.9, dotY, 0.7 * S, 'F'); }
|
|
||||||
write(doc, S, dry, cur, skill, { pt: 9, color: C.sideText, x: SIDE_X + 4, width: SIDE_W - 4, factor: 1.28 });
|
|
||||||
cur.y += 1.4 * S;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cv && cv.sprachen.length) {
|
|
||||||
section('Sprachen');
|
|
||||||
cv.sprachen.forEach((s) => {
|
|
||||||
// two-tone: language white, level muted, on one line
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setFontSize(9 * S);
|
|
||||||
if (!dry) {
|
|
||||||
doc.setTextColor(C.sideText[0], C.sideText[1], C.sideText[2]);
|
|
||||||
doc.text(s.sprache, SIDE_X, cur.y + 9 * S * PT * 0.76);
|
|
||||||
if (s.niveau) {
|
|
||||||
const w = doc.getTextWidth(s.sprache);
|
|
||||||
doc.setTextColor(C.sideLabel[0], C.sideLabel[1], C.sideLabel[2]);
|
|
||||||
doc.text(` – ${s.niveau}`, SIDE_X + w, cur.y + 9 * S * PT * 0.76);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cur.y += 9 * S * PT * 1.4;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cv && cv.hobbys.length) {
|
|
||||||
section('Hobbys');
|
|
||||||
write(doc, S, dry, cur, cv.hobbys.join(' · '), { pt: 9, color: C.sideText, x: SIDE_X, width: SIDE_W, factor: 1.3 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- main column ---------------------------------------------------------
|
// Column section heading: bold uppercase, tracked, with a thin underline.
|
||||||
|
function cvHeading(doc, S, dry, cur, title, x, w) {
|
||||||
function mainSection(doc, S, dry, cur, title) {
|
cur.y += 3 * S;
|
||||||
cur.y += 3.5 * S;
|
|
||||||
write(doc, S, dry, cur, title, { pt: 13, style: 'bold', color: C.ink, x: MAIN_X, width: MAIN_W, charSpace: 0.5, upper: true, factor: 1.0 });
|
|
||||||
cur.y += 2.2 * S;
|
|
||||||
rule(doc, S, dry, cur.y, MAIN_X, MAIN_X + 15, C.accent, 1.1);
|
|
||||||
cur.y += 6 * S;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One dated entry: date in the left gutter, content to the right.
|
|
||||||
function mainEntry(doc, S, dry, cur, { zeitraum, title, sub, note, desc }) {
|
|
||||||
const startY = cur.y;
|
|
||||||
// date (own baseline, doesn't drive the cursor)
|
|
||||||
if (!dry && zeitraum) {
|
|
||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
doc.setFontSize(8.5 * S);
|
doc.setFontSize(11.5 * S);
|
||||||
doc.setTextColor(C.gray[0], C.gray[1], C.gray[2]);
|
if (!dry) {
|
||||||
const dl = doc.splitTextToSize(zeitraum, DATE_W);
|
doc.setCharSpace(0.5 * S);
|
||||||
dl.forEach((ln, i) => doc.text(ln, MAIN_X, startY + 11.5 * S * PT * 0.76 + i * 8.5 * S * PT * 1.2));
|
doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]);
|
||||||
|
doc.text(String(title).toUpperCase(), x, cur.y + 11.5 * S * PT * 0.76);
|
||||||
|
doc.setCharSpace(0);
|
||||||
}
|
}
|
||||||
if (title) write(doc, S, dry, cur, title, { pt: 11.5, style: 'bold', color: C.ink, x: ENTRY_X, width: ENTRY_W, factor: 1.22 });
|
cur.y += 11.5 * S * PT + 1.8 * S;
|
||||||
if (sub) write(doc, S, dry, cur, sub, { pt: 10, style: 'bold', color: C.company, x: ENTRY_X, width: ENTRY_W, factor: 1.25 });
|
rule(doc, S, dry, cur.y, x, x + w, RC.hair, 0.3);
|
||||||
if (note) write(doc, S, dry, cur, note, { pt: 9, style: 'italic', color: C.gray, x: ENTRY_X, width: ENTRY_W, factor: 1.25 });
|
cur.y += 3.2 * S;
|
||||||
if (desc) { cur.y += 0.6 * S; write(doc, S, dry, cur, desc, { pt: 9.3, color: C.gray, x: ENTRY_X, width: ENTRY_W, factor: 1.35 }); }
|
}
|
||||||
|
|
||||||
|
function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.3) {
|
||||||
|
const tx = x + 3.6 * S;
|
||||||
|
const tw = w - 3.6 * S;
|
||||||
|
for (const it of items) {
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(pt * S);
|
||||||
|
const lines = doc.splitTextToSize(String(it), tw);
|
||||||
|
lines.forEach((ln, i) => {
|
||||||
|
if (!dry) {
|
||||||
|
if (i === 0) { doc.setFillColor(RC.sub[0], RC.sub[1], RC.sub[2]); doc.circle(x + 0.8 * S, cur.y + pt * S * PT * 0.42, 0.6 * S, '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 += 0.8 * S;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cvExperience(doc, S, dry, cur, e, x, w) {
|
||||||
|
const meta = [e.firma, e.zeitraum].filter(Boolean).join(' | ');
|
||||||
|
write(doc, S, dry, cur, meta, { pt: 8.7, color: RC.sub, x, width: w, factor: 1.15 });
|
||||||
|
write(doc, S, dry, cur, e.titel, { pt: 10.5, style: 'bold', color: RC.ink, x, width: w, factor: 1.2 });
|
||||||
|
if (e.punkte && e.punkte.length) { cur.y += 0.8 * S; cvBullets(doc, S, dry, cur, e.punkte, x, w); }
|
||||||
|
cur.y += 3.4 * S;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cvEducation(doc, S, dry, cur, e, x, w) {
|
||||||
|
const meta = [e.institution, e.zeitraum].filter(Boolean).join(' | ');
|
||||||
|
write(doc, S, dry, cur, meta, { pt: 8.7, color: RC.sub, x, width: w, factor: 1.15 });
|
||||||
|
write(doc, S, dry, cur, e.abschluss, { pt: 10.5, style: 'bold', color: RC.ink, x, width: w, factor: 1.2 });
|
||||||
|
if (e.zusatz) write(doc, S, dry, cur, e.zusatz, { pt: 9, color: RC.sub, x, width: w, factor: 1.2 });
|
||||||
|
cur.y += 3 * S;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centered header: position title (with soft highlight), name, contact row.
|
||||||
|
function composeCvHeader(doc, S, dry, cur, header, titel) {
|
||||||
|
const title = String(titel || '').toUpperCase();
|
||||||
|
if (title) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(22 * S);
|
||||||
|
const tw = Math.min(doc.getTextWidth(title), RV_FULL);
|
||||||
|
const lineH = 22 * S * PT;
|
||||||
|
if (!dry) {
|
||||||
|
const padX = 4 * S;
|
||||||
|
doc.setFillColor(RC.highlight[0], RC.highlight[1], RC.highlight[2]);
|
||||||
|
doc.rect(RV_CENTER - tw / 2 - padX, cur.y + 1.4 * S, tw + padX * 2, lineH * 0.8, 'F');
|
||||||
|
doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]);
|
||||||
|
doc.text(title, RV_CENTER, cur.y + lineH * 0.72, { align: 'center', maxWidth: RV_FULL });
|
||||||
|
}
|
||||||
|
cur.y += lineH * 1.2;
|
||||||
|
}
|
||||||
|
centerText(doc, S, dry, cur, header.name, { pt: 13, color: RC.sub, factor: 1.25 });
|
||||||
|
cur.y += 2.6 * S;
|
||||||
|
|
||||||
|
rule(doc, S, dry, cur.y, RV.mx, PAGE.w - RV.mx, RC.line, 0.4);
|
||||||
|
cur.y += 3.4 * S;
|
||||||
|
const parts = [header.telefon, header.email, cityName(header) || header.ort, header.webseite].filter(Boolean);
|
||||||
|
if (parts.length) {
|
||||||
|
let cpt = 9;
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(cpt * S);
|
||||||
|
const contact = parts.join(' | ');
|
||||||
|
const cw = doc.getTextWidth(contact);
|
||||||
|
if (cw > RV_FULL) cpt = Math.max(7.5, cpt * RV_FULL / cw);
|
||||||
|
if (!dry) { doc.setFontSize(cpt * S); doc.setTextColor(RC.sub[0], RC.sub[1], RC.sub[2]); doc.text(contact, RV_CENTER, cur.y + cpt * S * PT * 0.76, { align: 'center' }); }
|
||||||
|
cur.y += cpt * S * PT * 1.2;
|
||||||
|
}
|
||||||
|
cur.y += 2.8 * S;
|
||||||
|
rule(doc, S, dry, cur.y, RV.mx, PAGE.w - RV.mx, RC.line, 0.4);
|
||||||
cur.y += 5 * S;
|
cur.y += 5 * S;
|
||||||
}
|
}
|
||||||
|
|
||||||
function composeMainCV(doc, S, dry, cur, { cv }) {
|
function composeCvLeft(doc, S, dry, cur, cv) {
|
||||||
|
const x = RV_LEFT_X, w = RV_LEFT_W;
|
||||||
if (cv.berufserfahrung.length) {
|
if (cv.berufserfahrung.length) {
|
||||||
mainSection(doc, S, dry, cur, 'Berufserfahrung');
|
cvHeading(doc, S, dry, cur, 'Berufserfahrung', x, w);
|
||||||
cv.berufserfahrung.forEach((e) => mainEntry(doc, S, dry, cur, {
|
cv.berufserfahrung.forEach((e) => cvExperience(doc, S, dry, cur, e, x, w));
|
||||||
zeitraum: e.zeitraum, title: e.titel, sub: e.firma, desc: e.beschreibung,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
// Education, split into norm-compliant German sections (reverse-chronological).
|
[['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Schulbildung', cv.schulbildung], ['Weiterbildungen', cv.weiterbildungen]].forEach(([t, entries]) => {
|
||||||
const eduSections = [
|
|
||||||
['Studium', cv.studium],
|
|
||||||
['Berufsausbildung', cv.berufsausbildung],
|
|
||||||
['Schulbildung', cv.schulbildung],
|
|
||||||
['Weiterbildungen', cv.weiterbildungen],
|
|
||||||
];
|
|
||||||
eduSections.forEach(([title, entries]) => {
|
|
||||||
if (!entries || !entries.length) return;
|
if (!entries || !entries.length) return;
|
||||||
mainSection(doc, S, dry, cur, title);
|
cvHeading(doc, S, dry, cur, t, x, w);
|
||||||
entries.forEach((e) => mainEntry(doc, S, dry, cur, {
|
entries.forEach((e) => cvEducation(doc, S, dry, cur, e, x, w));
|
||||||
zeitraum: e.zeitraum, title: e.abschluss, sub: e.institution, note: safeNote(e.zusatz),
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- two-column page assembly (measure → scale → draw) -------------------
|
function composeCvRight(doc, S, dry, cur, cv, header) {
|
||||||
|
const x = RV_RIGHT_X, w = RV_RIGHT_W;
|
||||||
|
if (cv.kenntnisse.length) {
|
||||||
|
cvHeading(doc, S, dry, cur, 'Fähigkeiten', x, w);
|
||||||
|
cvBullets(doc, S, dry, cur, cv.kenntnisse, x, w);
|
||||||
|
cur.y += 1 * S;
|
||||||
|
}
|
||||||
|
if (cv.sprachen.length) {
|
||||||
|
cvHeading(doc, S, dry, cur, 'Sprachen', x, w);
|
||||||
|
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, width: w, factor: 1.4 });
|
||||||
|
});
|
||||||
|
cur.y += 1 * S;
|
||||||
|
}
|
||||||
|
if (cv.hobbys.length) {
|
||||||
|
cvHeading(doc, S, dry, cur, 'Interessen', x, w);
|
||||||
|
cvBullets(doc, S, dry, cur, cv.hobbys, x, w);
|
||||||
|
cur.y += 1 * S;
|
||||||
|
}
|
||||||
|
const pers = [
|
||||||
|
header.geburtsdatum ? `Geboren: ${header.geburtsdatum}` : null,
|
||||||
|
header.fuehrerschein ? `Führerschein: ${header.fuehrerschein}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
if (pers.length) {
|
||||||
|
cvHeading(doc, S, dry, cur, 'Persönliches', x, w);
|
||||||
|
pers.forEach((p) => write(doc, S, dry, cur, p, { pt: 9.3, color: RC.ink, x, width: w, factor: 1.4 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderTwoColumn(composeSide, composeMain) {
|
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', RV.mx, RV_FULL);
|
||||||
|
write(doc, S, dry, cur, cv.profil, { pt: 9.7, color: RC.ink, x: RV.mx, width: RV_FULL, factor: 1.42 });
|
||||||
|
cur.y += 2.5 * S;
|
||||||
|
rule(doc, S, dry, cur.y, RV.mx, PAGE.w - RV.mx, RC.line, 0.4);
|
||||||
|
cur.y += 4.5 * S;
|
||||||
|
}
|
||||||
|
const bodyTop = cur.y;
|
||||||
|
const left = { y: bodyTop }; composeCvLeft(doc, S, dry, left, cv);
|
||||||
|
const right = { y: bodyTop }; composeCvRight(doc, S, dry, right, cv, header);
|
||||||
|
cur.y = Math.max(left.y, right.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCV(cv, header, titel) {
|
||||||
const doc = new jsPDF({ unit: 'mm', format: 'a4' });
|
const doc = new jsPDF({ unit: 'mm', format: 'a4' });
|
||||||
|
const m = { y: RV.top }; composeCV(doc, 1, true, m, { cv, header, titel });
|
||||||
// measure both columns at scale 1
|
const need = m.y - RV.top;
|
||||||
const sm = { y: TOP }; composeSide(doc, 1, true, sm);
|
const avail = PAGE.h - RV.top - RV.bottom;
|
||||||
const mm = { y: TOP }; composeMain(doc, 1, true, mm);
|
|
||||||
const need = Math.max(sm.y, mm.y) - TOP;
|
|
||||||
const avail = PAGE.h - TOP - 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 });
|
||||||
// draw
|
|
||||||
doc.setFillColor(C.navy[0], C.navy[1], C.navy[2]);
|
|
||||||
doc.rect(0, 0, SIDEBAR_W, PAGE.h, 'F');
|
|
||||||
composeSide(doc, S, false, { y: TOP });
|
|
||||||
composeMain(doc, S, false, { y: TOP });
|
|
||||||
|
|
||||||
return Buffer.from(doc.output('arraybuffer'));
|
return Buffer.from(doc.output('arraybuffer'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,10 +796,8 @@ function buildHeader(settings, kontakt, headline) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderLebenslaufPdf(cv, header) {
|
function renderLebenslaufPdf(cv, header) {
|
||||||
return renderTwoColumn(
|
// Title at the top = the position (headline), falling back to the name.
|
||||||
(doc, S, dry, cur) => composeSidebar(doc, S, dry, cur, { header, cv }),
|
return renderCV(cv, header, header.headline || '');
|
||||||
(doc, S, dry, cur) => composeMainCV(doc, S, dry, cur, { cv })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAnschreibenPdf(letter, header, job, anlagen, signatur) {
|
function renderAnschreibenPdf(letter, header, job, anlagen, signatur) {
|
||||||
|
|||||||
Reference in New Issue
Block a user