Die Abschnittsueberschriften wurden mit align:'center' zentriert. jsPDF misst
dafuer mit getTextWidth(), das den charSpace ignoriert, rendert den Text aber
gesperrt - er lief nach rechts, waehrend die Herzen symmetrisch sassen. Ergebnis:
links sichtbar mehr Luft als rechts. Die Ueberschrift wird jetzt selbst zentriert,
gemessen an der tatsaechlichen Tinte (Sperrung zaehlt nur zwischen den Glyphen,
nicht nach dem letzten), und die Herzen haengen an den echten Textkanten.
Kontaktdaten bekommen passende Icons: Umschlag, Telefon, Map-Pin, Globus,
Kalender, Ausweis. Als Vektoren gezeichnet, weil kein Icon-Glyph in der Schrift
vorausgesetzt werden kann; sie skalieren mit der Seitenskalierung mit.
buildContactLines liefert dafuer jetzt {typ, text} - die klassische Sidebar nutzt
weiterhin nur den Text und bleibt unveraendert schlicht.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2025 lines
91 KiB
JavaScript
2025 lines
91 KiB
JavaScript
// Document generation.
|
||
//
|
||
// Architecture: the *design* lives here as a fixed, high-quality template
|
||
// (clean monochrome single-column layout for both cover letter and résumé,
|
||
// reverse-chronological, DIN-ish margins). 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');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const promptStore = require('./prompts');
|
||
const designStore = require('./design');
|
||
|
||
// Embedded typefaces (all SIL Open Font License — see lib/fonts/OFL*.txt):
|
||
// Lato — the classic layout's workhorse
|
||
// Poppins — geometric sans for the playful "social" layout
|
||
// Pacifico — script face for that layout's handwritten line
|
||
// Read once at module load; only the faces a layout actually uses are registered
|
||
// on its document, so a classic CV never carries the playful fonts' weight.
|
||
const FONT_FILES = {
|
||
'Lato-Regular.ttf': ['Lato', 'normal'],
|
||
'Lato-Bold.ttf': ['Lato', 'bold'],
|
||
'Poppins-Regular.ttf': ['Poppins', 'normal'],
|
||
'Poppins-Bold.ttf': ['Poppins', 'bold'],
|
||
'Pacifico-Regular.ttf': ['Pacifico', 'normal'],
|
||
};
|
||
const FONT_B64 = Object.fromEntries(
|
||
Object.keys(FONT_FILES).map((f) => [f, fs.readFileSync(path.join(__dirname, 'fonts', f)).toString('base64')])
|
||
);
|
||
|
||
// `t` = the resolved theme (lib/design.js). Its `fonts` decide what gets
|
||
// embedded. The chosen families are stashed on the document itself (not in a
|
||
// module variable) so two renders can never race each other's typeface.
|
||
function makeDoc(t) {
|
||
const sans = (t && t.fonts && t.fonts.sans) || 'Lato';
|
||
const script = (t && t.fonts && t.fonts.script) || null;
|
||
const doc = new jsPDF({ unit: 'mm', format: 'a4' });
|
||
for (const [file, [family, style]] of Object.entries(FONT_FILES)) {
|
||
if (family !== sans && family !== script) continue;
|
||
doc.addFileToVFS(file, FONT_B64[file]);
|
||
doc.addFont(file, family, style);
|
||
}
|
||
doc.__sans = sans;
|
||
doc.__script = script || sans;
|
||
doc.setFont(sans);
|
||
return doc;
|
||
}
|
||
|
||
// 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 || 300000);
|
||
|
||
// ===========================================================================
|
||
// 1. LLM call — produce tailored TEXT for the template
|
||
// ===========================================================================
|
||
|
||
// Reused shape for every education-type section (Studium, Berufsausbildung, …).
|
||
const EDU_ITEM = {
|
||
type: 'object',
|
||
properties: {
|
||
zeitraum: { type: 'string' },
|
||
abschluss: { type: 'string' },
|
||
institution: { type: 'string' },
|
||
zusatz: { type: 'string' },
|
||
},
|
||
required: ['zeitraum', 'abschluss', 'institution', 'zusatz'],
|
||
};
|
||
|
||
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' },
|
||
},
|
||
required: ['email', 'telefon', 'ort', 'webseite', 'geburtsdatum'],
|
||
},
|
||
anschreiben: {
|
||
type: 'object',
|
||
properties: {
|
||
empfaenger: {
|
||
type: 'object',
|
||
properties: {
|
||
firma: { type: 'string' },
|
||
adresse: { type: 'string' },
|
||
ort: { type: 'string' },
|
||
ansprechpartner: { type: 'string' },
|
||
},
|
||
required: ['firma', 'adresse', 'ort', 'ansprechpartner'],
|
||
},
|
||
betreff: { type: 'string' },
|
||
anrede: { type: 'string' },
|
||
absaetze: { type: 'array', items: { type: 'string' } },
|
||
gruss: { type: 'string' },
|
||
},
|
||
required: ['empfaenger', 'betreff', 'anrede', 'absaetze', 'gruss'],
|
||
},
|
||
email: {
|
||
type: 'object',
|
||
properties: {
|
||
betreff: { type: 'string' },
|
||
text: { type: 'string' },
|
||
},
|
||
required: ['betreff', 'text'],
|
||
},
|
||
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'],
|
||
},
|
||
},
|
||
studium: { type: 'array', items: EDU_ITEM },
|
||
berufsausbildung: { type: 'array', items: EDU_ITEM },
|
||
weiterbildungen: { type: 'array', items: EDU_ITEM },
|
||
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: ['profil', 'berufserfahrung', 'studium', 'berufsausbildung', 'weiterbildungen', 'kenntnisse', 'sprachen', 'hobbys'],
|
||
},
|
||
},
|
||
required: ['headline', 'kontakt', 'anschreiben', 'email', 'lebenslauf'],
|
||
};
|
||
|
||
async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '', zusatzAnlagen = [], prompts = null }) {
|
||
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');
|
||
|
||
// Role, tone and rules come from the (editable) prompt store; the JSON skeleton
|
||
// below stays in code because the response parsing depends on it.
|
||
const system = promptStore.get(prompts, 'unterlagen');
|
||
|
||
const skeleton =
|
||
`{\n` +
|
||
` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)",\n` +
|
||
` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "" },\n` +
|
||
` "anschreiben": {\n` +
|
||
` "empfaenger": { "firma": "Unternehmen", "adresse": "Straße Hausnr. (falls bekannt, sonst leer)", "ort": "PLZ Ort", "ansprechpartner": "" },\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` +
|
||
` "email": {\n` +
|
||
` "betreff": "Bewerbung als … (ggf. mit Referenz-/Kennnummer)",\n` +
|
||
` "text": "Sehr kurzer Begleittext nur für die E-Mail: Anrede, ein Satz (Stelle + Unterlagen im Anhang), optional ein kurzer Schlusssatz, Grußformel, Name - jeweils durch Leerzeile getrennt"\n` +
|
||
` },\n` +
|
||
` "lebenslauf": {\n` +
|
||
` "profil": "2-3 Sätze Kurzprofil, zugeschnitten auf die Zielstelle",\n` +
|
||
` "berufserfahrung": [\n` +
|
||
` { "zeitraum": "02/2025 - heute", "titel": "Jobtitel", "firma": "Arbeitgeber, Ort", "punkte": ["Aufgabe/Erfolg 1", "Aufgabe/Erfolg 2"] }\n` +
|
||
` ],\n` +
|
||
` "studium": [\n` +
|
||
` { "zeitraum": "10/2010 - 03/2017", "abschluss": "Studiengang", "institution": "Hochschule", "zusatz": "" }\n` +
|
||
` ],\n` +
|
||
` "berufsausbildung": [\n` +
|
||
` { "zeitraum": "08/2006 - 07/2009", "abschluss": "Ausbildungsberuf / Abschluss", "institution": "Betrieb/Berufskolleg", "zusatz": "" }\n` +
|
||
` ],\n` +
|
||
` "weiterbildungen": [],\n` +
|
||
` "kenntnisse": ["Fähigkeit 1", "Fähigkeit 2"],\n` +
|
||
` "sprachen": [ { "sprache": "Deutsch", "niveau": "Muttersprache" }, { "sprache": "Englisch", "niveau": "verhandlungssicher" } ],\n` +
|
||
` "hobbys": ["Hobby 1", "Hobby 2"]\n` +
|
||
` }\n` +
|
||
`}`;
|
||
|
||
const notizenText = (llmNotizen && llmNotizen.trim())
|
||
? `# Zusätzliche Notizen des Bewerbers (WICHTIG — nutze diese Angaben aktiv: z. B. vollständige ` +
|
||
`Firmenanschrift, Ansprechpartner, besondere Hinweise zur Bewerbung oder zum Unternehmen)\n` +
|
||
`${llmNotizen.trim()}\n\n`
|
||
: '';
|
||
|
||
// Attachments actually enclosed with this application. The Lebenslauf is
|
||
// always part of the set; the extra items are the ones the user selected.
|
||
const anlagenListe = ['Lebenslauf', ...(zusatzAnlagen || []).filter(Boolean)];
|
||
const anlagenText =
|
||
`# Beigefügte Anlagen (genau diese Dokumente liegen der Bewerbung bei)\n` +
|
||
anlagenListe.map((a) => `- ${a}`).join('\n') + `\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` +
|
||
notizenText +
|
||
anlagenText +
|
||
`# 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 (deutscher Lebenslauf nach aktueller Norm/Best Practice):\n` +
|
||
`- headline: kurze, sachliche Berufsbezeichnung des Bewerbers, ggf. auf die Zielstelle zugeschnitten. ` +
|
||
`NIEMALS eine geschützte oder formal an eine Ausbildung/Prüfung/einen Studienabschluss gebundene ` +
|
||
`Berufsbezeichnung verwenden, die der Bewerber laut Unterlagen nicht nachweislich erworben hat ` +
|
||
`(z. B. "Fachinformatiker", "Ingenieur", "Techniker", "Meister", "Kaufmann/-frau", "Geselle", ` +
|
||
`"Facharzt", "Bachelor/Master", "Dipl.-..." u. Ä.). Im Zweifel eine neutrale, nicht geschützte ` +
|
||
`Tätigkeitsbezeichnung wählen (z. B. "Softwareentwickler", "IT-Fachkraft", "Anwendungsentwickler") ` +
|
||
`statt eines geschützten Titels.\n` +
|
||
`- kontakt: E-Mail, Telefon, Ort, Webseite, Geburtsdatum NUR übernehmen, wenn in den ` +
|
||
`Basis-Unterlagen vorhanden; sonst leerer String. Nichts erfinden. Geburtsdatum als ` +
|
||
`DD.MM.YYYY (z. B. "03.04.1990"). Keine anderen persönlichen Angaben (Führerschein, ` +
|
||
`Familienstand, Nationalität o. Ä.) - diese Felder bleiben leer.\n` +
|
||
`- anschreiben.empfaenger: Empfängerblock. firma = Unternehmen; adresse = Straße + Hausnr. ` +
|
||
`(nur falls aus Stellenanzeige oder Notizen bekannt, sonst leer); ort = "PLZ Ort"; ` +
|
||
`ansprechpartner = konkrete Kontaktperson (nur falls bekannt, sonst leer). Nutze eine in den ` +
|
||
`Notizen angegebene vollständige Firmenanschrift und einen genannten Ansprechpartner. ` +
|
||
`Ist ein Ansprechpartner bekannt, passe die Anrede an - aber NUR als "Sehr geehrte Frau <Nachname>," ` +
|
||
`bzw. "Sehr geehrter Herr <Nachname>," mit ausschließlichem NACHNAMEN, niemals mit Vorname oder ` +
|
||
`vollem Namen (also "Sehr geehrte Frau Müller," nicht "Sehr geehrte Frau Anna Müller,"). Verwende ` +
|
||
`den VOLLSTÄNDIGEN Nachnamen inklusive aller Namensbestandteile wie "Al", "El", "van", "von", "de" ` +
|
||
`(z. B. "Sehr geehrte Frau Al Haruni," nicht "Sehr geehrte Frau Haruni,"). Steht in den Unterlagen ` +
|
||
`ein voller Name (z. B. "Omaima Arrami Al Haruni"), verwende in der Anrede nur den Nachnamen ` +
|
||
`("Frau Al Haruni"). Der ansprechpartner-Wert im JSON darf der volle Name sein (für "z. Hd. …").\n` +
|
||
`- anschreiben.absaetze: 3–4 kurze, überzeugende Absätze (kein Adressblock, kein Datum, ` +
|
||
`keine Grußformel hier). Sachlich und positiv, in Aktiv-Sätzen, ohne Floskeln und ` +
|
||
`Weichspüler ("Ich würde gerne …"). KEINE Gehaltsvorstellungen, KEINE Kündigungsgründe, ` +
|
||
`KEINE Erwähnung von Lücken/Arbeitslosigkeit, keine Selbstzweifel. Keine Platzhalter — echte Angaben nutzen. ` +
|
||
`Nenne den AKTUELLEN Arbeitgeber NIE beim Namen im Anschreiben (keine Firmenbezeichnung wie z. B. ` +
|
||
`"IT-Problemlöser GmbH") — beschreibe höchstens die Tätigkeit/Rolle allgemein, falls überhaupt relevant. ` +
|
||
`Der Lebenslauf nennt die Arbeitgeber wie gehabt; diese Regel gilt nur für das Anschreiben. ` +
|
||
`Berücksichtige die beigefügten Anlagen (siehe Abschnitt "Beigefügte Anlagen"): Du darfst im Text ` +
|
||
`natürlich auf relevante Anlagen verweisen (z. B. "wie meine beigefügten Arbeitszeugnisse zeigen"), ` +
|
||
`aber erfinde KEINE Anlagen, die dort nicht aufgeführt sind, und stütze keine Aussage auf einen Nachweis, ` +
|
||
`der nicht beiliegt. Erstelle KEINE eigene "Anlagen:"-Auflistung im Text — die Anlagenliste wird separat erzeugt. ` +
|
||
`Nur der ERSTE Absatz beginnt mit einem KLEINbuchstaben (die Anrede endet mit Komma, der Satz wird ` +
|
||
`fortgesetzt), z. B. "mit …" oder "als …" — außer das erste Wort ist ein Substantiv, ein Eigenname oder die ` +
|
||
`Höflichkeitsform "Ihre/Ihr/Ihnen". Alle WEITEREN Absätze sind neue Sätze und beginnen normal mit GROSSbuchstaben.\n` +
|
||
`- email: ein SEHR KURZER Begleittext für die E-Mail, mit der die Bewerbung samt Anhängen ` +
|
||
`(Lebenslauf, Anschreiben, ggf. Zeugnisse) versendet wird. Es ist NUR die E-Mail-Begleitnotiz, NICHT das ` +
|
||
`vollständige Anschreiben - dessen Inhalt NICHT wiederholen. Ein HR-Experte überfliegt diese E-Mail im ` +
|
||
`Vorbeigehen: max. 3 Sätze gesamt, auf das absolute Minimum reduziert. KEINE Motivationssätze, KEINE ` +
|
||
`Qualifikationsauflistungen, KEINE Wiederholung des Anschreibens. ` +
|
||
`email.betreff: "Bewerbung als <Stelle>" (ggf. Referenz-/Kennnummer anhängen). ` +
|
||
`email.text: sofort versendbar, exakt diese Struktur - (1) dieselbe Anrede wie im Anschreiben; ` +
|
||
`(2) ein Satz: auf welche Stelle man sich bewirbt UND dass die vollständigen Bewerbungsunterlagen im Anhang ` +
|
||
`beiliegen; (3) ein kurzer, freundlicher Schlusssatz mit Ausblick auf ein persönliches Gespräch; ` +
|
||
`(4) Grußformel "Mit freundlichen Grüßen"; (5) vollständiger Name in der letzten Zeile. ` +
|
||
`Trenne Anrede, Text, Grußformel und Name jeweils durch eine Leerzeile (\\n\\n). ` +
|
||
`Höflich, sachlich, in Aktiv-Sätzen, ohne Floskeln und Weichspüler, keine Platzhalter.\n` +
|
||
`- profil: 2–3 Sätze Kurzprofil, konkret auf die Zielstelle zugeschnitten (der individuelle Text zur Stelle).\n` +
|
||
`- Stil/Sprache (gilt für Anschreiben, Profil UND E-Mail): Schreibe so natürlich, wie ein deutscher ` +
|
||
`Muttersprachler tatsächlich schreibt - konkrete Verben, kurze klare Sätze. KEINE leeren Floskeln oder ` +
|
||
`Buzzword-Paare (also NICHT "serviceorientiert und klar", "motiviert und zuverlässig", "teamfähig und ` +
|
||
`belastbar", "kommuniziere ich serviceorientiert und klar - sowohl mit Anwendern als auch im Team"). Jede ` +
|
||
`genannte Eigenschaft MUSS aus einer konkreten Tätigkeit oder einem Ergebnis hervorgehen, sonst weglassen. ` +
|
||
`KEINE Meta-Aussagen über die eigene Person oder Sprache wie "Als Muttersprachler ..." - Sprachkenntnisse ` +
|
||
`gehören ausschließlich in die Sprachen-Sektion des Lebenslaufs, niemals in den Fließtext. Formuliere aus ` +
|
||
`der konkreten Aufgabe heraus statt über abstrakte Selbstetiketten.\n` +
|
||
`- berufserfahrung: ALLE Stationen aus den Unterlagen, neueste zuerst — lückenloser Verlauf, ` +
|
||
`keine Station weglassen, die sonst eine Lücke hinterlässt (Überschneidungen sind ok). ` +
|
||
`"firma" = "Arbeitgeber, Ort". Je jünger/relevanter eine Station, desto mehr Stichpunkte: die ` +
|
||
`2–3 jüngsten erhalten je 2–3 knappe "punkte", mittelalte 0–1 Stichpunkt, ältere ein leeres ` +
|
||
`Array (nur noch Zeile mit Zeitraum, Titel, Firma). Irrelevante alte Stationen dürfen als ` +
|
||
`letzte knappe Zeile stehenbleiben, wenn sie für einen lückenlosen Verlauf nötig sind — nicht ausschmücken.\n` +
|
||
`- Bildung: Hochschulstudium → studium; Berufsausbildung/Ausbildungsberuf → berufsausbildung; ` +
|
||
`Zertifikate/Fortbildungen → weiterbildungen. Schulabschlüsse (Mittlere Reife, Fachoberschulreife, ` +
|
||
`Fachhochschulreife, Abitur u. ä.) NICHT in den Lebenslauf aufnehmen — komplett weglassen. ` +
|
||
`Grundschule ebenfalls weglassen.\n` +
|
||
`- Studium ohne Abschluss: ehrlich, aber neutral als Eintrag in "studium" führen — NIE einen ` +
|
||
`akademischen Grad voransetzen (kein "B.Sc.", "M.Sc.", "Diplom", "Dr." o. Ä.), der nicht erworben ` +
|
||
`wurde. "abschluss" = reiner Studiengangname (z. B. "Praktische Informatik"), "zusatz" bleibt leer ` +
|
||
`(kein "ohne Abschluss"/"nicht beendet"/"abgebrochen"). Ein kurzer, sachlicher Eintrag reicht; ` +
|
||
`bei vorhandener Berufserfahrung trägt diese den Lebenslauf, das Studium ist eine Nebenzeile.\n` +
|
||
`- Nur auf die Stelle bezogene Inhalte: alles weglassen, was nicht zur Zielstelle passt.\n` +
|
||
`- kenntnisse: NUR Fähigkeiten, die für DIESE Stelle wirklich NOTWENDIG sind — d. h. die in der ` +
|
||
`Stellenanzeige gefordert werden oder ohne die die Position faktisch nicht auszufüllen ist. Keine ` +
|
||
`"nice-to-have"- oder Allgemein-Plätze (z. B. keine "Teamfähigkeit", "MS Office", "Zuverlässigkeit"), ` +
|
||
`kein Auffüllen. Lieber 4–6 konkrete, harte Pflichtfähigkeiten als 12 weiche. Jedes Stichwort muss ` +
|
||
`konkret benennbar sein (Technologie, Methode, Werkzeug, Zertifizierung) und aus den Basis-Unterlagen ` +
|
||
`belegbar bleiben — nichts erfinden. Reihenfolge: geforderte Pflichtfähigkeiten zuerst.\n` +
|
||
`- sprachen: IMMER aus den Basis-Unterlagen übernehmen (Sprachkenntnisse gehören in jeden ` +
|
||
`deutschen Lebenslauf, unabhängig davon, ob die Stelle sie explizit fordert). Deutsch als ` +
|
||
`Muttersprache sowie alle weiteren dort genannten Sprachen — insbesondere Englisch — ` +
|
||
`vollständig aufführen, mit Niveau laut Unterlagen. Nichts erfinden, nichts weglassen.\n` +
|
||
`- weiterbildungen: NUR falls in den Unterlagen vorhanden UND als Anforderung in der ` +
|
||
`Stellenanzeige genannt bzw. für die Stelle zwingend. Sonst weglassen.\n` +
|
||
`- hobbys: NUR aufnehmen, wenn sie direkt für den Beruf nützlich sind (z. B. Open-Source-Engagement, ` +
|
||
`vereinsinterne IT/Verwaltung, ehrenamtliche Projektarbeit mit Bezug zur Stelle); sonst komplett weglassen.\n\n` +
|
||
`WICHTIG — keine HR-Red-Flags:\n` +
|
||
`- Lückenloser Zeitverlauf: übernimm ALLE Zeiträume aus den Unterlagen, sodass keine unerklärten ` +
|
||
`Lücken entstehen. Lasse keine Stationen weg, die sonst eine Lücke hinterlassen (Überschneidungen sind ok).\n` +
|
||
`- Keine negativen/entwertenden Vermerke: NIEMALS "nicht beendet", "abgebrochen", "ohne Abschluss", ` +
|
||
`"arbeitslos", "arbeitssuchend", "gekündigt" o. Ä. Lasse "zusatz" in solchen Fällen leer und nenne nur ` +
|
||
`Zeitraum, Bezeichnung und Institution neutral.\n` +
|
||
`- Sachlich, positiv, konsistente Datumsformate (MM/YYYY), z. B. "02/2025 - heute".\n` +
|
||
`- Trennzeichen: in ALLEN Texten (Anschreiben UND Lebenslauf) ausschließlich den einfachen Bindestrich ` +
|
||
`"-" verwenden - niemals den Halbgeviertstrich (–) oder Geviertstrich (—). Gilt auch für Datumsangaben, ` +
|
||
`Wertebereiche (z. B. "2-3 Jahre") und Aufzählungen.\n\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.
|
||
|
||
// Normalize one content string: trim, and force a plain hyphen "-" instead of
|
||
// en/em dash ("–"/"—") everywhere — applies to every field of both documents
|
||
// so the requirement holds even if the model still emits dashes.
|
||
// Backstop: strip non-Latin script characters (e.g. stray CJK like "包括")
|
||
// the model may emit despite the German-only instruction. Safe for German
|
||
// application text — no legitimate field contains these scripts. Preserves
|
||
// newlines (paragraph breaks) and common German umlauts/punctuation.
|
||
const NON_LATIN_SCRIPT = /[㐀-鿿豈--ヿ가- -〿]/g;
|
||
const str = (v) => (typeof v === 'string' ? v.trim() : (v == null ? '' : String(v).trim()))
|
||
.replace(/[–—]/g, '-')
|
||
.replace(NON_LATIN_SCRIPT, '')
|
||
.replace(/[ \t]{2,}/g, ' ');
|
||
|
||
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 [];
|
||
}
|
||
// Render CV date ranges with a slash separator (MM/YYYY) regardless of how
|
||
// the model formatted them, so the timeline reads consistently. Only the
|
||
// month.year pattern is rewritten; year-only entries and "heute" stay as-is.
|
||
function normalizeDate(s) {
|
||
return String(s || '').replace(/\b(\d{1,2})\.(\d{4})\b/g, (_, m, y) => String(m).padStart(2, '0') + '/' + y);
|
||
}
|
||
|
||
function toZeitraum(v) {
|
||
if (!v) return '';
|
||
let out;
|
||
if (typeof v === 'string') out = v.trim();
|
||
else if (typeof v === 'object') {
|
||
const von = str(pick(v, ['von', 'from', 'start', 'beginn']));
|
||
const bis = str(pick(v, ['bis', 'to', 'ende', 'end']));
|
||
out = [von, bis].filter(Boolean).join(' - ');
|
||
} else out = str(v);
|
||
// Use a plain hyphen "-" (never en/em dash "–"/"—") for the date range.
|
||
return normalizeDate(out).replace(/[–—]/g, '-');
|
||
}
|
||
|
||
// After an Anrede ending in a comma, the body continues the sentence, so its
|
||
// first word is lowercase — unless it's a noun, proper name or the polite
|
||
// "Ihr/Ihre/…". We only downcase clearly-safe sentence openers (prepositions,
|
||
// conjunctions, adverbs, lowercase pronouns) to avoid breaking those cases.
|
||
const LOWER_OPENERS = new Set([
|
||
'mit', 'für', 'über', 'auf', 'in', 'bei', 'durch', 'seit', 'nach', 'aus', 'von', 'zu',
|
||
'im', 'am', 'zum', 'zur', 'vor', 'gegen', 'um', 'ohne', 'trotz', 'wegen', 'aufgrund',
|
||
'dank', 'während', 'per', 'laut', 'als', 'wie', 'da', 'weil', 'nachdem', 'hiermit',
|
||
'anbei', 'beiliegend', 'gerne', 'gern', 'sehr', 'bereits', 'schon', 'momentan',
|
||
'derzeit', 'aktuell', 'nun', 'mittlerweile', 'inzwischen', 'ich', 'mein', 'meine',
|
||
'meinen', 'meiner', 'meinem', 'meines',
|
||
]);
|
||
|
||
// Capitalize the first letter of a paragraph (every paragraph is a new sentence).
|
||
function capitalizeFirstLetter(p) {
|
||
return String(p).replace(/^(\s*)([a-zäöüß])/, (_, lead, ch) => lead + ch.toUpperCase());
|
||
}
|
||
|
||
// Lowercase the first word only if it's a clearly-safe sentence opener.
|
||
function lowercaseFirstIfOpener(p) {
|
||
const m = String(p).match(/^(\s*)([A-Za-zÄÖÜäöüß]+)/);
|
||
if (!m) return p;
|
||
const word = m[2];
|
||
const first = word[0];
|
||
const isUpper = (first >= 'A' && first <= 'Z') || 'ÄÖÜ'.includes(first);
|
||
if (isUpper && LOWER_OPENERS.has(word.toLowerCase())) {
|
||
return m[1] + first.toLowerCase() + p.slice(m[1].length + 1);
|
||
}
|
||
return p;
|
||
}
|
||
|
||
// Fix paragraph casing: all paragraphs start capitalized (new sentences); the
|
||
// first paragraph continues the sentence after the Anrede comma, so a safe
|
||
// opener there is lowercased.
|
||
function fixParagraphCasing(anrede, absaetze) {
|
||
if (!absaetze.length) return absaetze;
|
||
const out = absaetze.map(capitalizeFirstLetter);
|
||
if (anrede.trim().endsWith(',')) out[0] = lowercaseFirstIfOpener(out[0]);
|
||
return out;
|
||
}
|
||
|
||
// Defensive: the salutation must be "Sehr geehrte Frau <Nachname>" /
|
||
// "Sehr geehrter Herr <Nachname>" with ONLY the last name — never a first
|
||
// name or full name. Strip any leading given names the model may have added,
|
||
// but keep academic titles (Dr., Prof., …) and name particles (van, von, …).
|
||
const SAL_TITLES = new Set(['dr', 'prof', 'dipl', 'ing', 'rer', 'nat', 'mag', 'phd', 'habil', 'dr.habil']);
|
||
const SAL_PARTICLES = new Set(['van', 'de', 'von', 'zu', 'der', 'den', 'ten', 'da', 'di', 'del', 'della', 'la', 'le', 'vom', 'zur', 'mc', 'mac', 'al', 'el']);
|
||
function cleanAnrede(anrede) {
|
||
return String(anrede).replace(
|
||
/^(Sehr geehrte\s+Frau|Sehr geehrter\s+Herr)\s+(.+?)(,?\s*)$/i,
|
||
(m, sal, name, tail) => {
|
||
const words = String(name).trim().split(/\s+/);
|
||
if (words.length <= 1) return m; // already a single name
|
||
const kept = words.filter((w, i) => {
|
||
if (i === words.length - 1) return true; // last word = surname
|
||
const lw = w.toLowerCase().replace(/[.]/g, '');
|
||
return SAL_TITLES.has(lw) || SAL_PARTICLES.has(lw); // keep titles / particles
|
||
});
|
||
if (kept.length === words.length) return m; // nothing to strip
|
||
return `${sal} ${kept.join(' ')}${tail}`;
|
||
},
|
||
);
|
||
}
|
||
|
||
// Defensive: never let an HR red-flag phrase reach the PDF, even if the model
|
||
// ignores the instruction.
|
||
const RED_FLAG = /(nicht\s*beendet|abgebrochen|abbruch|ohne\s*abschluss|arbeits(los|suchend)|gek(ü|ue)ndigt|entlassen)/i;
|
||
const safeNote = (s) => (RED_FLAG.test(s) ? '' : s);
|
||
|
||
function mapEdu(e) {
|
||
return {
|
||
zeitraum: toZeitraum(pick(e, ['zeitraum', 'zeit', 'dauer', 'period', 'jahr']) || e.zeitraum),
|
||
abschluss: str(pick(e, ['abschluss', 'titel', 'grad', 'degree', 'qualifikation', 'studiengang', 'fach', 'name'])),
|
||
institution: str(pick(e, ['institution', 'schule', 'hochschule', 'universitaet', 'universität', 'einrichtung', 'betrieb', 'organisation', 'ort'])),
|
||
zusatz: safeNote(str(pick(e, ['zusatz', 'note', 'hinweis', 'schwerpunkt', 'details']))),
|
||
};
|
||
}
|
||
const eduList = (obj, keys) => pickArray(obj, keys).map(mapEdu).filter((e) => e.abschluss || e.institution);
|
||
|
||
function normalizeResult(parsed) {
|
||
const k = parsed.kontakt || parsed.contact || {};
|
||
const a = parsed.anschreiben || parsed.cover_letter || {};
|
||
const l = parsed.lebenslauf || parsed.cv || parsed.resume || {};
|
||
|
||
// Typed education sections; fall back to a lumped "ausbildung"/"bildung" list.
|
||
let studium = eduList(l, ['studium', 'hochschulstudium', 'studies']);
|
||
let berufsausbildung = eduList(l, ['berufsausbildung', 'ausbildung', 'berufsabschluss', 'apprenticeship']);
|
||
let schulbildung = eduList(l, ['schulbildung', 'schulausbildung', 'schule', 'schooling']);
|
||
const weiterbildungen = eduList(l, ['weiterbildungen', 'weiterbildung', 'zertifikate', 'certifications', 'fortbildungen']);
|
||
if (!studium.length && !berufsausbildung.length && !schulbildung.length) {
|
||
const lumped = eduList(l, ['ausbildung', 'bildung', 'education', 'qualifikationen']);
|
||
berufsausbildung = lumped;
|
||
}
|
||
|
||
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: (() => {
|
||
const anrede = cleanAnrede(str(pick(a, ['anrede', 'salutation'])));
|
||
const absaetze = pickArray(a, ['absaetze', 'absätze', 'paragraphs', 'text', 'inhalt']).map(str).filter(Boolean);
|
||
const e = a.empfaenger || a.recipient || a.empfänger || {};
|
||
return {
|
||
empfaenger: {
|
||
firma: str(pick(e, ['firma', 'unternehmen', 'company', 'name'])),
|
||
adresse: str(pick(e, ['adresse', 'strasse', 'straße', 'street', 'anschrift'])),
|
||
ort: str(pick(e, ['ort', 'plz_ort', 'plzort', 'stadt', 'city', 'plz'])),
|
||
ansprechpartner: str(pick(e, ['ansprechpartner', 'ansprechperson', 'kontaktperson', 'contact'])),
|
||
},
|
||
betreff: str(pick(a, ['betreff', 'subject', 'titel'])),
|
||
anrede,
|
||
absaetze: fixParagraphCasing(anrede, absaetze),
|
||
gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])),
|
||
};
|
||
})(),
|
||
email: (() => {
|
||
const em = parsed.email || parsed.email_anschreiben || parsed.mail || parsed.begleitmail || {};
|
||
if (typeof em === 'string') return { betreff: '', text: str(em) };
|
||
return {
|
||
betreff: str(pick(em, ['betreff', 'subject', 'titel'])),
|
||
text: str(pick(em, ['text', 'body', 'inhalt', 'nachricht', 'mailtext', 'anschreiben'])),
|
||
};
|
||
})(),
|
||
lebenslauf: {
|
||
profil: str(pick(l, ['profil', 'kurzprofil', 'zusammenfassung', 'summary', 'ueberblick', 'über_mich'])),
|
||
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', 'bullets', 'details'])
|
||
.map((p) => safeNote(str(p))).filter(Boolean),
|
||
}))
|
||
.filter((e) => e.titel || e.firma),
|
||
studium,
|
||
berufsausbildung,
|
||
schulbildung,
|
||
weiterbildungen,
|
||
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. Shared PDF primitives
|
||
// ===========================================================================
|
||
|
||
const PT = 0.352778; // pt -> mm
|
||
const PAGE = { w: 210, h: 297 };
|
||
const MIN_SCALE = 0.6;
|
||
|
||
// 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(doc.__sans, 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);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Resume (Lebenslauf) — modern two-column layout
|
||
//
|
||
// A tinted left sidebar (photo, contact, competencies, languages, interests)
|
||
// and a wide main column (large name header, profile, timeline experience,
|
||
// education). This is the canonical premium German Lebenslauf: the recruiter
|
||
// reads the whole person in the first glance — face, role, core skills — and
|
||
// the career story reads top-to-bottom on a single, deliberate axis.
|
||
// ===========================================================================
|
||
|
||
const RV = { top: 20, bottom: 14 };
|
||
|
||
// --- Horizontal grid (fixed; only vertical rhythm scales to fit one page) ---
|
||
const SB_W = 68; // sidebar width
|
||
const SB_X = 10; // sidebar text left
|
||
const SB_R = SB_W - 10; // 58 sidebar text right
|
||
const SB_CW = SB_R - SB_X; // 48 sidebar content width
|
||
|
||
const MAIN_X = SB_W + 12; // 80 main column left (section labels)
|
||
const MAIN_R = PAGE.w - 15; // 195 main column right (right-aligned dates)
|
||
const BODY_X = MAIN_X + 8; // 88 body text left (hanging under labels)
|
||
const BODY_W = MAIN_R - BODY_X; // 107
|
||
const MAIN_W = MAIN_R - MAIN_X; // 115 full main width (name / heading rules)
|
||
|
||
// The palette (`t.rc` here, `t.lc` for the letter) comes from lib/design.js and
|
||
// is threaded through every draw call as `t`. The base stays a neutral grey
|
||
// scale — hierarchy is carried by weight, size and tracking, exactly as in the
|
||
// monochrome original. Only the accent (name, section labels, company names,
|
||
// markers, photo frame) is the user's choice, so no setting can flatten the
|
||
// typographic structure.
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sidebar
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Fit an applicant photo into its frame, preserving aspect ratio. The frame size
|
||
// is fixed (does not scale with the page-fit S) so the sidebar keeps a stable,
|
||
// confident anchor regardless of how much text is below it.
|
||
//
|
||
// Returns the frame box (w/h — what the layout reserves) *and* the draw box
|
||
// (dw/dh — what addImage gets). They differ for the round portrait: the frame is
|
||
// a square, and the image is scaled to *cover* it and centred, with the overflow
|
||
// clipped away by the circle. Scaling a non-square photo into a square frame
|
||
// instead would squash the face.
|
||
function fitPhoto(doc, foto, rund = false) {
|
||
const MISS = { ok: false, w: 0, h: 0, dw: 0, dh: 0 };
|
||
const MAXW = 42, MAXH = 52;
|
||
if (!foto || !foto.dataUrl) return MISS;
|
||
try {
|
||
const p = doc.getImageProperties(foto.dataUrl);
|
||
if (!(p.width > 0 && p.height > 0)) return MISS;
|
||
const ar = p.width / p.height;
|
||
if (rund) {
|
||
const D = MAXW; // square frame = circle diameter
|
||
const dw = ar >= 1 ? D * ar : D; // cover: the short side matches D
|
||
const dh = ar >= 1 ? D : D / ar;
|
||
return { ok: true, w: D, h: D, dw, dh };
|
||
}
|
||
let w = MAXW, h = w / ar;
|
||
if (h > MAXH) { h = MAXH; w = h * ar; }
|
||
return { ok: true, w, h, dw: w, dh: h };
|
||
} catch (e) { return MISS; }
|
||
}
|
||
|
||
// Each entry carries a `typ` so a layout can pick a matching icon; the classic
|
||
// sidebar just prints `text` and ignores it.
|
||
function buildContactLines(header) {
|
||
const lines = [];
|
||
if (header.email) lines.push({ typ: 'email', text: header.email });
|
||
if (header.telefon) lines.push({ typ: 'telefon', text: header.telefon });
|
||
const ort = cityName(header) || header.ort;
|
||
if (ort) lines.push({ typ: 'ort', text: ort });
|
||
if (header.webseite) lines.push({ typ: 'web', text: header.webseite });
|
||
if (header.geburtsdatum) lines.push({ typ: 'geburtstag', text: `Geb. ${header.geburtsdatum}` });
|
||
if (header.fuehrerschein) lines.push({ typ: 'fuehrerschein', text: `Führerschein ${header.fuehrerschein}` });
|
||
return lines;
|
||
}
|
||
|
||
// Sidebar section label: a compact tracked uppercase accent word. No underline
|
||
// rule — the sidebar stays clean; tracking and weight carry the structure.
|
||
function sbHeading(doc, t, S, dry, cur, title) {
|
||
const pt = 9;
|
||
const cs = 1.0 * S;
|
||
cur.y += 5 * S;
|
||
doc.setFont(doc.__sans, 'bold');
|
||
doc.setFontSize(pt * S);
|
||
const label = String(title).toUpperCase();
|
||
if (!dry) {
|
||
doc.setCharSpace(cs);
|
||
doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||
doc.text(label, SB_X, cur.y + pt * S * PT * 0.76);
|
||
doc.setCharSpace(0);
|
||
}
|
||
cur.y += pt * S * PT + 4.6 * S;
|
||
}
|
||
|
||
function sbBullets(doc, t, S, dry, cur, items, pt = 8.7) {
|
||
const tx = SB_X + 3.4;
|
||
const tw = SB_CW - 3.4;
|
||
for (const it of items) {
|
||
doc.setFont(doc.__sans, 'normal');
|
||
doc.setFontSize(pt * S);
|
||
const lines = doc.splitTextToSize(String(it), tw);
|
||
lines.forEach((ln, i) => {
|
||
if (!dry) {
|
||
if (i === 0) {
|
||
const sz = 1.1 * S;
|
||
doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||
doc.rect(SB_X + 0.2, cur.y + pt * S * PT * 0.42 - sz / 2, sz, sz, 'F');
|
||
}
|
||
doc.setTextColor(t.rc.onSide[0], t.rc.onSide[1], t.rc.onSide[2]);
|
||
doc.text(ln, tx, cur.y + pt * S * PT * 0.76);
|
||
}
|
||
cur.y += pt * S * PT * 1.32;
|
||
});
|
||
cur.y += 1.1 * S;
|
||
}
|
||
}
|
||
|
||
// Language row: name (left) + level (right-aligned in the sidebar column).
|
||
function sbLangRow(doc, t, S, dry, cur, s) {
|
||
const pt = 8.7;
|
||
doc.setFont(doc.__sans, 'normal');
|
||
doc.setFontSize(pt * S);
|
||
if (!dry) {
|
||
doc.setTextColor(t.rc.onSide[0], t.rc.onSide[1], t.rc.onSide[2]);
|
||
doc.text(String(s.sprache), SB_X, cur.y + pt * S * PT * 0.76);
|
||
if (s.niveau) {
|
||
doc.setTextColor(t.rc.onSideSub[0], t.rc.onSideSub[1], t.rc.onSideSub[2]);
|
||
doc.text(String(s.niveau), SB_R, cur.y + pt * S * PT * 0.76, { align: 'right' });
|
||
}
|
||
}
|
||
cur.y += pt * S * PT * 1.62;
|
||
}
|
||
|
||
function composeSidebar(doc, t, S, dry, { cv, header, foto }) {
|
||
const cur = { y: RV.top };
|
||
|
||
const photo = fitPhoto(doc, foto, t.foto.rund);
|
||
if (photo.ok) {
|
||
const px = (SB_W - photo.w) / 2;
|
||
const py = RV.top - 2;
|
||
if (!dry) {
|
||
doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]);
|
||
doc.setLineWidth(0.3 * S);
|
||
if (t.foto.rund) {
|
||
// Circular portrait: clip to the circle, draw the image centred and
|
||
// over-sized (cover), then trace the frame on top of the clipped edge.
|
||
const r = photo.w / 2;
|
||
const cx = px + r;
|
||
const cy = py + r;
|
||
doc.saveGraphicsState();
|
||
doc.circle(cx, cy, r, null);
|
||
doc.clip();
|
||
doc.discardPath();
|
||
doc.addImage(foto.dataUrl, foto.format || 'PNG',
|
||
cx - photo.dw / 2, cy - photo.dh / 2, photo.dw, photo.dh);
|
||
doc.restoreGraphicsState();
|
||
doc.circle(cx, cy, r, 'S');
|
||
} else {
|
||
doc.addImage(foto.dataUrl, foto.format || 'PNG', px, py, photo.w, photo.h);
|
||
doc.rect(px, py, photo.w, photo.h);
|
||
}
|
||
}
|
||
cur.y = py + photo.h + 7 * S;
|
||
}
|
||
|
||
const contact = buildContactLines(header);
|
||
if (contact.length) {
|
||
sbHeading(doc, t, S, dry, cur, 'Kontakt');
|
||
contact.forEach((line) => write(doc, S, dry, cur, line.text, { pt: 8.6, color: t.rc.onSideSub, x: SB_X, width: SB_CW, factor: 1.5 }));
|
||
}
|
||
if (cv.kenntnisse.length) {
|
||
sbHeading(doc, t, S, dry, cur, 'Kernkompetenzen');
|
||
sbBullets(doc, t, S, dry, cur, cv.kenntnisse);
|
||
}
|
||
if (cv.sprachen.length) {
|
||
sbHeading(doc, t, S, dry, cur, 'Sprachen');
|
||
cv.sprachen.forEach((s) => sbLangRow(doc, t, S, dry, cur, s));
|
||
}
|
||
if (cv.hobbys.length) {
|
||
sbHeading(doc, t, S, dry, cur, 'Interessen');
|
||
write(doc, S, dry, cur, cv.hobbys.join(' · '), { pt: 8.6, color: t.rc.onSide, x: SB_X, width: SB_CW, factor: 1.55 });
|
||
}
|
||
return cur.y;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main column
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Main section heading: tracked uppercase accent label, outdented over a
|
||
// full-width hairline whose first stretch is a heavier accent segment as wide
|
||
// as the label word — a precise, repeating anchor down the page.
|
||
function mHeading(doc, t, S, dry, cur, title) {
|
||
const pt = 11;
|
||
const cs = 0.9 * S;
|
||
cur.y += 5.5 * S;
|
||
doc.setFont(doc.__sans, 'bold');
|
||
doc.setFontSize(pt * S);
|
||
const label = String(title).toUpperCase();
|
||
const lineY = cur.y + pt * S * PT * 1.1;
|
||
if (!dry) {
|
||
doc.setCharSpace(cs);
|
||
doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||
doc.text(label, MAIN_X, cur.y + pt * S * PT * 0.76);
|
||
doc.setCharSpace(0);
|
||
const wordW = doc.getTextWidth(label) + cs * label.length;
|
||
doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]);
|
||
doc.setLineWidth(0.3 * S);
|
||
doc.line(MAIN_X, lineY, MAIN_R, lineY);
|
||
doc.setDrawColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||
doc.setLineWidth(1.1 * S);
|
||
doc.line(MAIN_X, lineY, Math.min(MAIN_X + wordW, MAIN_R), lineY);
|
||
}
|
||
cur.y += pt * S * PT + 4.6 * S;
|
||
}
|
||
|
||
function cvBullets(doc, t, S, dry, cur, items, x, w, pt = 9.2) {
|
||
const tx = x + 3.8;
|
||
const tw = w - 3.8;
|
||
for (const it of items) {
|
||
doc.setFont(doc.__sans, 'normal');
|
||
doc.setFontSize(pt * S);
|
||
const lines = doc.splitTextToSize(String(it), tw);
|
||
lines.forEach((ln, i) => {
|
||
if (!dry) {
|
||
if (i === 0) {
|
||
const sz = 1.15 * S;
|
||
doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||
doc.rect(x + 0.2, cur.y + pt * S * PT * 0.42 - sz / 2, sz, sz, 'F');
|
||
}
|
||
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
|
||
doc.text(ln, tx, cur.y + pt * S * PT * 0.76);
|
||
}
|
||
cur.y += pt * S * PT * 1.32;
|
||
});
|
||
cur.y += 1.0 * S;
|
||
}
|
||
}
|
||
|
||
// One experience entry: an accent node on the timeline, bold role title with a
|
||
// right-aligned period, the company in accent, then the achievement bullets.
|
||
function cvExperience(doc, t, S, dry, cur, e) {
|
||
const cx = MAIN_X + 1.4; // node / track centre x
|
||
const titlePt = 11, datePt = 9.3;
|
||
const dateW = 30;
|
||
doc.setFont(doc.__sans, 'bold');
|
||
doc.setFontSize(titlePt * S);
|
||
const titleLines = doc.splitTextToSize(String(e.titel || ''), BODY_W - dateW);
|
||
const titleLineH = titlePt * S * PT * 1.16;
|
||
if (!dry) {
|
||
doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||
doc.circle(cx, cur.y + titlePt * S * PT * 0.44, 1.5 * S, 'F');
|
||
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
|
||
titleLines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * titleLineH + titlePt * S * PT * 0.76));
|
||
if (e.zeitraum) {
|
||
doc.setFont(doc.__sans, 'normal');
|
||
doc.setFontSize(datePt * S);
|
||
doc.setTextColor(t.rc.sub[0], t.rc.sub[1], t.rc.sub[2]);
|
||
doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' });
|
||
}
|
||
}
|
||
cur.y += titleLineH * Math.max(1, titleLines.length);
|
||
if (e.firma) write(doc, S, dry, cur, e.firma, { pt: 9.6, color: t.rc.accent, x: BODY_X, width: BODY_W, factor: 1.24 });
|
||
if (e.punkte && e.punkte.length) { cur.y += 1.4 * S; cvBullets(doc, t, S, dry, cur, e.punkte, BODY_X, BODY_W); }
|
||
cur.y += 5 * S;
|
||
}
|
||
|
||
// The full Berufserfahrung section, threaded on a subtle vertical timeline.
|
||
// The track is measured with a dry pass so it can be drawn behind the nodes in
|
||
// one clean stroke, then the entries render on top.
|
||
function composeTimeline(doc, t, S, dry, cur, entries) {
|
||
const startY = cur.y;
|
||
if (!dry) {
|
||
const probe = { y: cur.y };
|
||
entries.forEach((e) => cvExperience(doc, t, S, true, probe, e));
|
||
doc.setDrawColor(t.rc.track[0], t.rc.track[1], t.rc.track[2]);
|
||
doc.setLineWidth(0.5 * S);
|
||
doc.line(MAIN_X + 1.4, startY + 2 * S, MAIN_X + 1.4, probe.y - 5 * S);
|
||
}
|
||
entries.forEach((e) => cvExperience(doc, t, S, dry, cur, e));
|
||
}
|
||
|
||
function cvEducation(doc, t, S, dry, cur, e) {
|
||
const pt = 10.5, datePt = 9.3, dateW = 30;
|
||
doc.setFont(doc.__sans, 'bold');
|
||
doc.setFontSize(pt * S);
|
||
const lines = doc.splitTextToSize(String(e.abschluss || ''), BODY_W - dateW);
|
||
const lineH = pt * S * PT * 1.16;
|
||
if (!dry) {
|
||
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
|
||
lines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * lineH + pt * S * PT * 0.76));
|
||
if (e.zeitraum) {
|
||
doc.setFont(doc.__sans, 'normal');
|
||
doc.setFontSize(datePt * S);
|
||
doc.setTextColor(t.rc.sub[0], t.rc.sub[1], t.rc.sub[2]);
|
||
doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' });
|
||
}
|
||
}
|
||
cur.y += lineH * Math.max(1, lines.length);
|
||
if (e.institution) write(doc, S, dry, cur, e.institution, { pt: 9.5, color: t.rc.accent, x: BODY_X, width: BODY_W, factor: 1.2 });
|
||
if (e.zusatz) write(doc, S, dry, cur, e.zusatz, { pt: 9.3, color: t.rc.sub, x: BODY_X, width: BODY_W, factor: 1.24 });
|
||
cur.y += 4 * S;
|
||
}
|
||
|
||
// Big name + target role — the confident head the recruiter's eye lands on
|
||
// first. No divider rule here; the first section heading provides the structure.
|
||
function composeMainHeader(doc, t, S, dry, cur, header, titel) {
|
||
write(doc, S, dry, cur, header.name, { pt: 25, style: 'bold', color: t.rc.accent, x: MAIN_X, width: MAIN_W, factor: 1.06 });
|
||
if (titel) write(doc, S, dry, cur, titel, { pt: 12, color: t.rc.sub, x: MAIN_X, width: MAIN_W, factor: 1.36, charSpace: 0.3 });
|
||
cur.y += 2 * S;
|
||
}
|
||
|
||
function composeMain(doc, t, S, dry, { cv, header, titel }) {
|
||
const cur = { y: RV.top };
|
||
composeMainHeader(doc, t, S, dry, cur, header, titel);
|
||
if (cv.profil) {
|
||
mHeading(doc, t, S, dry, cur, 'Profil');
|
||
write(doc, S, dry, cur, cv.profil, { pt: 9.8, color: t.rc.ink, x: BODY_X, width: BODY_W, factor: 1.5 });
|
||
}
|
||
if (cv.berufserfahrung.length) {
|
||
mHeading(doc, t, S, dry, cur, 'Berufserfahrung');
|
||
composeTimeline(doc, t, S, dry, cur, cv.berufserfahrung);
|
||
}
|
||
[['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Weiterbildungen', cv.weiterbildungen]].forEach(([titel, entries]) => {
|
||
if (!entries || !entries.length) return;
|
||
mHeading(doc, t, S, dry, cur, titel);
|
||
entries.forEach((e) => cvEducation(doc, t, S, dry, cur, e));
|
||
});
|
||
return cur.y;
|
||
}
|
||
|
||
// Draw the sidebar band first, then the main column, then the sidebar text —
|
||
// so the tint sits underneath, and the PDF text stream leads with the name and
|
||
// career story (better for résumé parsers) while positions stay absolute.
|
||
function composeCV(doc, t, S, dry, ctx) {
|
||
if (!dry) {
|
||
doc.setFillColor(t.rc.sidebarBg[0], t.rc.sidebarBg[1], t.rc.sidebarBg[2]);
|
||
doc.rect(0, 0, SB_W, PAGE.h, 'F');
|
||
}
|
||
const mainBottom = composeMain(doc, t, S, dry, ctx);
|
||
const sideBottom = composeSidebar(doc, t, S, dry, ctx);
|
||
return Math.max(mainBottom, sideBottom);
|
||
}
|
||
|
||
// The user's font-size choice (t.scale) is the *starting* scale, not the final
|
||
// one: we measure at that size and shrink from there if the content would spill
|
||
// onto a second page. So "110 %" enlarges a sparse CV but can never break the
|
||
// one-page guarantee on a full one.
|
||
function renderCV(cv, header, titel, foto, t) {
|
||
const doc = makeDoc(t);
|
||
const ctx = { cv, header, titel, foto };
|
||
const social = t.layout === 'social';
|
||
const compose = social ? composeCVSocial : composeCV;
|
||
const top = social ? SO.top : RV.top;
|
||
const bottom = social ? SO.bottom : RV.bottom;
|
||
|
||
const S0 = t.scale;
|
||
const need = compose(doc, t, S0, true, ctx) - top;
|
||
const avail = PAGE.h - top - bottom;
|
||
let S = S0;
|
||
if (need > avail) S = Math.max(MIN_SCALE, S0 * (avail / need) * 0.99);
|
||
compose(doc, t, S, false, ctx);
|
||
return Buffer.from(doc.output('arraybuffer'));
|
||
}
|
||
|
||
// ===========================================================================
|
||
// "Social media" layout — the playful alternative
|
||
//
|
||
// A media-kit look: a tinted page, a round portrait, a script greeting over a
|
||
// huge name, hearts as section markers, rounded white cards for every entry and
|
||
// pill chips for skills and languages. Loud on purpose — meant for creative and
|
||
// social-media roles, not for a bank.
|
||
//
|
||
// It reuses the same machinery as the classic layout (dry-run measuring, the
|
||
// one-page auto-scale, the shared `write` helper), so it inherits the same
|
||
// guarantees. Only the drawing is different.
|
||
// ===========================================================================
|
||
|
||
const SO = {
|
||
mx: 15, // page margin
|
||
get r() { return PAGE.w - this.mx; },
|
||
get w() { return PAGE.w - 2 * this.mx; },
|
||
top: 16,
|
||
bottom: 14,
|
||
pad: 4.6, // inner padding of a card
|
||
radius: 3.2, // corner radius of cards
|
||
};
|
||
|
||
// --- Decorative primitives -------------------------------------------------
|
||
|
||
// A heart, drawn as two Bézier lobes. `s` is the width in mm; the shape is
|
||
// centred on (cx, cy). Used as the section marker and the bullet glyph, because
|
||
// the sans faces don't carry a heart glyph we could rely on.
|
||
function heart(doc, cx, cy, s, color) {
|
||
const w = s, h = s;
|
||
doc.setFillColor(color[0], color[1], color[2]);
|
||
// Two cubic lobes, drawn from the bottom tip. In jsPDF's `lines`, a segment's
|
||
// control points and end point are all relative to that segment's *start*.
|
||
// Left lobe: tip -> up the left side -> down into the top notch.
|
||
// Right lobe: notch -> up the right side -> back to the tip.
|
||
doc.lines(
|
||
[
|
||
[-w * 0.5, -h * 0.35, -w * 0.5, -h * 1.0, 0, -h * 0.65],
|
||
[w * 0.5, -h * 0.35, w * 0.5, h * 0.30, 0, h * 0.65],
|
||
],
|
||
cx, cy + h * 0.36, // bottom tip (shape sits centred on cy)
|
||
[1, 1], 'F', true
|
||
);
|
||
}
|
||
|
||
// A four-pointed sparkle, the other motif from the reference: a thin star made
|
||
// of four concave points.
|
||
function sparkle(doc, cx, cy, s, color) {
|
||
const a = s / 2;
|
||
doc.setFillColor(color[0], color[1], color[2]);
|
||
// Four points, each a cubic whose control points both sit at the centre — that
|
||
// pulls the edges inwards and gives the concave, twinkling star shape.
|
||
doc.lines(
|
||
[
|
||
[0, a, 0, a, a, a], // top -> right
|
||
[-a, 0, -a, 0, -a, a], // right -> bottom
|
||
[0, -a, 0, -a, -a, -a], // bottom -> left
|
||
[a, 0, a, 0, a, -a], // left -> top
|
||
],
|
||
cx, cy - a, [1, 1], 'F', true
|
||
);
|
||
}
|
||
|
||
// Contact icons, drawn as strokes so they scale with the page-fit and never
|
||
// depend on an icon font being present. Each one is centred on (cx, cy) and fits
|
||
// inside a square of side `s`.
|
||
function icon(doc, typ, cx, cy, s, color, S) {
|
||
const lw = Math.max(0.18, 0.16 * s);
|
||
doc.setDrawColor(color[0], color[1], color[2]);
|
||
doc.setFillColor(color[0], color[1], color[2]);
|
||
doc.setLineWidth(lw);
|
||
const h = s / 2;
|
||
|
||
if (typ === 'email') {
|
||
// Envelope: a rectangle with the flap drawn as two strokes to the centre.
|
||
const w = s, hh = s * 0.72;
|
||
const x = cx - w / 2, y = cy - hh / 2;
|
||
doc.roundedRect(x, y, w, hh, lw, lw, 'S');
|
||
doc.lines([[w / 2, hh * 0.52], [w / 2, -hh * 0.52]], x, y + lw * 0.4, [1, 1], 'S');
|
||
return;
|
||
}
|
||
if (typ === 'telefon') {
|
||
// Handset: a rounded body with the earpiece/mouthpiece implied by the fill.
|
||
const w = s * 0.66, hh = s * 0.96;
|
||
doc.roundedRect(cx - w / 2, cy - hh / 2, w, hh, w * 0.28, w * 0.28, 'S');
|
||
doc.circle(cx, cy + hh * 0.28, lw * 0.9, 'F');
|
||
doc.line(cx - w * 0.22, cy - hh * 0.3, cx + w * 0.22, cy - hh * 0.3);
|
||
return;
|
||
}
|
||
if (typ === 'ort') {
|
||
// Map pin: a circle with an open V tapering to the point. Drawn open (not a
|
||
// closed triangle) so no horizontal edge cuts across the circle.
|
||
const r = s * 0.32;
|
||
const top = cy - s * 0.16;
|
||
doc.circle(cx, top, r, 'S');
|
||
doc.lines(
|
||
[[r * 0.72, r * 1.55], [r * 0.72, -r * 1.55]],
|
||
cx - r * 0.72, top + r * 0.5, [1, 1], 'S', false
|
||
);
|
||
doc.circle(cx, top, r * 0.3, 'F');
|
||
return;
|
||
}
|
||
if (typ === 'web') {
|
||
// Globe: circle, equator, and one narrow ellipse as the meridian. Two
|
||
// mirrored arcs would cross into an "X" at this size.
|
||
const r = s * 0.46;
|
||
doc.circle(cx, cy, r, 'S');
|
||
doc.line(cx - r, cy, cx + r, cy);
|
||
doc.ellipse(cx, cy, r * 0.44, r, 'S');
|
||
return;
|
||
}
|
||
if (typ === 'geburtstag') {
|
||
// Calendar: a sheet with two rings and a header rule.
|
||
const w = s * 0.92, hh = s * 0.82;
|
||
const x = cx - w / 2, y = cy - hh / 2 + s * 0.08;
|
||
doc.roundedRect(x, y, w, hh, lw, lw, 'S');
|
||
doc.line(x, y + hh * 0.32, x + w, y + hh * 0.32);
|
||
doc.line(x + w * 0.28, y - s * 0.12, x + w * 0.28, y + hh * 0.08);
|
||
doc.line(x + w * 0.72, y - s * 0.12, x + w * 0.72, y + hh * 0.08);
|
||
return;
|
||
}
|
||
if (typ === 'fuehrerschein') {
|
||
// ID card: a landscape card with a portrait block and two text rules.
|
||
const w = s, hh = s * 0.68;
|
||
const x = cx - w / 2, y = cy - hh / 2;
|
||
doc.roundedRect(x, y, w, hh, lw, lw, 'S');
|
||
doc.circle(x + w * 0.28, y + hh * 0.42, hh * 0.17, 'S');
|
||
doc.line(x + w * 0.52, y + hh * 0.36, x + w * 0.84, y + hh * 0.36);
|
||
doc.line(x + w * 0.52, y + hh * 0.62, x + w * 0.84, y + hh * 0.62);
|
||
return;
|
||
}
|
||
// Fallback: a small dot, so an unknown type never draws garbage.
|
||
doc.circle(cx, cy, h * 0.3, 'F');
|
||
}
|
||
|
||
// A rounded card. Draws the frame only — the caller fills it.
|
||
function card(doc, t, S, x, y, w, h) {
|
||
doc.setFillColor(t.rc.cardBg[0], t.rc.cardBg[1], t.rc.cardBg[2]);
|
||
doc.setDrawColor(t.rc.cardBorder[0], t.rc.cardBorder[1], t.rc.cardBorder[2]);
|
||
doc.setLineWidth(0.4 * S);
|
||
doc.roundedRect(x, y, w, h, SO.radius * S, SO.radius * S, 'FD');
|
||
}
|
||
|
||
// A pill chip ("FOLLOWER" in the reference; here: periods, skills, languages,
|
||
// contact details). `item` is a plain string, or { text, icon } to prefix it
|
||
// with one of the contact icons. Returns its size so chips can be flowed.
|
||
function pill(doc, t, S, dry, x, y, item, opts = {}) {
|
||
const text = String((item && item.text !== undefined) ? item.text : item);
|
||
const ikon = (item && item.icon) || opts.icon || null;
|
||
|
||
const pt = opts.pt || 8.2;
|
||
const padX = 2.6 * S;
|
||
const h = pt * S * PT * 1.85;
|
||
const iconS = ikon ? pt * S * PT * 0.95 : 0;
|
||
const iconGap = ikon ? 1.5 * S : 0;
|
||
|
||
doc.setFont(doc.__sans, opts.style || 'bold');
|
||
doc.setFontSize(pt * S);
|
||
const w = doc.getTextWidth(text) + padX * 2 + iconS + iconGap;
|
||
|
||
if (!dry) {
|
||
const bg = opts.bg || t.rc.pillBg;
|
||
const ink = opts.ink || t.rc.pillInk;
|
||
doc.setFillColor(bg[0], bg[1], bg[2]);
|
||
doc.roundedRect(x, y, w, h, h / 2, h / 2, 'F');
|
||
if (ikon) icon(doc, ikon, x + padX + iconS / 2, y + h / 2, iconS, ink, S);
|
||
doc.setTextColor(ink[0], ink[1], ink[2]);
|
||
doc.text(text, x + padX + iconS + iconGap, y + h / 2 + pt * S * PT * 0.34);
|
||
}
|
||
return { w, h };
|
||
}
|
||
|
||
// Flow pill chips across the available width, wrapping into new rows.
|
||
function pillRow(doc, t, S, dry, cur, items, x, maxW, opts = {}) {
|
||
let cx = x;
|
||
const gap = 2.0 * S;
|
||
let rowH = 0;
|
||
for (const it of items) {
|
||
const probe = pill(doc, t, S, true, 0, 0, it, opts);
|
||
if (cx > x && cx + probe.w > x + maxW) { // wrap
|
||
cx = x;
|
||
cur.y += rowH + gap;
|
||
rowH = 0;
|
||
}
|
||
const p = pill(doc, t, S, dry, cx, cur.y, it, opts);
|
||
cx += p.w + gap;
|
||
rowH = Math.max(rowH, p.h);
|
||
}
|
||
cur.y += rowH;
|
||
}
|
||
|
||
// Section heading: centred, tracked, flanked by hairlines and a heart on each
|
||
// side — the reference's "♥ MEINE SOCIAL MEDIA KANÄLE ♥".
|
||
function soHeading(doc, t, S, dry, cur, title) {
|
||
const pt = 10.5;
|
||
cur.y += 6 * S;
|
||
const label = String(title).toUpperCase();
|
||
doc.setFont(doc.__sans, 'bold');
|
||
doc.setFontSize(pt * S);
|
||
const cs = 0.7 * S;
|
||
const midY = cur.y + pt * S * PT * 0.5;
|
||
if (!dry) {
|
||
// Centre the label by hand. jsPDF's align:'center' measures with
|
||
// getTextWidth(), which ignores charSpace, but *renders* with it: the text
|
||
// drifted right, so the left heart sat visibly further from the word than
|
||
// the right one. Measure the ink ourselves — the letter-spacing counts only
|
||
// between glyphs (n-1 gaps); the trailing one PDF emits after the last glyph
|
||
// is not ink and must not shift the centre.
|
||
const inkW = doc.getTextWidth(label) + cs * Math.max(0, label.length - 1);
|
||
const cx = PAGE.w / 2;
|
||
const textX = cx - inkW / 2;
|
||
|
||
doc.setCharSpace(cs);
|
||
doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||
doc.text(label, textX, cur.y + pt * S * PT * 0.76);
|
||
doc.setCharSpace(0);
|
||
|
||
// Hearts anchored to the real ink edges, so both gaps are identical.
|
||
const gap = 5.2 * S;
|
||
const heartS = 3.1 * S;
|
||
const heartR = heartS / 2;
|
||
const leftHeartX = textX - gap - heartR;
|
||
const rightHeartX = textX + inkW + gap + heartR;
|
||
heart(doc, leftHeartX, midY, heartS, t.rc.accent);
|
||
heart(doc, rightHeartX, midY, heartS, t.rc.accent);
|
||
|
||
doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]);
|
||
doc.setLineWidth(0.4 * S);
|
||
const lineGap = 3.4 * S;
|
||
doc.line(SO.mx + 4 * S, midY, leftHeartX - heartR - lineGap, midY);
|
||
doc.line(rightHeartX + heartR + lineGap, midY, SO.r - 4 * S, midY);
|
||
}
|
||
cur.y += pt * S * PT + 5 * S;
|
||
}
|
||
|
||
// Bullet list with heart glyphs instead of squares.
|
||
function soBullets(doc, t, S, dry, cur, items, x, w, pt = 9) {
|
||
const tx = x + 4.4 * S;
|
||
const tw = w - 4.4 * S;
|
||
for (const it of items) {
|
||
doc.setFont(doc.__sans, 'normal');
|
||
doc.setFontSize(pt * S);
|
||
const lines = doc.splitTextToSize(String(it), tw);
|
||
lines.forEach((ln, i) => {
|
||
if (!dry) {
|
||
if (i === 0) heart(doc, x + 1.5 * S, cur.y + pt * S * PT * 0.45, 2.3 * S, t.rc.accent);
|
||
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
|
||
doc.text(ln, tx, cur.y + pt * S * PT * 0.76);
|
||
}
|
||
cur.y += pt * S * PT * 1.34;
|
||
});
|
||
cur.y += 1.1 * S;
|
||
}
|
||
}
|
||
|
||
// Measure a block by running its draw function dry, then draw the card behind it
|
||
// and the content on top. This is how every entry card gets an exact height
|
||
// without hard-coding one.
|
||
function soCard(doc, t, S, dry, cur, inner) {
|
||
const startY = cur.y;
|
||
const probe = { y: startY + SO.pad * S };
|
||
inner(probe, true);
|
||
const h = (probe.y - startY) + SO.pad * S;
|
||
if (!dry) {
|
||
card(doc, t, S, SO.mx, startY, SO.w, h);
|
||
inner({ y: startY + SO.pad * S }, false);
|
||
}
|
||
cur.y = startY + h + 3.2 * S;
|
||
}
|
||
|
||
// --- Résumé sections -------------------------------------------------------
|
||
|
||
// The hero: round portrait on the left, script greeting + huge name on the right,
|
||
// with the target role as a tracked, dot-separated line underneath.
|
||
function soHero(doc, t, S, dry, cur, { header, titel, foto }) {
|
||
const startY = cur.y;
|
||
const hasPhoto = Boolean(foto && foto.dataUrl);
|
||
const D = 44 * S; // portrait diameter
|
||
const px = SO.mx + 2 * S;
|
||
const textX = hasPhoto ? px + D + 9 * S : SO.mx + 2 * S;
|
||
const textW = SO.r - textX;
|
||
|
||
if (hasPhoto && !dry) {
|
||
const p = fitPhoto(doc, foto, true);
|
||
if (p.ok) {
|
||
const r = D / 2;
|
||
const cx = px + r;
|
||
const cy = startY + r;
|
||
// Soft blob behind the portrait, like the reference's organic shape.
|
||
doc.setFillColor(t.rc.cardBorder[0], t.rc.cardBorder[1], t.rc.cardBorder[2]);
|
||
doc.circle(cx + 1.6 * S, cy + 1.2 * S, r + 1.8 * S, 'F');
|
||
const dw = D * (p.dw / p.w);
|
||
const dh = D * (p.dh / p.h);
|
||
doc.saveGraphicsState();
|
||
doc.circle(cx, cy, r, null);
|
||
doc.clip();
|
||
doc.discardPath();
|
||
doc.addImage(foto.dataUrl, foto.format || 'PNG', cx - dw / 2, cy - dh / 2, dw, dh);
|
||
doc.restoreGraphicsState();
|
||
doc.setDrawColor(255, 255, 255);
|
||
doc.setLineWidth(1.2 * S);
|
||
doc.circle(cx, cy, r, 'S');
|
||
}
|
||
}
|
||
|
||
const cur2 = { y: startY + 2 * S };
|
||
|
||
// Script line — the one handwritten touch.
|
||
if (!dry) {
|
||
doc.setFont(doc.__script, 'normal');
|
||
doc.setFontSize(14 * S);
|
||
doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||
doc.text('Hallo, ich bin', textX, cur2.y + 14 * S * PT * 0.76);
|
||
}
|
||
cur2.y += 14 * S * PT * 1.25;
|
||
|
||
// The name, as big as it fits.
|
||
const name = String(header.name || '').toUpperCase();
|
||
let namePt = 30;
|
||
doc.setFont(doc.__sans, 'bold');
|
||
for (; namePt > 15; namePt -= 0.5) {
|
||
doc.setFontSize(namePt * S);
|
||
if (doc.getTextWidth(name) <= textW) break;
|
||
}
|
||
write(doc, S, dry, cur2, name, { pt: namePt, style: 'bold', color: t.rc.accent, x: textX, width: textW, factor: 1.02 });
|
||
|
||
// Role line: "SYSTEMADMINISTRATOR · LINUX · NETZWERKE"
|
||
if (titel) {
|
||
cur2.y += 1.5 * S;
|
||
write(doc, S, dry, cur2, titel, {
|
||
pt: 8.8, style: 'bold', color: t.rc.sub, x: textX, width: textW,
|
||
factor: 1.35, charSpace: 0.55, upper: true,
|
||
});
|
||
}
|
||
|
||
if (!dry) {
|
||
sparkle(doc, SO.r - 6 * S, startY + 3 * S, 5.2 * S, t.rc.deko);
|
||
sparkle(doc, SO.r - 13 * S, startY + 9 * S, 3.0 * S, t.rc.deko);
|
||
}
|
||
|
||
const photoBottom = hasPhoto ? startY + D + 2 * S : startY;
|
||
cur.y = Math.max(cur2.y, photoBottom) + 2 * S;
|
||
}
|
||
|
||
function soExperience(doc, t, S, dry, cur, e) {
|
||
soCard(doc, t, S, dry, cur, (c, isDry) => {
|
||
const x = SO.mx + SO.pad * S;
|
||
const w = SO.w - 2 * SO.pad * S;
|
||
// Period as a pill, right-aligned on the first line.
|
||
let pillW = 0;
|
||
if (e.zeitraum) {
|
||
const probe = pill(doc, t, S, true, 0, 0, e.zeitraum);
|
||
pillW = probe.w;
|
||
pill(doc, t, S, isDry, SO.mx + SO.w - SO.pad * S - probe.w, c.y - 0.6 * S, e.zeitraum);
|
||
}
|
||
write(doc, S, isDry, c, e.titel || '', {
|
||
pt: 11.4, style: 'bold', color: t.rc.ink, x, width: w - pillW - 3 * S, factor: 1.2,
|
||
});
|
||
if (e.firma) write(doc, S, isDry, c, e.firma, { pt: 9.4, style: 'bold', color: t.rc.accent, x, width: w, factor: 1.3 });
|
||
if (e.punkte && e.punkte.length) {
|
||
c.y += 1.4 * S;
|
||
soBullets(doc, t, S, isDry, c, e.punkte, x, w);
|
||
}
|
||
});
|
||
}
|
||
|
||
function soEducation(doc, t, S, dry, cur, e) {
|
||
soCard(doc, t, S, dry, cur, (c, isDry) => {
|
||
const x = SO.mx + SO.pad * S;
|
||
const w = SO.w - 2 * SO.pad * S;
|
||
let pillW = 0;
|
||
if (e.zeitraum) {
|
||
const probe = pill(doc, t, S, true, 0, 0, e.zeitraum);
|
||
pillW = probe.w;
|
||
pill(doc, t, S, isDry, SO.mx + SO.w - SO.pad * S - probe.w, c.y - 0.6 * S, e.zeitraum);
|
||
}
|
||
write(doc, S, isDry, c, e.abschluss || '', {
|
||
pt: 10.6, style: 'bold', color: t.rc.ink, x, width: w - pillW - 3 * S, factor: 1.2,
|
||
});
|
||
if (e.institution) write(doc, S, isDry, c, e.institution, { pt: 9.2, style: 'bold', color: t.rc.accent, x, width: w, factor: 1.28 });
|
||
if (e.zusatz) write(doc, S, isDry, c, e.zusatz, { pt: 8.8, color: t.rc.sub, x, width: w, factor: 1.28 });
|
||
});
|
||
}
|
||
|
||
function composeCVSocial(doc, t, S, dry, ctx) {
|
||
const { cv, header, titel, foto } = ctx;
|
||
if (!dry && t.rc.pageBg) {
|
||
doc.setFillColor(t.rc.pageBg[0], t.rc.pageBg[1], t.rc.pageBg[2]);
|
||
doc.rect(0, 0, PAGE.w, PAGE.h, 'F');
|
||
}
|
||
const cur = { y: SO.top };
|
||
|
||
soHero(doc, t, S, dry, cur, { header, titel, foto });
|
||
|
||
// Contact + profile in one card — the "Willkommen in meiner bunten Welt" block.
|
||
const kontakt = buildContactLines(header);
|
||
if (cv.profil || kontakt.length) {
|
||
soCard(doc, t, S, dry, cur, (c, isDry) => {
|
||
const x = SO.mx + SO.pad * S;
|
||
const w = SO.w - 2 * SO.pad * S;
|
||
if (cv.profil) write(doc, S, isDry, c, cv.profil, { pt: 9.6, color: t.rc.ink, x, width: w, factor: 1.5 });
|
||
if (kontakt.length) {
|
||
c.y += 2 * S;
|
||
const chips = kontakt.map((k) => ({ text: k.text, icon: k.typ }));
|
||
pillRow(doc, t, S, isDry, c, chips, x, w, { style: 'normal', pt: 8.4 });
|
||
}
|
||
});
|
||
}
|
||
|
||
if (cv.berufserfahrung.length) {
|
||
soHeading(doc, t, S, dry, cur, 'Berufserfahrung');
|
||
cv.berufserfahrung.forEach((e) => soExperience(doc, t, S, dry, cur, e));
|
||
}
|
||
|
||
[['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Weiterbildungen', cv.weiterbildungen]]
|
||
.forEach(([label, entries]) => {
|
||
if (!entries || !entries.length) return;
|
||
soHeading(doc, t, S, dry, cur, label);
|
||
entries.forEach((e) => soEducation(doc, t, S, dry, cur, e));
|
||
});
|
||
|
||
if (cv.kenntnisse.length) {
|
||
soHeading(doc, t, S, dry, cur, 'Kernkompetenzen');
|
||
const c = { y: cur.y };
|
||
pillRow(doc, t, S, dry, c, cv.kenntnisse, SO.mx + 1 * S, SO.w - 2 * S);
|
||
cur.y = c.y + 2 * S;
|
||
}
|
||
|
||
if (cv.sprachen.length) {
|
||
soHeading(doc, t, S, dry, cur, 'Sprachen');
|
||
const c = { y: cur.y };
|
||
const items = cv.sprachen.map((s) => (s.niveau ? `${s.sprache} — ${s.niveau}` : s.sprache));
|
||
pillRow(doc, t, S, dry, c, items, SO.mx + 1 * S, SO.w - 2 * S, { style: 'normal' });
|
||
cur.y = c.y + 2 * S;
|
||
}
|
||
|
||
if (cv.hobbys.length) {
|
||
soHeading(doc, t, S, dry, cur, 'Interessen');
|
||
const c = { y: cur.y };
|
||
pillRow(doc, t, S, dry, c, cv.hobbys, SO.mx + 1 * S, SO.w - 2 * S, { style: 'normal' });
|
||
cur.y = c.y + 2 * S;
|
||
}
|
||
|
||
return cur.y;
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Cover letter — clean, single-column DIN-5008 business letter (monochrome)
|
||
// ===========================================================================
|
||
|
||
const LET = { mx: 25, mr: 20, top: 24, bottom: 24 }; // DIN-ish margins (bottom reserves the footer strip)
|
||
const LET_R = PAGE.w - LET.mr; // 190
|
||
const LET_W = LET_R - LET.mx; // 165
|
||
|
||
// The letter shares the résumé's accent (see lib/design.js), so cover letter and
|
||
// CV keep reading as one deliberately designed set whatever the user picks.
|
||
|
||
// Extract just the town from a possibly-full address ("Feldstraße 76, 45968
|
||
// Gladbeck" / "45968 Gladbeck" / "Gladbeck" → "Gladbeck").
|
||
function cityName(header) {
|
||
let s = header.ort || header.adresse || '';
|
||
const parts = String(s).split(',').map((p) => p.trim()).filter(Boolean);
|
||
if (parts.length) s = parts[parts.length - 1];
|
||
return s.replace(/\b\d{5}\b/g, '').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function composeLetter(doc, t, S, dry, cur, { letter, header, job, anlagen, signatur }) {
|
||
const x = LET.mx;
|
||
const right = LET_R;
|
||
const width = LET_W;
|
||
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');
|
||
const stadt = cityName(header); // just the town for the date line ("Ort, den …")
|
||
|
||
// --- Letterhead: a big, confident name + role in tracked caps, closed by
|
||
// the shared hairline-with-accent motif. Full contact details live in the
|
||
// footer strip, which keeps the head clean and echoes the résumé header. ---
|
||
write(doc, S, dry, cur, header.name, { pt: 20, style: 'bold', color: t.lc.accent, x, width, factor: 1.12 });
|
||
if (header.headline) write(doc, S, dry, cur, header.headline, { pt: 9.5, color: t.lc.muted, x, width, factor: 1.32, charSpace: 0.8, upper: true });
|
||
|
||
// Hairline across, with a short heavier accent segment at the start — the
|
||
// same motif that anchors every section of the résumé.
|
||
cur.y += 3.4 * S;
|
||
if (!dry) {
|
||
doc.setDrawColor(t.lc.hair[0], t.lc.hair[1], t.lc.hair[2]);
|
||
doc.setLineWidth(0.3 * S);
|
||
doc.line(x, cur.y, right, cur.y);
|
||
doc.setDrawColor(t.lc.accent[0], t.lc.accent[1], t.lc.accent[2]);
|
||
doc.setLineWidth(1.1 * S);
|
||
doc.line(x, cur.y, x + 16 * S, cur.y);
|
||
}
|
||
cur.y += 13 * S;
|
||
|
||
// --- Recipient (Anschriftfeld) — from the LLM's empfaenger, falling back to job ---
|
||
const emp = letter.empfaenger || {};
|
||
const empFirma = emp.firma || job.firma || '';
|
||
const empOrt = emp.ort || job.ort || '';
|
||
if (empFirma) write(doc, S, dry, cur, empFirma, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.32 });
|
||
if (emp.ansprechpartner) write(doc, S, dry, cur, `z. Hd. ${emp.ansprechpartner}`, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.32 });
|
||
if (emp.adresse) write(doc, S, dry, cur, emp.adresse, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.32 });
|
||
if (empOrt) write(doc, S, dry, cur, empOrt, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.32 });
|
||
|
||
// --- Date (right-aligned) ---
|
||
cur.y += 7 * S;
|
||
const dateLine = stadt ? `${stadt}, den ${today}` : today;
|
||
write(doc, S, dry, cur, dateLine, { pt: 10, color: t.lc.ink, x, width, align: 'right', right, factor: 1.2 });
|
||
|
||
// --- Subject (bold, no "Betreff:" label) — the letter's headline ---
|
||
cur.y += 7 * S;
|
||
write(doc, S, dry, cur, betreff, { pt: 11.5, style: 'bold', color: t.lc.accent, x, width, factor: 1.3 });
|
||
|
||
// --- Salutation + body ---
|
||
cur.y += 6 * S;
|
||
if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.4 }); cur.y += 3 * S; }
|
||
letter.absaetze.forEach((p, i) => {
|
||
if (i > 0) cur.y += 3.2 * S;
|
||
write(doc, S, dry, cur, p, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.52 });
|
||
});
|
||
|
||
// --- Closing + signature ---
|
||
cur.y += 6 * S;
|
||
if (letter.gruss) write(doc, S, dry, cur, letter.gruss, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.3 });
|
||
|
||
// Signature image (if provided) directly under the closing, replacing the
|
||
// typed name; otherwise leave room and print the name.
|
||
let sigOk = false, sigW = 0, sigH = 0;
|
||
if (signatur && signatur.dataUrl) {
|
||
try {
|
||
const props = doc.getImageProperties(signatur.dataUrl);
|
||
const maxW = 57.6 * S, maxH = 24 * S; // 20% larger than before (48 / 20 mm)
|
||
sigW = maxW;
|
||
sigH = sigW * props.height / props.width;
|
||
if (sigH > maxH) { sigH = maxH; sigW = sigH * props.width / props.height; }
|
||
sigOk = props.width > 0 && props.height > 0;
|
||
} catch (e) { sigOk = false; }
|
||
}
|
||
if (sigOk) {
|
||
cur.y += 3 * S;
|
||
if (!dry) doc.addImage(signatur.dataUrl, signatur.format || 'PNG', x, cur.y, sigW, sigH);
|
||
cur.y += sigH;
|
||
} else {
|
||
cur.y += 13 * S; // room for a handwritten signature
|
||
write(doc, S, dry, cur, header.name, { pt: 10.5, style: 'bold', color: t.lc.ink, x, width, factor: 1.2 });
|
||
}
|
||
|
||
// --- Enclosures ---
|
||
if (anlagen && anlagen.length) {
|
||
cur.y += 8 * S;
|
||
write(doc, S, dry, cur, (anlagen.length > 1 ? 'Anlagen: ' : 'Anlage: ') + anlagen.join(', '),
|
||
{ pt: 9, color: t.lc.muted, x, width, factor: 1.2 });
|
||
}
|
||
|
||
// --- Footer letterhead: a centred contact strip under a hairline, drawn in
|
||
// the reserved bottom margin so it never collides with the body. ---
|
||
if (!dry) {
|
||
const parts = [];
|
||
// Flatten a possibly multi-line address (newlines and/or commas) to one
|
||
// line — doc.text breaks on \n, which would stack the street above the city.
|
||
if (header.adresse) {
|
||
const adr = String(header.adresse).split(/[\n,]/).map((s) => s.trim()).filter(Boolean).join(', ');
|
||
if (adr) parts.push(adr);
|
||
}
|
||
if (header.telefon) parts.push(header.telefon);
|
||
if (header.email) parts.push(header.email);
|
||
if (parts.length) {
|
||
const fy = PAGE.h - 15;
|
||
doc.setDrawColor(t.lc.hair[0], t.lc.hair[1], t.lc.hair[2]);
|
||
doc.setLineWidth(0.3 * S);
|
||
doc.line(x, fy, right, fy);
|
||
doc.setFont(doc.__sans, 'normal');
|
||
doc.setFontSize(8 * S);
|
||
doc.setTextColor(t.lc.muted[0], t.lc.muted[1], t.lc.muted[2]);
|
||
doc.text(parts.join(' · '), (x + right) / 2, fy + 4 * S, { align: 'center' });
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Cover letter, playful variant — mirrors the "social" résumé
|
||
//
|
||
// Same page tint, same accent, same cards, hearts and pills, same script line.
|
||
// The letter's *substance* is untouched: recipient block, date, subject,
|
||
// salutation, body, closing, signature and enclosures all stay where a German
|
||
// reader expects them. It's the same letter, dressed like the CV it arrives
|
||
// with — a playful résumé must never show up with a stiff monochrome letter.
|
||
// ===========================================================================
|
||
|
||
function composeLetterSocial(doc, t, S, dry, cur, { letter, header, job, anlagen, signatur }) {
|
||
const x = SO.mx + 4 * S;
|
||
const right = SO.r - 4 * S;
|
||
const width = right - x;
|
||
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');
|
||
const stadt = cityName(header);
|
||
|
||
if (!dry && t.lc.pageBg) {
|
||
doc.setFillColor(t.lc.pageBg[0], t.lc.pageBg[1], t.lc.pageBg[2]);
|
||
doc.rect(0, 0, PAGE.w, PAGE.h, 'F');
|
||
}
|
||
|
||
// --- Letterhead: script greeting + big name, echoing the résumé's hero ---
|
||
if (!dry) {
|
||
doc.setFont(doc.__script, 'normal');
|
||
doc.setFontSize(13 * S);
|
||
doc.setTextColor(t.lc.accent[0], t.lc.accent[1], t.lc.accent[2]);
|
||
doc.text('Hallo, ich bin', x, cur.y + 13 * S * PT * 0.76);
|
||
sparkle(doc, right - 4 * S, cur.y + 2 * S, 5 * S, t.lc.deko);
|
||
}
|
||
cur.y += 13 * S * PT * 1.25;
|
||
|
||
const name = String(header.name || '').toUpperCase();
|
||
let namePt = 24;
|
||
doc.setFont(doc.__sans, 'bold');
|
||
for (; namePt > 13; namePt -= 0.5) {
|
||
doc.setFontSize(namePt * S);
|
||
if (doc.getTextWidth(name) <= width) break;
|
||
}
|
||
write(doc, S, dry, cur, name, { pt: namePt, style: 'bold', color: t.lc.accent, x, width, factor: 1.04 });
|
||
if (header.headline) {
|
||
write(doc, S, dry, cur, header.headline, {
|
||
pt: 8.6, style: 'bold', color: t.lc.muted, x, width, factor: 1.35, charSpace: 0.55, upper: true,
|
||
});
|
||
}
|
||
|
||
// Heart-flanked divider instead of the classic hairline.
|
||
cur.y += 4 * S;
|
||
if (!dry) {
|
||
doc.setDrawColor(t.lc.hair[0], t.lc.hair[1], t.lc.hair[2]);
|
||
doc.setLineWidth(0.4 * S);
|
||
doc.line(x, cur.y, right - 7 * S, cur.y);
|
||
heart(doc, right - 3 * S, cur.y, 3.4 * S, t.lc.accent);
|
||
}
|
||
cur.y += 9 * S;
|
||
|
||
// --- Recipient in a rounded card, with the date as a pill on the right ---
|
||
const emp = letter.empfaenger || {};
|
||
const empFirma = emp.firma || job.firma || '';
|
||
const empOrt = emp.ort || job.ort || '';
|
||
const empLines = [
|
||
empFirma,
|
||
emp.ansprechpartner ? `z. Hd. ${emp.ansprechpartner}` : '',
|
||
emp.adresse || '',
|
||
empOrt,
|
||
].filter(Boolean);
|
||
|
||
if (empLines.length) {
|
||
const startY = cur.y;
|
||
const inner = (c, isDry) => {
|
||
empLines.forEach((l) => write(doc, S, isDry, c, l, {
|
||
pt: 10, color: t.lc.ink, x: x + SO.pad * S, width: width - 2 * SO.pad * S - 34 * S, factor: 1.34,
|
||
}));
|
||
};
|
||
const probe = { y: startY + SO.pad * S };
|
||
inner(probe, true);
|
||
const h = (probe.y - startY) + SO.pad * S;
|
||
if (!dry) {
|
||
doc.setFillColor(t.lc.cardBg[0], t.lc.cardBg[1], t.lc.cardBg[2]);
|
||
doc.setDrawColor(t.lc.cardBorder[0], t.lc.cardBorder[1], t.lc.cardBorder[2]);
|
||
doc.setLineWidth(0.4 * S);
|
||
doc.roundedRect(x, startY, width, h, SO.radius * S, SO.radius * S, 'FD');
|
||
inner({ y: startY + SO.pad * S }, false);
|
||
const d = stadt ? `${stadt}, ${today}` : today;
|
||
const p = pill(doc, t, S, true, 0, 0, d, { pt: 8.4 });
|
||
pill(doc, t, S, false, x + width - SO.pad * S - p.w, startY + SO.pad * S, d, { pt: 8.4 });
|
||
}
|
||
cur.y = startY + h + 7 * S;
|
||
}
|
||
|
||
// --- Subject ---
|
||
write(doc, S, dry, cur, betreff, { pt: 12, style: 'bold', color: t.lc.accent, x, width, factor: 1.3 });
|
||
|
||
// --- Salutation + body ---
|
||
cur.y += 5 * S;
|
||
if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.4 }); cur.y += 2.6 * S; }
|
||
letter.absaetze.forEach((p, i) => {
|
||
if (i > 0) cur.y += 3 * S;
|
||
write(doc, S, dry, cur, p, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.52 });
|
||
});
|
||
|
||
// --- Closing + signature ---
|
||
cur.y += 5 * S;
|
||
if (letter.gruss) write(doc, S, dry, cur, letter.gruss, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.3 });
|
||
|
||
let sigOk = false, sigW = 0, sigH = 0;
|
||
if (signatur && signatur.dataUrl) {
|
||
try {
|
||
const props = doc.getImageProperties(signatur.dataUrl);
|
||
const maxW = 52 * S, maxH = 22 * S;
|
||
sigW = maxW;
|
||
sigH = sigW * props.height / props.width;
|
||
if (sigH > maxH) { sigH = maxH; sigW = sigH * props.width / props.height; }
|
||
sigOk = props.width > 0 && props.height > 0;
|
||
} catch (e) { sigOk = false; }
|
||
}
|
||
if (sigOk) {
|
||
cur.y += 2.5 * S;
|
||
if (!dry) doc.addImage(signatur.dataUrl, signatur.format || 'PNG', x, cur.y, sigW, sigH);
|
||
cur.y += sigH;
|
||
} else {
|
||
cur.y += 12 * S;
|
||
write(doc, S, dry, cur, header.name, { pt: 10.2, style: 'bold', color: t.lc.accent, x, width, factor: 1.2 });
|
||
}
|
||
|
||
// --- Enclosures as pills ---
|
||
if (anlagen && anlagen.length) {
|
||
cur.y += 6 * S;
|
||
write(doc, S, dry, cur, 'Anlagen', { pt: 8.4, style: 'bold', color: t.lc.muted, x, width, factor: 1.4, charSpace: 0.5, upper: true });
|
||
cur.y += 1 * S;
|
||
const c = { y: cur.y };
|
||
pillRow(doc, t, S, dry, c, anlagen, x, width, { style: 'normal' });
|
||
cur.y = c.y;
|
||
}
|
||
|
||
// --- Footer contact strip, centred under a hairline ---
|
||
if (!dry) {
|
||
const parts = [];
|
||
if (header.adresse) {
|
||
const adr = String(header.adresse).split(/[\n,]/).map((s) => s.trim()).filter(Boolean).join(', ');
|
||
if (adr) parts.push(adr);
|
||
}
|
||
if (header.telefon) parts.push(header.telefon);
|
||
if (header.email) parts.push(header.email);
|
||
if (parts.length) {
|
||
const fy = PAGE.h - 15;
|
||
doc.setDrawColor(t.lc.hair[0], t.lc.hair[1], t.lc.hair[2]);
|
||
doc.setLineWidth(0.4 * S);
|
||
doc.line(x, fy, right, fy);
|
||
doc.setFont(doc.__sans, 'normal');
|
||
doc.setFontSize(8 * S);
|
||
doc.setTextColor(t.lc.muted[0], t.lc.muted[1], t.lc.muted[2]);
|
||
doc.text(parts.join(' · '), (x + right) / 2, fy + 4.4 * S, { align: 'center' });
|
||
heart(doc, x + 1 * S, fy, 2.6 * S, t.lc.accent);
|
||
heart(doc, right - 1 * S, fy, 2.6 * S, t.lc.accent);
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderSingleColumn(compose, t) {
|
||
const doc = makeDoc(t);
|
||
const social = t.layout === 'social';
|
||
const top = social ? SO.top : LET.top;
|
||
const bottom = social ? SO.bottom : LET.bottom;
|
||
const S0 = t.scale;
|
||
const m = { y: top }; compose(doc, t, S0, true, m);
|
||
const need = m.y - top;
|
||
const avail = PAGE.h - top - bottom;
|
||
let S = S0;
|
||
if (need > avail) S = Math.max(MIN_SCALE, S0 * (avail / need) * 0.99);
|
||
compose(doc, t, 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,
|
||
};
|
||
}
|
||
|
||
// `design` = the user's design overrides ({} / undefined -> shipped defaults).
|
||
function renderLebenslaufPdf(cv, header, foto, design = null) {
|
||
const t = designStore.resolve(design || {});
|
||
// Title at the top = the position (headline), falling back to the name.
|
||
return renderCV(cv, header, header.headline || '', t.foto.anzeigen ? foto : null, t);
|
||
}
|
||
|
||
// The letter always follows the résumé's layout — that is the contract of the
|
||
// design setting: one application, one look.
|
||
function renderAnschreibenPdf(letter, header, job, anlagen, signatur, design = null) {
|
||
const t = designStore.resolve(design || {});
|
||
const compose = t.layout === 'social' ? composeLetterSocial : composeLetter;
|
||
return renderSingleColumn(
|
||
(doc, th, S, dry, cur) => compose(doc, th, S, dry, cur, { letter, header, job, anlagen, signatur }),
|
||
t
|
||
);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 3. Public entry point
|
||
// ===========================================================================
|
||
|
||
function hasAnschreiben(a) { return a && a.absaetze && a.absaetze.length > 0; }
|
||
function hasLebenslauf(l) {
|
||
return l && (l.berufserfahrung.length || l.studium.length || l.berufsausbildung.length
|
||
|| l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length);
|
||
}
|
||
|
||
async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null, prompts = null, design = null }) {
|
||
const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen, prompts });
|
||
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 = [];
|
||
const cvPresent = hasLebenslauf(data.lebenslauf);
|
||
// "Anlagen" list for the letter: Lebenslauf first, then any static extras.
|
||
const anlagen = [
|
||
...(cvPresent ? ['Lebenslauf'] : []),
|
||
...zusatzAnlagen.filter(Boolean),
|
||
];
|
||
|
||
if (hasAnschreiben(data.anschreiben)) {
|
||
documents.push({
|
||
name: `Anschreiben - ${label}`.trim(),
|
||
filename: `Anschreiben_${suffix}.pdf`,
|
||
mime: 'application/pdf',
|
||
buffer: renderAnschreibenPdf(data.anschreiben, header, job, anlagen, signatur, design),
|
||
});
|
||
}
|
||
|
||
if (cvPresent) {
|
||
documents.push({
|
||
name: `Lebenslauf - ${label}`.trim(),
|
||
filename: `Lebenslauf_${suffix}.pdf`,
|
||
mime: 'application/pdf',
|
||
buffer: renderLebenslaufPdf(data.lebenslauf, header, bewerbungsfoto, design),
|
||
});
|
||
}
|
||
|
||
if (documents.length === 0) {
|
||
throw new Error('Die KI hat keine verwertbaren Unterlagen erzeugt.');
|
||
}
|
||
|
||
return { documents, email: data.email || { betreff: '', text: '' } };
|
||
}
|
||
|
||
// ===========================================================================
|
||
// AI reply drafting — draft a professional German reply to an incoming email
|
||
// ===========================================================================
|
||
|
||
// Small shared Ollama JSON call (used by the reply drafter).
|
||
async function ollamaChatJSON({ system, user, schema, temperature = 0.5 }) {
|
||
const apiKey = process.env.OLLAMA_API_KEY;
|
||
if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt.');
|
||
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: schema,
|
||
options: { temperature },
|
||
messages: [
|
||
{ role: 'system', content: system },
|
||
{ role: 'user', content: user },
|
||
],
|
||
}),
|
||
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 textOut = ((data && data.message && data.message.content) || '').trim();
|
||
if (!textOut) throw new Error('Die KI hat keine Antwort geliefert (leerer Inhalt).');
|
||
const cleaned = textOut.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim();
|
||
try { return JSON.parse(cleaned); } catch (e) {
|
||
const m = cleaned.match(/\{[\s\S]*\}/);
|
||
if (m) { try { return JSON.parse(m[0]); } catch (_) { /* ignore */ } }
|
||
}
|
||
throw new Error('Die KI-Antwort konnte nicht als JSON gelesen werden.');
|
||
}
|
||
|
||
const REPLY_SCHEMA = {
|
||
type: 'object',
|
||
properties: { betreff: { type: 'string' }, text: { type: 'string' } },
|
||
required: ['betreff', 'text'],
|
||
};
|
||
|
||
// Draft a reply to a recruiter/company e-mail in the context of an application.
|
||
// `incoming` = { from, subject, text }; `job` = { firma, stelle };
|
||
// `settings` = { name, ... }. `hinweise` = optional free-text steering.
|
||
// `typ` = 'antwort' (default) for a normal reply, or 'absage' for a polite, short
|
||
// rejection because the applicant accepted another position.
|
||
async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise = '', typ = 'antwort', prompts = null }) {
|
||
const name = (settings && settings.name) || 'der Bewerber';
|
||
const isAbsage = typ === 'absage';
|
||
|
||
// Role prompt (per type) plus the shared style rules — both editable; the task
|
||
// description below stays in code because it defines the expected JSON.
|
||
const commonRules = promptStore.get(prompts, 'email_stil');
|
||
// Joined with a space so neither part has to carry padding of its own — the
|
||
// user edits these as plain paragraphs.
|
||
const withRules = (rolle) => [rolle, commonRules].filter((s) => s && s.trim()).join(' ');
|
||
|
||
let system, user, betreffDefault;
|
||
if (isAbsage) {
|
||
system = withRules(promptStore.get(prompts, 'email_absage', { name }));
|
||
user =
|
||
`# Kontext der Bewerbung\n` +
|
||
`Unternehmen: ${job.firma || '-'}\n` +
|
||
`Stelle: ${job.stelle || '-'}\n` +
|
||
`Bewerber: ${name}\n\n` +
|
||
`# Eingegangene E-Mail (Bezug)\n` +
|
||
`Von: ${incoming.from || '-'}\n` +
|
||
`Betreff: ${incoming.subject || '-'}\n\n` +
|
||
`${incoming.text || ''}\n\n` +
|
||
(hinweise && hinweise.trim() ? `# Hinweise für die Absage (aktiv berücksichtigen)\n${hinweise.trim()}\n\n` : '') +
|
||
`# Aufgabe\n` +
|
||
`Formuliere eine kurze, höfliche Absage-E-Mail: Du hast eine andere Stelle angenommen ` +
|
||
`und ziehst dich aus diesem Prozess zurück. Struktur des Feldes "text": Anrede (an den ` +
|
||
`konkreten Absender, falls Name erkennbar, sonst "Sehr geehrte Damen und Herren,"), ein ` +
|
||
`bis zwei kurze Absätze, Grußformel "Mit freundlichen Grüßen" und in der letzten Zeile der ` +
|
||
`Name "${name}". Trenne Anrede, Absätze, Gruß und Name durch je eine Leerzeile (\\n\\n). ` +
|
||
`"betreff": knappe Betreffzeile, i. d. R. "Re: ${incoming.subject || ''}". ` +
|
||
`Verwende ausschließlich den einfachen Bindestrich "-" (niemals – oder —). ` +
|
||
`Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, ohne Markdown, ohne Code-Fences.`;
|
||
betreffDefault = 'Re: ' + (incoming.subject || ('Absage zur Bewerbung als ' + (job.stelle || '')));
|
||
} else {
|
||
system = withRules(promptStore.get(prompts, 'email_antwort', { name }));
|
||
user =
|
||
`# Kontext der Bewerbung\n` +
|
||
`Unternehmen: ${job.firma || '-'}\n` +
|
||
`Stelle: ${job.stelle || '-'}\n` +
|
||
`Bewerber: ${name}\n\n` +
|
||
`# Eingegangene E-Mail\n` +
|
||
`Von: ${incoming.from || '-'}\n` +
|
||
`Betreff: ${incoming.subject || '-'}\n\n` +
|
||
`${incoming.text || ''}\n\n` +
|
||
(hinweise && hinweise.trim() ? `# Hinweise für die Antwort (aktiv berücksichtigen)\n${hinweise.trim()}\n\n` : '') +
|
||
`# Aufgabe\n` +
|
||
`Formuliere eine passende Antwort-E-Mail. Struktur des Feldes "text": Anrede (an den ` +
|
||
`konkreten Absender, falls Name erkennbar, sonst "Sehr geehrte Damen und Herren,"), ` +
|
||
`2-4 kurze Absätze, Grußformel "Mit freundlichen Grüßen" und in der letzten Zeile der ` +
|
||
`Name "${name}". Trenne Anrede, Absätze, Gruß und Name durch je eine Leerzeile (\\n\\n). ` +
|
||
`"betreff": sinnvolle Betreffzeile, i. d. R. "Re: ${incoming.subject || ''}". ` +
|
||
`Verwende ausschließlich den einfachen Bindestrich "-" (niemals – oder —). ` +
|
||
`Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, ohne Markdown, ohne Code-Fences.`;
|
||
betreffDefault = 'Re: ' + (incoming.subject || '');
|
||
}
|
||
|
||
const parsed = await ollamaChatJSON({ system, user, schema: REPLY_SCHEMA, temperature: 0.5 });
|
||
return {
|
||
betreff: str(pick(parsed, ['betreff', 'subject', 'titel'])) || betreffDefault,
|
||
text: str(pick(parsed, ['text', 'body', 'inhalt', 'nachricht'])),
|
||
};
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Design preview
|
||
// ===========================================================================
|
||
|
||
// Render the cover letter + CV from fixed sample content, so the Vorlagen page
|
||
// can show what a design choice actually looks like without calling the LLM
|
||
// (which costs time and money) and without needing a real application. The
|
||
// user's own name, signature and photo are used when available — the point is
|
||
// to preview *their* documents, not a stranger's.
|
||
function renderDesignVorschau({ settings = {}, signatur = null, bewerbungsfoto = null, design = null }) {
|
||
const header = buildHeader(settings, {
|
||
email: 'name@example.de', telefon: '0123 4567890', ort: 'Musterstadt',
|
||
}, 'Systemadministrator');
|
||
|
||
const letter = {
|
||
empfaenger: { firma: 'Muster GmbH', adresse: 'Industriestraße 5', ort: '12345 Musterstadt', ansprechpartner: 'Frau Beispiel' },
|
||
betreff: 'Bewerbung als Systemadministrator',
|
||
anrede: 'Sehr geehrte Frau Beispiel,',
|
||
absaetze: [
|
||
'dies ist eine Vorschau Ihres Anschreiben-Layouts. Der Text ist ein Platzhalter und zeigt, '
|
||
+ 'wie Absätze, Zeilenabstand und Schriftgröße im fertigen Dokument wirken.',
|
||
'Die Farbe des Namens, der Überschriften und der Linien folgt der Akzentfarbe, die Sie unter '
|
||
+ 'Design gewählt haben. Der eigentliche Bewerbungstext wird später von der KI aus Ihren '
|
||
+ 'Basis-Unterlagen erzeugt.',
|
||
'Unterschrift und Anlagenliste erscheinen genau so wie hier dargestellt.',
|
||
],
|
||
gruss: 'Mit freundlichen Grüßen',
|
||
};
|
||
|
||
const cv = {
|
||
profil: 'Kurzprofil als Platzhalter: zwei bis drei Sätze, die später von der KI auf die '
|
||
+ 'jeweilige Stelle zugeschnitten werden.',
|
||
berufserfahrung: [
|
||
{ zeitraum: '02/2022 - heute', titel: 'Systemadministrator', firma: 'Beispiel AG, Musterstadt', punkte: ['Betrieb der Server- und Netzwerkinfrastruktur', 'Automatisierung wiederkehrender Aufgaben'] },
|
||
{ zeitraum: '05/2018 - 01/2022', titel: 'IT-Supporter', firma: 'Muster GmbH, Musterstadt', punkte: ['First- und Second-Level-Support', 'Betreuung der Clients und Benutzerkonten'] },
|
||
],
|
||
studium: [],
|
||
berufsausbildung: [{ zeitraum: '08/2015 - 06/2018', abschluss: 'Fachinformatiker Systemintegration', institution: 'Musterbetrieb / Berufskolleg', zusatz: '' }],
|
||
schulbildung: [],
|
||
weiterbildungen: [],
|
||
kenntnisse: ['Linux / Windows Server', 'Netzwerke, Firewalls', 'Docker', 'Backup & Monitoring'],
|
||
sprachen: [{ sprache: 'Deutsch', niveau: 'Muttersprache' }, { sprache: 'Englisch', niveau: 'gut' }],
|
||
hobbys: ['Heimserver', 'Radfahren'],
|
||
};
|
||
|
||
return {
|
||
anschreiben: renderAnschreibenPdf(letter, header, { firma: 'Muster GmbH', stelle: 'Systemadministrator' },
|
||
['Lebenslauf', 'Zeugnisse'], signatur, design),
|
||
lebenslauf: renderLebenslaufPdf(cv, header, bewerbungsfoto, design),
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
generateApplicationDocuments,
|
||
generateEmailReply,
|
||
generateTailoredTexts,
|
||
renderLebenslaufPdf,
|
||
renderAnschreibenPdf,
|
||
renderDesignVorschau,
|
||
};
|