From cd66e2ef4c3f599dd8f0938a0f55a4e0e80fe574 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Fri, 3 Jul 2026 10:13:17 +0200 Subject: [PATCH] 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 --- lib/documents.js | 591 +++++++++++++++++++++++------------------------ 1 file changed, 286 insertions(+), 305 deletions(-) diff --git a/lib/documents.js b/lib/documents.js index c16ef41..a666353 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -1,26 +1,25 @@ -// 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). +// Document generation. // -// 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. +// 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 -// (e.g. point OLLAMA_HOST at a local Ollama instance). +// 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); -// ----- Ollama call --------------------------------------------------------- +// =========================================================================== +// 1. LLM call — produce tailored TEXT for the template +// =========================================================================== -// 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: { + headline: { type: 'string' }, kontakt: { type: 'object', properties: { @@ -28,8 +27,10 @@ const OUTPUT_SCHEMA = { telefon: { type: 'string' }, ort: { type: 'string' }, webseite: { type: 'string' }, + geburtsdatum: { type: 'string' }, + fuehrerschein: { type: 'string' }, }, - required: ['email', 'telefon', 'ort', 'webseite'], + required: ['email', 'telefon', 'ort', 'webseite', 'geburtsdatum', 'fuehrerschein'], }, anschreiben: { type: 'object', @@ -44,7 +45,6 @@ const OUTPUT_SCHEMA = { lebenslauf: { type: 'object', properties: { - profil: { type: 'string' }, berufserfahrung: { type: 'array', items: { @@ -53,9 +53,9 @@ const OUTPUT_SCHEMA = { zeitraum: { type: 'string' }, titel: { type: 'string' }, firma: { type: 'string' }, - punkte: { type: 'array', items: { type: 'string' } }, + beschreibung: { type: 'string' }, }, - required: ['zeitraum', 'titel', 'firma', 'punkte'], + required: ['zeitraum', 'titel', 'firma', 'beschreibung'], }, }, ausbildung: { @@ -66,8 +66,9 @@ const OUTPUT_SCHEMA = { zeitraum: { type: 'string' }, abschluss: { type: 'string' }, institution: { type: 'string' }, + zusatz: { type: 'string' }, }, - required: ['zeitraum', 'abschluss', 'institution'], + required: ['zeitraum', 'abschluss', 'institution', 'zusatz'], }, }, kenntnisse: { type: 'array', items: { type: 'string' } }, @@ -79,15 +80,14 @@ const OUTPUT_SCHEMA = { required: ['sprache', 'niveau'], }, }, + hobbys: { type: 'array', items: { type: 'string' } }, }, - required: ['profil', 'berufserfahrung', 'ausbildung', 'kenntnisse', 'sprachen'], + required: ['berufserfahrung', 'ausbildung', 'kenntnisse', 'sprachen', 'hobbys'], }, }, - required: ['kontakt', 'anschreiben', 'lebenslauf'], + required: ['headline', '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) { @@ -125,19 +125,17 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) { 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.'; + '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.'; - // 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` + + ` "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` + @@ -145,15 +143,15 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) { ` "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` + + ` { "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" }\n` + + ` { "zeitraum": "2012 – 2015", "abschluss": "Abschluss", "institution": "Schule/Hochschule", "zusatz": "" }\n` + ` ],\n` + - ` "kenntnisse": ["Skill 1", "Skill 2"],\n` + - ` "sprachen": [ { "sprache": "Deutsch", "niveau": "Muttersprache" } ]\n` + + ` "kenntnisse": ["Fähigkeit 1", "Fähigkeit 2"],\n` + + ` "sprachen": [ { "sprache": "Deutsch", "niveau": "Muttersprache" } ],\n` + + ` "hobbys": ["Hobby 1", "Hobby 2"]\n` + ` }\n` + `}`; @@ -162,20 +160,23 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) { `# 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 ` + + `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` + - `- 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` + + `- headline: kurze Berufsbezeichnung des Bewerbers, ggf. auf die Zielstelle zugeschnitten.\n` + + `- kontakt: E-Mail, Telefon, Ort, Webseite, Geburtsdatum, Führerschein NUR übernehmen, ` + + `wenn in den Basis-Unterlagen vorhanden; sonst leerer String. Nichts erfinden.\n` + + `- anschreiben.absaetze: 3–4 kurze, überzeugende Absätze (kein Adressblock, kein Datum, ` + + `keine Grußformel hier). Keine Platzhalter — echte Angaben nutzen.\n` + + `- berufserfahrung: relevanteste Stationen (neueste zuerst). "firma" = "Arbeitgeber, Ort". ` + + `"beschreibung" = optional EIN kurzer Satz (sonst leer).\n` + + `- ausbildung: relevante Abschlüsse/Stationen. "zusatz" optional (z. B. "Abschluss: …" oder "nicht beendet").\n` + + `- kenntnisse: max. 12 prägnante Stichworte, relevanteste zuerst.\n` + + `- sprachen & hobbys: nur falls in den Unterlagen vorhanden.\n` + `Leere Felder als leerer String bzw. leeres Array. Antworte AUSSCHLIESSLICH mit dem ` + `JSON-Objekt, ohne Markdown, ohne Code-Fences, ohne weiteren Text.`; @@ -186,10 +187,7 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) { try { res = await fetch(`${OLLAMA_HOST}/api/chat`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ model: OLLAMA_MODEL, stream: false, @@ -218,20 +216,15 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) { 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).'); - } + 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 (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.'); @@ -241,27 +234,20 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) { } // ----- 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. +// 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())); -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]; - } + 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]; - } + 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(); @@ -275,39 +261,40 @@ function toZeitraum(v) { function normalizeResult(parsed) { const k = parsed.kontakt || parsed.contact || {}; - const a = parsed.anschreiben || parsed.cover_letter || parsed.anschreiben_daten || {}; + 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', 'gruss_anfang'])), - absaetze: pickArray(a, ['absaetze', 'absätze', 'paragraphs', 'text', 'inhalt', 'absaetze_text']) - .map(str).filter(Boolean), + 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: { - 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), + 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', 'ort', 'organisation'])), + 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']) @@ -318,285 +305,279 @@ function normalizeResult(parsed) { ? { 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), }, }; } // =========================================================================== -// PDF rendering — modern, single-page A4 layout +// 2. PDF template — fixed two-column design (sidebar + main column) // =========================================================================== -const PT_TO_MM = 0.352778; // 1 pt in millimetres +const PT = 0.352778; // pt → mm -// 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 }; +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], }; -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(); +// 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); } -// 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 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); +} - function font(style, pt) { - doc.setFont('helvetica', style); - doc.setFontSize(pt * S); +// ----- 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 }); } - 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); - } + 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 }); } - function gap(mm) { st.y += mm * S; } + 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; + }; - 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); - } + // 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; + }); } - // 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); + 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; + }); } - // 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)); + 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); } - 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; + cur.y += 9 * S * PT * 1.4; + }); } - return { st, textBlock, gap, rule, header, section, entryHead, bullets, chips }; + 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 }); + } } -// Two-pass render: measure at scale 1, then draw scaled to fit one page. -function renderSinglePage(compose) { +// ----- 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' }); - 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); + // 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); - const drawer = makeWriter(doc, scale, false); - compose(drawer); + // 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) { - 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 buildHeader(settings, kontakt, headline) { + return { + name: (settings && settings.name) || '', + adresse: (settings && settings.adresse) || '', + headline, + ...kontakt, + }; } 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 }); - } - }); + 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) { - 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 }); - }); + 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 }) + ); } -// ----- Public entry point -------------------------------------------------- +// =========================================================================== +// 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 || l.profil); + return l && (l.berufserfahrung.length || l.ausbildung.length || l.kenntnisse.length); } -// 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 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');