// 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 // 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. const { jsPDF } = require('jspdf'); // Ollama Cloud API (https://ollama.com). Override host/model via env if needed. const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, ''); const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b'; const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 180000); // =========================================================================== // 1. LLM call — produce tailored TEXT for the template // =========================================================================== const OUTPUT_SCHEMA = { type: 'object', properties: { headline: { type: 'string' }, kontakt: { type: 'object', properties: { email: { type: 'string' }, telefon: { type: 'string' }, ort: { type: 'string' }, webseite: { type: 'string' }, geburtsdatum: { type: 'string' }, fuehrerschein: { type: 'string' }, }, required: ['email', 'telefon', 'ort', 'webseite', 'geburtsdatum', 'fuehrerschein'], }, anschreiben: { type: 'object', properties: { betreff: { type: 'string' }, anrede: { type: 'string' }, absaetze: { type: 'array', items: { type: 'string' } }, gruss: { type: 'string' }, }, required: ['betreff', 'anrede', 'absaetze', 'gruss'], }, lebenslauf: { type: 'object', properties: { berufserfahrung: { type: 'array', items: { type: 'object', properties: { zeitraum: { type: 'string' }, titel: { type: 'string' }, firma: { type: 'string' }, beschreibung: { type: 'string' }, }, required: ['zeitraum', 'titel', 'firma', 'beschreibung'], }, }, ausbildung: { type: 'array', items: { type: 'object', properties: { zeitraum: { type: 'string' }, abschluss: { type: 'string' }, institution: { type: 'string' }, zusatz: { type: 'string' }, }, required: ['zeitraum', 'abschluss', 'institution', 'zusatz'], }, }, kenntnisse: { type: 'array', items: { type: 'string' } }, sprachen: { type: 'array', items: { type: 'object', properties: { sprache: { type: 'string' }, niveau: { type: 'string' } }, required: ['sprache', 'niveau'], }, }, hobbys: { type: 'array', items: { type: 'string' } }, }, required: ['berufserfahrung', 'ausbildung', 'kenntnisse', 'sprachen', 'hobbys'], }, }, required: ['headline', 'kontakt', 'anschreiben', 'lebenslauf'], }; async function generateTailoredTexts({ job, basisDokumente, settings }) { const apiKey = process.env.OLLAMA_API_KEY; if (!apiKey) { throw new Error( 'OLLAMA_API_KEY ist nicht gesetzt. Bitte den API-Schlüssel als ' + 'Umgebungsvariable (z. B. in einer .env-Datei) hinterlegen, damit ' + 'Bewerbungsunterlagen generiert werden können.' ); } if (!basisDokumente || basisDokumente.length === 0) { throw new Error( 'Es sind keine Basis-Unterlagen hinterlegt. Bitte zuerst unter "Vorlagen" ' + 'mindestens ein Basis-Dokument (z. B. Anschreiben und Lebenslauf) bereitstellen.' ); } const bewerber = [ settings && settings.name ? `Name: ${settings.name}` : null, settings && settings.adresse ? `Adresse: ${settings.adresse}` : null, ].filter(Boolean).join('\n') || 'Keine Angaben'; const basisText = basisDokumente .map((d, i) => `### Basis-Dokument ${i + 1} — Typ: ${d.typ || 'Sonstiges'} (${d.name || 'ohne Titel'})\n${d.inhalt}`) .join('\n\n'); const stelleText = [ job.stelle ? `Stellenbezeichnung: ${job.stelle}` : null, job.firma ? `Unternehmen: ${job.firma}` : null, job.ort ? `Ort: ${job.ort}` : null, job.gehalt ? `Gehalt/Konditionen: ${job.gehalt}` : null, '', 'Stellenbeschreibung:', job.stellenbeschreibung || '(keine Beschreibung übermittelt)', ].filter((l) => l !== null).join('\n'); const system = 'Du bist ein erfahrener Bewerbungscoach und erstellst professionelle, ' + 'deutschsprachige Bewerbungstexte. Du passt die BEREITGESTELLTEN Basis-Unterlagen ' + 'des Bewerbers auf eine konkrete Stellenausschreibung an. Wichtigste Regel: Du ' + 'erfindest KEINE Fakten, Qualifikationen, Abschlüsse, Kontaktdaten oder ' + 'Berufserfahrungen. Verwende ausschließlich Informationen aus den Basis-Unterlagen. ' + 'Du darfst umformulieren, gewichten, relevante Punkte hervorheben und auf die Stelle ' + 'zuschneiden — aber nichts hinzudichten. Schreibe natürlich, konkret und ohne Floskeln.'; const skeleton = `{\n` + ` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)",\n` + ` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "", "fuehrerschein": "" },\n` + ` "anschreiben": {\n` + ` "betreff": "Bewerbung als …",\n` + ` "anrede": "Sehr geehrte Damen und Herren,",\n` + ` "absaetze": ["Absatz 1", "Absatz 2", "Absatz 3"],\n` + ` "gruss": "Mit freundlichen Grüßen"\n` + ` },\n` + ` "lebenslauf": {\n` + ` "berufserfahrung": [\n` + ` { "zeitraum": "02.2025 – heute", "titel": "Jobtitel", "firma": "Arbeitgeber, Ort", "beschreibung": "1 kurzer Satz (optional)" }\n` + ` ],\n` + ` "ausbildung": [\n` + ` { "zeitraum": "2012 – 2015", "abschluss": "Abschluss", "institution": "Schule/Hochschule", "zusatz": "" }\n` + ` ],\n` + ` "kenntnisse": ["Fähigkeit 1", "Fähigkeit 2"],\n` + ` "sprachen": [ { "sprache": "Deutsch", "niveau": "Muttersprache" } ],\n` + ` "hobbys": ["Hobby 1", "Hobby 2"]\n` + ` }\n` + `}`; const userPrompt = `# Bewerberdaten\n${bewerber}\n\n` + `# Basis-Unterlagen des Bewerbers (Faktengrundlage — NUR diese Fakten verwenden)\n${basisText}\n\n` + `# Zielstelle\n${stelleText}\n\n` + `# Aufgabe\n` + `Erzeuge die TEXTE für ein Anschreiben und einen Lebenslauf, passgenau auf diese ` + `Stelle zugeschnitten. Layout/Design ist bereits vorgegeben — liefere nur die Inhalte. ` + `Beide Dokumente werden auf JE EINER A4-Seite gedruckt, halte dich also kurz.\n\n` + `Verwende EXAKT diese JSON-Struktur und exakt diese Schlüsselnamen ` + `(keine anderen, keine zusätzlichen Felder, "zeitraum" immer als einzelner String):\n\n` + `${skeleton}\n\n` + `Vorgaben:\n` + `- headline: kurze Berufsbezeichnung des Bewerbers, ggf. auf die Zielstelle zugeschnitten.\n` + `- kontakt: E-Mail, Telefon, Ort, Webseite, Geburtsdatum, Führerschein NUR übernehmen, ` + `wenn in den Basis-Unterlagen vorhanden; sonst leerer String. Nichts erfinden.\n` + `- anschreiben.absaetze: 3–4 kurze, überzeugende Absätze (kein Adressblock, kein Datum, ` + `keine Grußformel hier). Keine Platzhalter — echte Angaben nutzen.\n` + `- berufserfahrung: relevanteste Stationen (neueste zuerst). "firma" = "Arbeitgeber, Ort". ` + `"beschreibung" = optional EIN kurzer Satz (sonst leer).\n` + `- ausbildung: relevante Abschlüsse/Stationen. "zusatz" optional (z. B. "Abschluss: …" oder "nicht beendet").\n` + `- kenntnisse: max. 12 prägnante Stichworte, relevanteste zuerst.\n` + `- sprachen & hobbys: nur falls in den Unterlagen vorhanden.\n` + `Leere Felder als leerer String bzw. leeres Array. Antworte AUSSCHLIESSLICH mit dem ` + `JSON-Objekt, ohne Markdown, ohne Code-Fences, ohne weiteren Text.`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS); let res; try { res = await fetch(`${OLLAMA_HOST}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ model: OLLAMA_MODEL, stream: false, format: OUTPUT_SCHEMA, options: { temperature: 0.4 }, messages: [ { role: 'system', content: system }, { role: 'user', content: userPrompt }, ], }), signal: controller.signal, }); } catch (err) { if (err.name === 'AbortError') { throw new Error(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(OLLAMA_TIMEOUT_MS / 1000)}s).`); } throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`); } finally { clearTimeout(timeout); } if (!res.ok) { const body = await res.text().catch(() => ''); throw new Error(`Ollama-API antwortete mit ${res.status}: ${body.slice(0, 300)}`); } const data = await res.json(); const text = ((data && data.message && data.message.content) || '').trim(); if (!text) throw new Error('Die KI hat keine Antwort geliefert (leerer Inhalt).'); const cleaned = text.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim(); let parsed; try { parsed = JSON.parse(cleaned); } catch (e) { const match = cleaned.match(/\{[\s\S]*\}/); if (match) { try { parsed = JSON.parse(match[0]); } catch (_) { /* ignore */ } } } if (!parsed || typeof parsed !== 'object') { throw new Error('Die KI-Antwort konnte nicht als JSON gelesen werden.'); } return normalizeResult(parsed); } // ----- tolerant normalisation ---------------------------------------------- // Models honour the schema keys only loosely, so accept common German synonyms. const str = (v) => (typeof v === 'string' ? v.trim() : (v == null ? '' : String(v).trim())); function pick(obj, keys) { if (!obj) return ''; for (const k of keys) if (obj[k] != null && String(obj[k]).trim() !== '') return obj[k]; return ''; } function pickArray(obj, keys) { if (!obj) return []; for (const k of keys) if (Array.isArray(obj[k]) && obj[k].length) return obj[k]; return []; } function toZeitraum(v) { if (!v) return ''; if (typeof v === 'string') return v.trim(); if (typeof v === 'object') { const von = str(pick(v, ['von', 'from', 'start', 'beginn'])); const bis = str(pick(v, ['bis', 'to', 'ende', 'end'])); return [von, bis].filter(Boolean).join(' – '); } return str(v); } function normalizeResult(parsed) { const k = parsed.kontakt || parsed.contact || {}; const a = parsed.anschreiben || parsed.cover_letter || {}; const l = parsed.lebenslauf || parsed.cv || parsed.resume || {}; return { headline: str(pick(parsed, ['headline', 'berufsbezeichnung', 'titel', 'position', 'beruf', 'rolle'])), kontakt: { email: str(pick(k, ['email', 'e_mail', 'mail'])), telefon: str(pick(k, ['telefon', 'phone', 'tel', 'mobil'])), ort: str(pick(k, ['ort', 'stadt', 'wohnort', 'city'])), webseite: str(pick(k, ['webseite', 'website', 'web', 'url', 'linkedin'])), geburtsdatum: str(pick(k, ['geburtsdatum', 'geboren', 'birthdate', 'dob', 'geburt'])), fuehrerschein: str(pick(k, ['fuehrerschein', 'führerschein', 'driving_license', 'license', 'licence', 'fuehrerscheinklasse'])), }, anschreiben: { betreff: str(pick(a, ['betreff', 'subject', 'titel'])), anrede: str(pick(a, ['anrede', 'salutation'])), absaetze: pickArray(a, ['absaetze', 'absätze', 'paragraphs', 'text', 'inhalt']).map(str).filter(Boolean), gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])), }, lebenslauf: { berufserfahrung: pickArray(l, ['berufserfahrung', 'erfahrung', 'work_experience', 'experience', 'stationen']) .map((e) => ({ zeitraum: toZeitraum(pick(e, ['zeitraum', 'zeit', 'dauer', 'period']) || e.zeitraum), titel: str(pick(e, ['titel', 'position', 'rolle', 'taetigkeit', 'jobtitel', 'bezeichnung', 'job'])), firma: str(pick(e, ['firma', 'arbeitgeber', 'unternehmen', 'company', 'organisation'])), beschreibung: str(pick(e, ['beschreibung', 'description', 'details', 'text', 'zusatz'])), })) .filter((e) => e.titel || e.firma), ausbildung: pickArray(l, ['ausbildung', 'bildung', 'education', 'qualifikationen']) .map((e) => ({ zeitraum: toZeitraum(pick(e, ['zeitraum', 'zeit', 'dauer', 'period', 'jahr']) || e.zeitraum), abschluss: str(pick(e, ['abschluss', 'titel', 'grad', 'degree', 'qualifikation', 'name'])), institution: str(pick(e, ['institution', 'schule', 'hochschule', 'einrichtung', 'organisation'])), zusatz: str(pick(e, ['zusatz', 'note', 'hinweis', 'details'])), })) .filter((e) => e.abschluss || e.institution), kenntnisse: pickArray(l, ['kenntnisse', 'skills', 'faehigkeiten', 'fähigkeiten', 'kompetenzen']) .map((s) => (typeof s === 'object' ? str(pick(s, ['name', 'skill', 'bezeichnung'])) : str(s))) .filter(Boolean), sprachen: pickArray(l, ['sprachen', 'languages']) .map((e) => (typeof e === 'string' ? { sprache: str(e), niveau: '' } : { sprache: str(pick(e, ['sprache', 'name', 'language'])), niveau: str(pick(e, ['niveau', 'level', 'stufe'])) })) .filter((e) => e.sprache), hobbys: pickArray(l, ['hobbys', 'hobbies', 'interessen', 'interests']) .map((s) => (typeof s === 'object' ? str(pick(s, ['name', 'bezeichnung'])) : str(s))) .filter(Boolean), }, }; } // =========================================================================== // 2. PDF template — fixed two-column design (sidebar + main column) // =========================================================================== const PT = 0.352778; // pt → mm 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 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. function write(doc, S, dry, cur, text, o) { const pt = o.pt; const factor = o.factor == null ? 1.3 : o.factor; if (text == null || text === '') return; doc.setFont('helvetica', o.style || 'normal'); doc.setFontSize(pt * S); if (o.charSpace) doc.setCharSpace(o.charSpace * S); const content = o.upper ? String(text).toUpperCase() : String(text); const lines = doc.splitTextToSize(content, o.width); for (const ln of lines) { if (!dry) { doc.setTextColor(o.color[0], o.color[1], o.color[2]); const drawX = o.align === 'right' ? o.right : o.x; doc.text(ln, drawX, cur.y + pt * S * PT * 0.76, { align: o.align || 'left' }); } cur.y += pt * S * PT * factor; } if (o.charSpace) doc.setCharSpace(0); } function rule(doc, S, dry, y, x1, x2, color, weight) { if (dry) return; doc.setDrawColor(color[0], color[1], color[2]); doc.setLineWidth(weight * S); doc.line(x1, y, x2, y); } // ----- sidebar ------------------------------------------------------------- function composeSidebar(doc, S, dry, cur, { header, cv }) { // Name (split into two lines on the first space, like the reference) const nameParts = (header.name || '').trim().split(/\s+/); const nameLines = nameParts.length > 1 ? [nameParts.slice(0, -1).join(' '), nameParts[nameParts.length - 1]] : [header.name || '']; for (const nl of nameLines) { write(doc, S, dry, cur, nl, { pt: 25, style: 'bold', color: [255, 255, 255], x: SIDE_X, width: SIDE_W, factor: 1.02 }); } 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) => { cur.y += 6 * S; 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 }); cur.y += 1.8 * S; rule(doc, S, dry, cur.y, SIDE_X, SIDEBAR_W - SIDE_PAD, C.sideRule, 0.35); cur.y += 4.2 * S; }; // KONTAKT const kontaktItems = [ ['Adresse', header.adresse], ['Telefon', header.telefon], ['E-Mail', header.email], ['Geboren', header.geburtsdatum], ['Führerschein', header.fuehrerschein], ].filter(([, v]) => v); 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 --------------------------------------------------------- function mainSection(doc, S, dry, cur, title) { 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.setFontSize(8.5 * S); doc.setTextColor(C.gray[0], C.gray[1], C.gray[2]); const dl = doc.splitTextToSize(zeitraum, DATE_W); dl.forEach((ln, i) => doc.text(ln, MAIN_X, startY + 11.5 * S * PT * 0.76 + i * 8.5 * S * PT * 1.2)); } 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 }); if (sub) write(doc, S, dry, cur, sub, { pt: 10, style: 'bold', color: C.company, x: ENTRY_X, width: ENTRY_W, factor: 1.25 }); if (note) write(doc, S, dry, cur, note, { pt: 9, style: 'italic', color: C.gray, x: ENTRY_X, width: ENTRY_W, factor: 1.25 }); 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 }); } cur.y += 5 * S; } function composeMainCV(doc, S, dry, cur, { cv }) { if (cv.berufserfahrung.length) { mainSection(doc, S, dry, cur, 'Berufserfahrung'); cv.berufserfahrung.forEach((e) => mainEntry(doc, S, dry, cur, { zeitraum: e.zeitraum, title: e.titel, sub: e.firma, desc: e.beschreibung, })); } if (cv.ausbildung.length) { mainSection(doc, S, dry, cur, 'Ausbildung'); cv.ausbildung.forEach((e) => mainEntry(doc, S, dry, cur, { zeitraum: e.zeitraum, title: e.abschluss, sub: e.institution, note: e.zusatz, })); } } function composeMainLetter(doc, S, dry, cur, { letter, header, job }) { const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); const betreff = letter.betreff || (job.stelle ? `Bewerbung als ${job.stelle}` : 'Bewerbung'); cur.y += 4 * S; if (job.firma) write(doc, S, dry, cur, job.firma, { pt: 10.5, style: 'bold', color: C.ink, x: MAIN_X, width: MAIN_W, factor: 1.25 }); if (job.ort) write(doc, S, dry, cur, job.ort, { pt: 10, color: C.gray, x: MAIN_X, width: MAIN_W, factor: 1.25 }); cur.y += 4 * S; const dateLine = header.ort ? `${header.ort}, den ${today}` : today; write(doc, S, dry, cur, dateLine, { pt: 10, color: C.gray, x: MAIN_X, width: MAIN_W, align: 'right', right: MAIN_R, factor: 1.2 }); cur.y += 4 * S; write(doc, S, dry, cur, betreff, { pt: 12, style: 'bold', color: C.ink, x: MAIN_X, width: MAIN_W, factor: 1.25 }); cur.y += 3.5 * S; if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.5, color: C.ink, x: MAIN_X, width: MAIN_W, factor: 1.35 }); cur.y += 2 * S; } letter.absaetze.forEach((p, i) => { if (i > 0) cur.y += 2.6 * S; write(doc, S, dry, cur, p, { pt: 10.5, color: C.ink, x: MAIN_X, width: MAIN_W, factor: 1.45 }); }); cur.y += 4.5 * S; if (letter.gruss) write(doc, S, dry, cur, letter.gruss, { pt: 10.5, color: C.ink, x: MAIN_X, width: MAIN_W, factor: 1.3 }); cur.y += 8 * S; write(doc, S, dry, cur, header.name, { pt: 10.5, style: 'bold', color: C.ink, x: MAIN_X, width: MAIN_W, factor: 1.2 }); } // ----- two-column page assembly (measure → scale → draw) ------------------- function renderTwoColumn(composeSide, composeMain) { const doc = new jsPDF({ unit: 'mm', format: 'a4' }); // measure both columns at scale 1 const sm = { y: TOP }; composeSide(doc, 1, true, sm); 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; if (need > avail) S = Math.max(MIN_SCALE, (avail / need) * 0.99); // 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')); } function buildHeader(settings, kontakt, headline) { return { name: (settings && settings.name) || '', adresse: (settings && settings.adresse) || '', headline, ...kontakt, }; } function renderLebenslaufPdf(cv, header) { return renderTwoColumn( (doc, S, dry, cur) => composeSidebar(doc, S, dry, cur, { header, cv }), (doc, S, dry, cur) => composeMainCV(doc, S, dry, cur, { cv }) ); } function renderAnschreibenPdf(letter, header, job) { return renderTwoColumn( (doc, S, dry, cur) => composeSidebar(doc, S, dry, cur, { header, cv: null }), (doc, S, dry, cur) => composeMainLetter(doc, S, dry, cur, { letter, header, job }) ); } // =========================================================================== // 3. Public entry point // =========================================================================== function hasAnschreiben(a) { return a && a.absaetze && a.absaetze.length > 0; } function hasLebenslauf(l) { return l && (l.berufserfahrung.length || l.ausbildung.length || l.kenntnisse.length); } async function generateApplicationDocuments({ job, basisDokumente, settings }) { const data = await generateTailoredTexts({ job, basisDokumente, settings }); const headline = data.headline || job.stelle || ''; const header = buildHeader(settings, data.kontakt, headline); const safe = (s) => String(s || '').replace(/[^a-zA-Z0-9äöüÄÖÜß _-]/g, '').replace(/\s+/g, '_').slice(0, 60) || 'Bewerbung'; const suffix = safe(job.firma || job.stelle || 'Bewerbung'); const label = job.stelle || job.firma || ''; const documents = []; if (hasAnschreiben(data.anschreiben)) { documents.push({ name: `Anschreiben – ${label}`.trim(), filename: `Anschreiben_${suffix}.pdf`, mime: 'application/pdf', buffer: renderAnschreibenPdf(data.anschreiben, header, job), }); } if (hasLebenslauf(data.lebenslauf)) { documents.push({ name: `Lebenslauf – ${label}`.trim(), filename: `Lebenslauf_${suffix}.pdf`, mime: 'application/pdf', buffer: renderLebenslaufPdf(data.lebenslauf, header), }); } if (documents.length === 0) { throw new Error('Die KI hat keine verwertbaren Unterlagen erzeugt.'); } return documents; } module.exports = { generateApplicationDocuments, generateTailoredTexts, renderLebenslaufPdf, renderAnschreibenPdf, };