Files
jobbi-bewerbung/lib/documents.js
T
thomasandClaude Opus 4.8 27734c77e5 Redesign generated PDFs: structured AI output + single-page A4 layout
- AI now returns structured data (contact, cover-letter paragraphs, CV
  experience/education/skills/languages) instead of flowing text.
- Modern CV renderer: header with name + contact, tracked section titles with
  hairlines, entries with right-aligned dates and bullets, skill chips, languages.
- Clean DIN-style cover letter with matching header, recipient block,
  right-aligned date, subject, salutation, body, closing.
- Auto-fit engine scales fonts/spacing so each document always fits one A4 page.
- Tolerant parsing (key synonyms, object date ranges, markdown fences) + an
  explicit JSON skeleton in the prompt, since the model honours the schema loosely.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:45:25 +02:00

638 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Document generation: tailors application documents to a job description with
// 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
// invent experience the applicant doesn't have.
const { jsPDF } = require('jspdf');
// Ollama Cloud API (https://ollama.com). Override host/model via env if needed
// (e.g. point OLLAMA_HOST at a local Ollama instance).
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);
// ----- Ollama call ---------------------------------------------------------
// 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;
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 Bewerbungsunterlagen. 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, ' +
'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, 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` +
`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: 34 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: 12 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);
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).');
}
// 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(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 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 {
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 — modern, single-page A4 layout
// ===========================================================================
const PT_TO_MM = 0.352778; // 1 pt in millimetres
// 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 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' });
}
}
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 });
});
// 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 });
});
}
// ----- 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 data = await generateTailoredTexts({ job, basisDokumente, settings });
const header = buildHeader(settings, data.kontakt);
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,
};