diff --git a/lib/documents.js b/lib/documents.js index e788406..c16ef41 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -1,5 +1,6 @@ // Document generation: tailors application documents to a job description with -// the help of an LLM (Ollama Cloud), then renders them to PDF. +// the help of an LLM (Ollama Cloud), then renders them as polished, single-page +// A4 PDFs (a modern CV and a clean DIN-style cover letter). // // The AI *rewrites* the user's own, previously provided base documents // (Basis-Dokumente) so the result stays grounded in real facts — it must not @@ -15,7 +16,77 @@ const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 180000); // ----- Ollama call --------------------------------------------------------- -// Returns { anschreiben: string, lebenslauf: string }. +// Structured schema the model must return. Everything is required (arrays may +// be empty) so the JSON shape is predictable. +const OUTPUT_SCHEMA = { + type: 'object', + properties: { + kontakt: { + type: 'object', + properties: { + email: { type: 'string' }, + telefon: { type: 'string' }, + ort: { type: 'string' }, + webseite: { type: 'string' }, + }, + required: ['email', 'telefon', 'ort', 'webseite'], + }, + 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: { + profil: { type: 'string' }, + berufserfahrung: { + type: 'array', + items: { + type: 'object', + properties: { + zeitraum: { type: 'string' }, + titel: { type: 'string' }, + firma: { type: 'string' }, + punkte: { type: 'array', items: { type: 'string' } }, + }, + required: ['zeitraum', 'titel', 'firma', 'punkte'], + }, + }, + ausbildung: { + type: 'array', + items: { + type: 'object', + properties: { + zeitraum: { type: 'string' }, + abschluss: { type: 'string' }, + institution: { type: 'string' }, + }, + required: ['zeitraum', 'abschluss', 'institution'], + }, + }, + kenntnisse: { type: 'array', items: { type: 'string' } }, + sprachen: { + type: 'array', + items: { + type: 'object', + properties: { sprache: { type: 'string' }, niveau: { type: 'string' } }, + required: ['sprache', 'niveau'], + }, + }, + }, + required: ['profil', 'berufserfahrung', 'ausbildung', 'kenntnisse', 'sprachen'], + }, + }, + required: ['kontakt', 'anschreiben', 'lebenslauf'], +}; + +// Returns the structured object { kontakt, anschreiben, lebenslauf }. // Throws if no API key is configured or the API call fails. async function generateTailoredTexts({ job, basisDokumente, settings }) { const apiKey = process.env.OLLAMA_API_KEY; @@ -47,46 +118,66 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) { job.firma ? `Unternehmen: ${job.firma}` : null, job.ort ? `Ort: ${job.ort}` : null, job.gehalt ? `Gehalt/Konditionen: ${job.gehalt}` : null, - job.quelle_url ? `Quelle: ${job.quelle_url}` : null, '', 'Stellenbeschreibung:', job.stellenbeschreibung || '(keine Beschreibung übermittelt)', ].filter((l) => l !== null).join('\n'); const system = - 'Du bist ein erfahrener Bewerbungscoach und schreibst professionelle, ' + + 'Du bist ein erfahrener Bewerbungscoach und erstellst professionelle, ' + 'deutschsprachige Bewerbungsunterlagen. Du passt die BEREITGESTELLTEN ' + 'Basis-Unterlagen des Bewerbers auf eine konkrete Stellenausschreibung an. ' + - 'Wichtigste Regel: Du erfindest KEINE Fakten, Qualifikationen, Abschlüsse ' + - 'oder Berufserfahrungen. Verwende ausschließlich Informationen, die in den ' + - 'Basis-Unterlagen des Bewerbers stehen. Du darfst umformulieren, ' + + 'Wichtigste Regel: Du erfindest KEINE Fakten, Qualifikationen, Abschlüsse, ' + + 'Kontaktdaten oder Berufserfahrungen. Verwende ausschließlich Informationen, ' + + 'die in den Basis-Unterlagen des Bewerbers stehen. Du darfst umformulieren, ' + 'gewichten, relevante Punkte hervorheben und auf die Stelle zuschneiden — ' + - 'aber nichts hinzudichten. Schreibe natürlich, überzeugend und ohne Floskeln.'; + 'aber nichts hinzudichten. Schreibe natürlich, konkret und ohne Floskeln.'; + + // An explicit skeleton with the EXACT keys — models that treat the schema as a + // hint still copy the key names reliably from a concrete example. + const skeleton = + `{\n` + + ` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "" },\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` + + ` "profil": "",\n` + + ` "berufserfahrung": [\n` + + ` { "zeitraum": "2018 – heute", "titel": "Jobtitel", "firma": "Arbeitgeber", "punkte": ["Aufgabe/Erfolg 1", "Aufgabe/Erfolg 2"] }\n` + + ` ],\n` + + ` "ausbildung": [\n` + + ` { "zeitraum": "2012 – 2015", "abschluss": "Abschluss", "institution": "Schule/Hochschule" }\n` + + ` ],\n` + + ` "kenntnisse": ["Skill 1", "Skill 2"],\n` + + ` "sprachen": [ { "sprache": "Deutsch", "niveau": "Muttersprache" } ]\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` + - `Erstelle zwei zugeschnittene Dokumente:\n` + - `1. "anschreiben": Ein vollständiges, individuelles Anschreiben für genau diese Stelle. ` + - `Beginne mit einer passenden Anrede und schließe mit einer Grußformel und dem Namen des Bewerbers. ` + - `Betone die zur Stellenbeschreibung passenden Stärken aus den Basis-Unterlagen. ` + - `Keine Platzhalter wie [Firma] — nutze die echten Angaben. Kein Briefkopf/Adressblock, nur der Brieftext.\n` + - `2. "lebenslauf": Eine auf die Stelle zugeschnittene, gut strukturierte Fassung des Lebenslaufs ` + - `(relevante Stationen und Kompetenzen zuerst), rein auf Basis der vorhandenen Angaben. ` + - `Falls keine Lebenslauf-Informationen in den Basis-Unterlagen vorhanden sind, gib einen leeren String zurück.\n` + - `Antworte ausschließlich im geforderten JSON-Format.`; - - // Structured output: ask Ollama to return exactly this JSON shape. - const format = { - type: 'object', - properties: { - anschreiben: { type: 'string' }, - lebenslauf: { type: 'string' }, - }, - required: ['anschreiben', 'lebenslauf'], - }; + `Erzeuge strukturierte Daten für ein Anschreiben und einen Lebenslauf, jeweils ` + + `passgenau auf diese Stelle zugeschnitten. Beide Dokumente werden auf JE EINER ` + + `A4-Seite gedruckt — halte dich daher kurz und relevant.\n\n` + + `Verwende EXAKT die folgende 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` + + `- kontakt: E-Mail, Telefon, Ort und Webseite NUR übernehmen, wenn sie in den ` + + `Basis-Unterlagen stehen; 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: max. 4 relevanteste Stationen (neueste zuerst), je max. 3 knappe Stichpunkte.\n` + + `- kenntnisse: max. 12 prägnante Schlagworte, relevanteste zuerst.\n` + + `- profil: 1–2 Sätze (optional, sonst leer). sprachen nur falls 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); @@ -102,7 +193,7 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) { body: JSON.stringify({ model: OLLAMA_MODEL, stream: false, - format, + format: OUTPUT_SCHEMA, options: { temperature: 0.4 }, messages: [ { role: 'system', content: system }, @@ -131,121 +222,403 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) { throw new Error('Die KI hat keine Antwort geliefert (leerer Inhalt).'); } + // Strip Markdown code fences some models wrap around JSON, then parse. + const cleaned = text.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim(); let parsed; try { - parsed = JSON.parse(text); + parsed = JSON.parse(cleaned); } catch (e) { - // Fallback: try to extract the first JSON object; otherwise treat the - // whole answer as the cover letter so nothing is lost. - const match = text.match(/\{[\s\S]*\}/); + const match = cleaned.match(/\{[\s\S]*\}/); if (match) { try { parsed = JSON.parse(match[0]); } catch (_) { /* ignore */ } } - if (!parsed) parsed = { anschreiben: text, lebenslauf: '' }; + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('Die KI-Antwort konnte nicht als JSON gelesen werden.'); } + return normalizeResult(parsed); +} + +// ----- tolerant normalisation ---------------------------------------------- +// Models don't always honour the exact schema keys, so accept common German +// synonyms and shapes and coerce everything into our predictable structure. + +const str = (v) => (typeof v === 'string' ? v.trim() : (v == null ? '' : String(v).trim())); +const asArray = (v) => (Array.isArray(v) ? v : []); + +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 []; +} +// Accept "2018 – heute" or { von, bis } style objects. +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 || parsed.anschreiben_daten || {}; + const l = parsed.lebenslauf || parsed.cv || parsed.resume || {}; + return { - anschreiben: (parsed.anschreiben || '').trim(), - lebenslauf: (parsed.lebenslauf || '').trim(), + 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'])), + }, + anschreiben: { + betreff: str(pick(a, ['betreff', 'subject', 'titel'])), + anrede: str(pick(a, ['anrede', 'salutation', 'gruss_anfang'])), + absaetze: pickArray(a, ['absaetze', 'absätze', 'paragraphs', 'text', 'inhalt', 'absaetze_text']) + .map(str).filter(Boolean), + gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])), + }, + lebenslauf: { + profil: str(pick(l, ['profil', 'kurzprofil', 'zusammenfassung', 'summary', 'ueberblick'])), + 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'])), + punkte: pickArray(e, ['punkte', 'aufgaben', 'taetigkeiten', 'highlights', 'schwerpunkte', 'technologien', 'details', 'beschreibung']) + .map(str).filter(Boolean), + })) + .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', 'ort', 'organisation'])), + })) + .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), + }, }; } -// ----- PDF rendering ------------------------------------------------------- +// =========================================================================== +// PDF rendering — modern, single-page A4 layout +// =========================================================================== -// Renders a text document to a PDF Buffer with a simple, clean A4 layout. -function textToPdf({ title, headerLines = [], bodyText = '' }) { - const doc = new jsPDF({ unit: 'mm', format: 'a4' }); - const pageWidth = doc.internal.pageSize.getWidth(); - const pageHeight = doc.internal.pageSize.getHeight(); - const marginX = 20; - const marginTop = 20; - const marginBottom = 20; - const maxWidth = pageWidth - marginX * 2; - let y = marginTop; +const PT_TO_MM = 0.352778; // 1 pt in millimetres - function ensureSpace(lineHeight) { - if (y + lineHeight > pageHeight - marginBottom) { - doc.addPage(); - y = marginTop; +// Restrained, professional palette (RGB). +const COLORS = { + ink: [30, 41, 59], // slate-800 — body text + sub: [71, 85, 105], // slate-600 — secondary + muted: [100, 116, 139], // slate-500 — meta + accent: [37, 99, 235], // blue-600 — name, section titles, accents + hair: [226, 232, 240], // slate-200 — hairlines + chipBg: [239, 246, 255],// blue-50 — skill chips +}; + +const PAGE = { w: 210, h: 297, marginX: 20, marginTop: 20, marginBottom: 18 }; +const CONTENT_W = PAGE.w - PAGE.marginX * 2; +const RIGHT = PAGE.w - PAGE.marginX; +const MIN_SCALE = 0.68; + +function oneLine(v) { + return String(v || '').replace(/\s*\n\s*/g, ', ').replace(/\s+/g, ' ').trim(); +} + +// A tiny layout helper bound to one jsPDF document + a vertical cursor. +// `scale` shrinks every font size and gap so the content fits one page; +// `dryRun` measures without drawing. +function makeWriter(doc, scale, dryRun) { + const st = { y: PAGE.marginTop }; + const S = scale; + + function font(style, pt) { + doc.setFont('helvetica', style); + doc.setFontSize(pt * S); + } + function color(c) { doc.setTextColor(c[0], c[1], c[2]); } + const lh = (pt, factor = 1.3) => pt * S * PT_TO_MM * factor; + const baseline = (pt) => pt * S * PT_TO_MM * 0.76; // approx ascent from top + + // Multi-line text block; returns nothing, advances the cursor. + function textBlock(text, { pt, style = 'normal', col = COLORS.ink, factor = 1.3, x = PAGE.marginX, width = CONTENT_W, align = 'left' } = {}) { + if (!text) return; + font(style, pt); + const lines = doc.splitTextToSize(String(text), width); + for (const line of lines) { + if (!dryRun) { + color(col); + const drawX = align === 'right' ? RIGHT : x; + doc.text(line, drawX, st.y + baseline(pt), { align }); + } + st.y += lh(pt, factor); } } - function writeBlock(text, { fontSize = 11, fontStyle = 'normal', gapAfter = 4, lineHeight } = {}) { - doc.setFont('helvetica', fontStyle); - doc.setFontSize(fontSize); - const lh = lineHeight || fontSize * 0.5; // mm per line - // Preserve intentional blank lines from the source text. - const paragraphs = String(text).split('\n'); - paragraphs.forEach((para) => { - if (para.trim() === '') { - ensureSpace(lh); - y += lh; - return; + function gap(mm) { st.y += mm * S; } + + function rule(col = COLORS.hair, weight = 0.3) { + if (!dryRun) { + doc.setDrawColor(col[0], col[1], col[2]); + doc.setLineWidth(weight * S); + doc.line(PAGE.marginX, st.y, RIGHT, st.y); + } + } + + // Header shared by both documents (name + contact + accent rule). + function header(name, contactParts) { + textBlock(name || '', { pt: 23, style: 'bold', col: COLORS.ink, factor: 1.05 }); + const contact = contactParts.map(oneLine).filter(Boolean).join(' · '); + if (contact) { + gap(1.3); + textBlock(contact, { pt: 9.5, col: COLORS.muted, factor: 1.25 }); + } + gap(2.6); + rule(COLORS.accent, 0.8); + gap(4.5); + } + + // Section heading: uppercase, tracked, accent, with a hairline beneath. + function section(title) { + gap(3.2); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(9.5 * S); + if (!dryRun) { + doc.setCharSpace(0.45 * S); + color(COLORS.accent); + doc.text(String(title).toUpperCase(), PAGE.marginX, st.y + baseline(9.5)); + doc.setCharSpace(0); + } + st.y += lh(9.5, 1.0); + gap(1.4); + rule(COLORS.hair, 0.3); + gap(3.2); + } + + // A title (bold) on the left and meta (muted) right-aligned on the same line. + function entryHead(title, meta) { + font('normal', 9.5); + const metaW = meta ? doc.getTextWidth(meta) : 0; + font('bold', 11); + const titleLines = doc.splitTextToSize(title || '', CONTENT_W - metaW - 4 * S); + const first = titleLines[0] || ''; + if (!dryRun) { + color(COLORS.ink); + doc.text(first, PAGE.marginX, st.y + baseline(11)); + if (meta) { + font('normal', 9.5); + color(COLORS.muted); + doc.text(meta, RIGHT, st.y + baseline(11), { align: 'right' }); } - const lines = doc.splitTextToSize(para, maxWidth); - lines.forEach((line) => { - ensureSpace(lh); - doc.text(line, marginX, y); - y += lh; + } + st.y += lh(11, 1.2); + } + + function bullets(items) { + const textX = PAGE.marginX + 4.5 * S; + const width = RIGHT - textX; + for (const item of items) { + font('normal', 9.7); + const lines = doc.splitTextToSize(item, width); + lines.forEach((line, idx) => { + if (!dryRun) { + if (idx === 0) { color(COLORS.accent); doc.text('•', PAGE.marginX + 1.2 * S, st.y + baseline(9.7)); } + color(COLORS.ink); + doc.text(line, textX, st.y + baseline(9.7)); + } + st.y += lh(9.7, 1.3); }); + } + } + + // Skill "chips": rounded pills that wrap to the content width. + function chips(items) { + const pt = 9; + font('normal', pt); + const padX = 2.6 * S; + const chipH = pt * S * PT_TO_MM + 2.8 * S; + const gapX = 2.2 * S; + const gapY = 2.2 * S; + let x = PAGE.marginX; + let rows = 1; + for (const item of items) { + const w = doc.getTextWidth(item) + padX * 2; + if (x + w > RIGHT && x > PAGE.marginX) { x = PAGE.marginX; st.y += chipH + gapY; rows++; } + if (!dryRun) { + doc.setFillColor(COLORS.chipBg[0], COLORS.chipBg[1], COLORS.chipBg[2]); + doc.roundedRect(x, st.y, w, chipH, chipH / 2, chipH / 2, 'F'); + color(COLORS.accent); + doc.text(item, x + padX, st.y + chipH / 2 + pt * S * PT_TO_MM * 0.34); + } + x += w + gapX; + } + st.y += chipH; + return rows; + } + + return { st, textBlock, gap, rule, header, section, entryHead, bullets, chips }; +} + +// Two-pass render: measure at scale 1, then draw scaled to fit one page. +function renderSinglePage(compose) { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }); + + const measured = makeWriter(doc, 1, true); + compose(measured); + const contentH = measured.st.y - PAGE.marginTop; + const availH = PAGE.h - PAGE.marginTop - PAGE.marginBottom; + let scale = 1; + if (contentH > availH) scale = Math.max(MIN_SCALE, (availH / contentH) * 0.99); + + const drawer = makeWriter(doc, scale, false); + compose(drawer); + + return Buffer.from(doc.output('arraybuffer')); +} + +function buildHeader(settings, kontakt) { + const name = (settings && settings.name) || ''; + const adresse = (settings && settings.adresse) || ''; + return { name, adresse, ...kontakt }; +} + +function contactParts(header) { + return [header.adresse, header.email, header.telefon, header.webseite].filter(Boolean); +} + +function renderLebenslaufPdf(cv, header) { + return renderSinglePage((w) => { + w.header(header.name, contactParts(header)); + + if (cv.profil) { + w.textBlock(cv.profil, { pt: 10, col: COLORS.sub, factor: 1.35 }); + w.gap(1.5); + } + + if (cv.berufserfahrung.length) { + w.section('Berufserfahrung'); + cv.berufserfahrung.forEach((e, i) => { + if (i > 0) w.gap(2.6); + w.entryHead(e.titel, e.zeitraum); + if (e.firma) w.textBlock(e.firma, { pt: 9.7, style: 'bold', col: COLORS.accent, factor: 1.2 }); + if (e.punkte.length) { w.gap(0.6); w.bullets(e.punkte); } + }); + } + + if (cv.ausbildung.length) { + w.section('Ausbildung'); + cv.ausbildung.forEach((e, i) => { + if (i > 0) w.gap(1.8); + w.entryHead(e.abschluss, e.zeitraum); + if (e.institution) w.textBlock(e.institution, { pt: 9.7, col: COLORS.muted, factor: 1.2 }); + }); + } + + if (cv.kenntnisse.length) { + w.section('Kenntnisse'); + w.chips(cv.kenntnisse); + w.gap(1); + } + + if (cv.sprachen.length) { + w.section('Sprachen'); + const line = cv.sprachen.map((s) => (s.niveau ? `${s.sprache} (${s.niveau})` : s.sprache)).join(' · '); + w.textBlock(line, { pt: 10, col: COLORS.ink, factor: 1.25 }); + } + }); +} + +function renderAnschreibenPdf(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'); + return renderSinglePage((w) => { + w.header(header.name, contactParts(header)); + + // Recipient block + if (job.firma) w.textBlock(job.firma, { pt: 10, style: 'bold', col: COLORS.ink, factor: 1.25 }); + if (job.ort) w.textBlock(job.ort, { pt: 10, col: COLORS.sub, factor: 1.25 }); + + // Date (right-aligned) + w.gap(3); + const dateLine = header.ort ? `${header.ort}, den ${today}` : today; + w.textBlock(dateLine, { pt: 10, col: COLORS.muted, align: 'right', factor: 1.2 }); + + // Subject + w.gap(4); + w.textBlock(betreff, { pt: 11.5, style: 'bold', col: COLORS.ink, factor: 1.25 }); + + // Salutation + body + w.gap(3.5); + if (letter.anrede) { w.textBlock(letter.anrede, { pt: 10.5, factor: 1.35 }); w.gap(2); } + letter.absaetze.forEach((p, i) => { + if (i > 0) w.gap(2.6); + w.textBlock(p, { pt: 10.5, col: COLORS.ink, factor: 1.42 }); }); - y += gapAfter; - } - if (title) { - writeBlock(title, { fontSize: 16, fontStyle: 'bold', gapAfter: 6 }); - } - if (headerLines.length) { - writeBlock(headerLines.join('\n'), { fontSize: 10, fontStyle: 'normal', gapAfter: 8 }); - } - if (bodyText) { - writeBlock(bodyText, { fontSize: 11, fontStyle: 'normal', gapAfter: 4, lineHeight: 6 }); - } + // Closing + name + w.gap(4); + if (letter.gruss) w.textBlock(letter.gruss, { pt: 10.5, factor: 1.3 }); + w.gap(7); + w.textBlock(header.name, { pt: 10.5, style: 'bold', col: COLORS.ink, factor: 1.2 }); + }); +} - const arrayBuffer = doc.output('arraybuffer'); - return Buffer.from(arrayBuffer); +// ----- 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 || l.profil); } // Build the list of attachment documents (PDF buffers) for a job. // Returns [{ name, filename, mime, buffer }]. async function generateApplicationDocuments({ job, basisDokumente, settings }) { - const { anschreiben, lebenslauf } = await generateTailoredTexts({ job, basisDokumente, settings }); + const data = await generateTailoredTexts({ job, basisDokumente, settings }); + const header = buildHeader(settings, data.kontakt); - const today = new Date().toLocaleDateString('de-DE'); - const bewerberName = (settings && settings.name) || ''; - const bewerberAdresse = (settings && settings.adresse) || ''; 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 (anschreiben) { - const headerLines = [ - bewerberName, - bewerberAdresse, - '', - job.firma || '', - job.ort || '', - '', - today, - '', - `Bewerbung als ${job.stelle || ''}`.trim(), - ].filter((l) => l !== null); + if (hasAnschreiben(data.anschreiben)) { documents.push({ - name: `Anschreiben – ${job.stelle || job.firma || ''}`.trim(), + name: `Anschreiben – ${label}`.trim(), filename: `Anschreiben_${suffix}.pdf`, mime: 'application/pdf', - buffer: textToPdf({ title: '', headerLines, bodyText: anschreiben }), + buffer: renderAnschreibenPdf(data.anschreiben, header, job), }); } - if (lebenslauf) { - const headerLines = [bewerberName, bewerberAdresse].filter(Boolean); + if (hasLebenslauf(data.lebenslauf)) { documents.push({ - name: `Lebenslauf – ${job.stelle || job.firma || ''}`.trim(), + name: `Lebenslauf – ${label}`.trim(), filename: `Lebenslauf_${suffix}.pdf`, mime: 'application/pdf', - buffer: textToPdf({ title: 'Lebenslauf', headerLines, bodyText: lebenslauf }), + buffer: renderLebenslaufPdf(data.lebenslauf, header), }); } @@ -256,4 +629,9 @@ async function generateApplicationDocuments({ job, basisDokumente, settings }) { return documents; } -module.exports = { generateApplicationDocuments, generateTailoredTexts, textToPdf }; +module.exports = { + generateApplicationDocuments, + generateTailoredTexts, + renderLebenslaufPdf, + renderAnschreibenPdf, +};