Files
jobbi-bewerbung/lib/documents.js
T
thomasandClaude Opus 4.8 cd66e2ef4c Restyle PDFs as a two-column template (dark sidebar + main column)
- Fixed, high-quality CV/cover-letter template modelled on the user's reference:
  navy sidebar (name, headline, contact, skills with bullets, languages, hobbys)
  and a white main column with accent-underlined section headings and dated
  entries (date gutter, bold title, blue company line, description).
- The LLM now supplies only the text; layout/design is owned by the renderer.
- Extended the extracted fields with headline, birth date, driving licence and
  hobbys (grounded in the base documents, empty when absent).
- Two-column auto-fit keeps each document on a single A4 page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:13:17 +02:00

619 lines
25 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.
//
// 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: 34 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,
};