Der Lebenslauf kann jetzt zwischen zwei Layouts waehlen, und das Anschreiben uebernimmt immer das Layout des Lebenslaufs - beide Dokumente kommen als ein Set beim Unternehmen an. "Social Media" ist die verspielte Variante nach der Media-Kit-Vorlage: getoente Seite, rundes Portrait mit Blob, Schreibschrift-Zeile ueber einem grossen Namen, Herzchen als Abschnittsmarker und Bullets, abgerundete Karten je Station, Pill-Chips fuer Kompetenzen und Sprachen, Glitzer-Sterne als Deko. Bewusst laut - gedacht fuer kreative und Social-Media-Stellen. Die gesamte Farbwelt (Seite, Karten, Pills, Deko) wird aus der Akzentfarbe abgeleitet, das Layout funktioniert also auch in Violett oder Tiefblau. Neue Akzente Pink und Violett; das Layout bringt eigene Defaults mit (pink, rundes Foto), sodass ein Wechsel sofort stimmig aussieht. Neue Schriften (SIL OFL): Poppins fuer die geometrische Sans, Pacifico fuer die Schreibschrift. Es werden nur die Fonts eingebettet, die das gewaehlte Layout nutzt - ein klassischer Lebenslauf traegt Poppins/Pacifico nicht mit. Herz und Sparkle sind als Bezier-Pfade gezeichnet, weil sich auf ein Herz-Glyph in der Schrift nicht verlassen werden kann. Das neue Layout nutzt dieselbe Mess- und Skalier-Mechanik wie das klassische, erbt also die Ein-Seiten-Garantie. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1929 lines
87 KiB
JavaScript
1929 lines
87 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; }
|
||
}
|
||
|
||
function buildContactLines(header) {
|
||
const lines = [];
|
||
if (header.email) lines.push(header.email);
|
||
if (header.telefon) lines.push(header.telefon);
|
||
const ort = cityName(header) || header.ort;
|
||
if (ort) lines.push(ort);
|
||
if (header.webseite) lines.push(header.webseite);
|
||
if (header.geburtsdatum) lines.push(`Geb. ${header.geburtsdatum}`);
|
||
if (header.fuehrerschein) lines.push(`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, { 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
|
||
);
|
||
}
|
||
|
||
// 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).
|
||
// Returns its width so chips can be flowed into rows.
|
||
function pill(doc, t, S, dry, x, y, text, opts = {}) {
|
||
const pt = opts.pt || 8.2;
|
||
const padX = 2.6 * S;
|
||
const h = pt * S * PT * 1.85;
|
||
doc.setFont(doc.__sans, opts.style || 'bold');
|
||
doc.setFontSize(pt * S);
|
||
const w = doc.getTextWidth(String(text)) + padX * 2;
|
||
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');
|
||
doc.setTextColor(ink[0], ink[1], ink[2]);
|
||
doc.text(String(text), x + padX, 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) {
|
||
doc.setCharSpace(cs);
|
||
const tw = doc.getTextWidth(label) + cs * label.length;
|
||
const cx = PAGE.w / 2;
|
||
doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||
doc.text(label, cx, cur.y + pt * S * PT * 0.76, { align: 'center' });
|
||
doc.setCharSpace(0);
|
||
|
||
const gap = 5.5 * S;
|
||
const hx = tw / 2 + gap;
|
||
heart(doc, cx - hx, midY, 3.1 * S, t.rc.accent);
|
||
heart(doc, cx + hx, midY, 3.1 * S, t.rc.accent);
|
||
|
||
doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]);
|
||
doc.setLineWidth(0.4 * S);
|
||
const lineGap = hx + 3.6 * S;
|
||
doc.line(SO.mx + 4 * S, midY, cx - lineGap, midY);
|
||
doc.line(cx + 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;
|
||
pillRow(doc, t, S, isDry, c, kontakt, 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,
|
||
};
|