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>
This commit is contained in:
+480
-102
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user