Files
jobbi-bewerbung/lib/documents.js
T
thomasandClaude Opus 4.8 090228479a Jobsuche: Berufsbezeichnungen steuern die Suche, nicht mehr Faehigkeiten
Der Feinschliff schlug per KI Technologien vor ("Proxmox", "Rechenzentrum")
und verbot Rollenbezeichnungen ausdruecklich - die sollte die Suche selbst
aus dem Lebenslauf ableiten. Das war schief: Welche Anzeigen ueberhaupt
gefunden werden, entscheidet die Suchanfrage, also der Stellentitel.
Technologien gewichten nur, was schon da ist. Damit schlug die KI genau das
vor, was der Such-Agent ohnehin selbst ableitet, waehrend die eine Sache,
die der Nutzer weder sieht noch steuern kann - unter welchen Titeln gesucht
wird -, bei jedem Lauf neu improvisiert wurde. Faellt "Fachinformatiker
Systemintegration" weg, merkt das niemand; es kommt nur wenig zurueck.

- Neues Profilfeld berufsbezeichnungen: die KI schlaegt es vor, der Nutzer
  editiert es, der Such-Agent fragt jeden Titel einzeln ab.
- zusatz_begriffe bleibt, aber als Kontext (Branche, Umfeld, Arbeitszeit),
  nicht als Stellentitel; ausschluesse unveraendert.
- Leeres Feld = altes Verhalten (Titel aus dem Lebenslauf), niemandes
  Profil aendert sich durch das Update.
- Spalte wird fuer bestehende Installationen nachgezogen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:27:06 +02:00

2249 lines
102 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Document generation.
//
// Architecture: the *design* lives here as a fixed, high-quality 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');
const config = require('./config');
// 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). Host/model/timeout come from the DB
// (editable via /einstellungen) and are read fresh per call via config.ollama().
// ===========================================================================
// 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'],
};
// Which documents a generation run should produce. `email` (the covering note
// for the mail body) is always generated — it is what the application is sent
// with, whatever is attached to it.
const DOKUMENT_TYPEN = ['anschreiben', 'lebenslauf'];
// Accept anything (string, array, undefined) and return a clean, ordered list.
// Empty / unknown input means "both" — the default everywhere.
function normalizeDokumente(v) {
const gewaehlt = [].concat(v == null ? [] : v).map((x) => String(x).trim().toLowerCase());
const gefiltert = DOKUMENT_TYPEN.filter((d) => gewaehlt.includes(d));
return gefiltert.length ? gefiltert : DOKUMENT_TYPEN.slice();
}
// Ask the model only for the documents we actually want. Dropping a section from
// the schema means the model never writes it — that saves a chunk of generation
// time and tokens when only one document is needed.
function buildOutputSchema(dokumente) {
const properties = { ...OUTPUT_SCHEMA.properties };
if (!dokumente.includes('anschreiben')) delete properties.anschreiben;
if (!dokumente.includes('lebenslauf')) delete properties.lebenslauf;
return {
type: 'object',
properties,
required: OUTPUT_SCHEMA.required.filter((k) => !DOKUMENT_TYPEN.includes(k) || dokumente.includes(k)),
};
}
async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '', zusatzAnlagen = [], prompts = null, dokumente = null }) {
const doks = normalizeDokumente(dokumente);
const { host: ollamaHost, model: ollamaModel, timeoutMs: ollamaTimeoutMs, apiKey } = config.ollama();
if (!apiKey) {
throw new Error(
'Kein Ollama-API-Schlüssel konfiguriert. Bitte unter „Einstellungen“ den ' +
'API-Schlüssel eintragen, 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,
settings && settings.email ? `E-Mail: ${settings.email}` : null,
settings && settings.telefon ? `Telefon: ${settings.telefon}` : null,
settings && settings.ort ? `Wohnort: ${settings.ort}` : null,
settings && settings.webseite ? `Webseite: ${settings.webseite}` : null,
settings && settings.geburtsdatum ? `Geburtsdatum: ${settings.geburtsdatum}` : 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');
// Built from blocks so it matches the (possibly reduced) schema exactly: a
// skeleton showing a section the schema forbids would only confuse the model.
const skelettBloecke = [
` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)"`,
` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "" }`,
];
if (doks.includes('anschreiben')) {
skelettBloecke.push(
` "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` +
` }`
);
}
skelettBloecke.push(
` "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` +
` }`
);
if (doks.includes('lebenslauf')) {
skelettBloecke.push(
` "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` +
` }`
);
}
const skeleton = `{\n${skelettBloecke.join(',\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 only
// listed when it is actually being generated — otherwise the cover letter would
// promise an enclosure that never gets attached.
const anlagenListe = [
...(doks.includes('lebenslauf') ? ['Lebenslauf'] : []),
...(zusatzAnlagen || []).filter(Boolean),
];
const anlagenText = anlagenListe.length
? `# Beigefügte Anlagen (genau diese Dokumente liegen der Bewerbung bei)\n` +
anlagenListe.map((a) => `- ${a}`).join('\n') + `\n\n`
: `# Beigefügte Anlagen\n(keine — erwähne keine Anlagen und kündige keine an)\n\n`;
// What the application e-mail will actually carry. Unlike the "Anlagen" list
// above (which is what the *letter* lists beneath itself), this includes the
// cover letter itself — so the mail's covering note never announces a document
// that isn't attached.
const mailAnhaenge = [
...(doks.includes('anschreiben') ? ['Anschreiben (PDF)'] : []),
...(doks.includes('lebenslauf') ? ['Lebenslauf (PDF)'] : []),
...(zusatzAnlagen || []).filter(Boolean),
];
const mailAnhangText =
`# Anhänge der Bewerbungs-E-Mail (genau diese Dateien werden mitgeschickt)\n` +
mailAnhaenge.map((a) => `- ${a}`).join('\n') + `\n` +
`Formuliere "email.text" passend zu genau diesen Anhängen — nenne keine Unterlagen, ` +
`die nicht dabei sind.\n\n`;
const aufgabe = doks.length === 2
? `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.`
: doks[0] === 'anschreiben'
? `Erzeuge NUR die TEXTE für ein Anschreiben, passgenau auf diese Stelle zugeschnitten. ` +
`Ein Lebenslauf wird diesmal NICHT erstellt und darf nicht im JSON auftauchen. ` +
`Layout/Design ist bereits vorgegeben — liefere nur die Inhalte. Das Anschreiben wird ` +
`auf EINER A4-Seite gedruckt, halte dich also kurz.`
: `Erzeuge NUR die TEXTE für einen Lebenslauf, passgenau auf diese Stelle zugeschnitten. ` +
`Ein Anschreiben wird diesmal NICHT erstellt und darf nicht im JSON auftauchen. ` +
`Layout/Design ist bereits vorgegeben — liefere nur die Inhalte. Der Lebenslauf wird ` +
`auf EINER A4-Seite gedruckt, halte dich also kurz.`;
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 +
mailAnhangText +
`# Aufgabe\n` +
aufgabe + `\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 (Wohnort), Webseite und Geburtsdatum werden DIR oben ` +
`unter "Bewerber" als feste Angaben geliefert. Übernimm sie WORTWÖRTLICH (Geburtsdatum ` +
`als DD.MM.YYYY, z. B. "03.04.1990"). Fehlt eine Angabe dort, übernimm sie nur, wenn sie ` +
`in den Basis-Unterlagen steht, sonst leerer String. NIEMALS erfinden. 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: 34 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: 23 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 ` +
`23 jüngsten erhalten je 23 knappe "punkte", mittelalte 01 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 46 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(), ollamaTimeoutMs);
let res;
try {
res = await fetch(`${ollamaHost}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: ollamaModel,
stream: false,
format: buildOutputSchema(doks),
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(ollamaTimeoutMs / 1000)}s).`);
}
throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`);
} finally {
clearTimeout(timeout);
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Ollama-API antwortete mit ${res.status}: ${body.slice(0, 300)}`);
}
const data = await res.json();
const text = ((data && data.message && data.message.content) || '').trim();
if (!text) throw new Error('Die KI hat keine Antwort geliefert (leerer Inhalt).');
const cleaned = text.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim();
let parsed;
try {
parsed = JSON.parse(cleaned);
} catch (e) {
const match = cleaned.match(/\{[\s\S]*\}/);
if (match) { try { parsed = JSON.parse(match[0]); } catch (_) { /* ignore */ } }
}
if (!parsed || typeof parsed !== 'object') {
throw new Error('Die KI-Antwort konnte nicht als JSON gelesen werden.');
}
return normalizeResult(parsed);
}
// ----- tolerant normalisation ----------------------------------------------
// Models honour the schema keys only loosely, so accept common German synonyms.
// Normalize one content string: trim, and force a plain hyphen "-" instead of
// en/em dash (""/"—") everywhere — applies to every field of both documents
// so the requirement holds even if the model still emits dashes.
// Backstop: strip non-Latin script characters (e.g. stray CJK like "包括")
// the model may emit despite the German-only instruction. Safe for German
// application text — no legitimate field contains these scripts. Preserves
// newlines (paragraph breaks) and common German umlauts/punctuation.
const NON_LATIN_SCRIPT = /[㐀-鿿豈-﫿぀-ヿ가-힯 -〿]/g;
const str = (v) => (typeof v === 'string' ? v.trim() : (v == null ? '' : String(v).trim()))
.replace(/[–—]/g, '-')
.replace(NON_LATIN_SCRIPT, '')
.replace(/[ \t]{2,}/g, ' ');
function pick(obj, keys) {
if (!obj) return '';
for (const k of keys) if (obj[k] != null && String(obj[k]).trim() !== '') return obj[k];
return '';
}
function pickArray(obj, keys) {
if (!obj) return [];
for (const k of keys) if (Array.isArray(obj[k]) && obj[k].length) return obj[k];
return [];
}
// Render CV date ranges with a slash separator (MM/YYYY) regardless of how
// the model formatted them, so the timeline reads consistently. Only the
// month.year pattern is rewritten; year-only entries and "heute" stay as-is.
function normalizeDate(s) {
return String(s || '').replace(/\b(\d{1,2})\.(\d{4})\b/g, (_, m, y) => String(m).padStart(2, '0') + '/' + y);
}
function toZeitraum(v) {
if (!v) return '';
let out;
if (typeof v === 'string') out = v.trim();
else if (typeof v === 'object') {
const von = str(pick(v, ['von', 'from', 'start', 'beginn']));
const bis = str(pick(v, ['bis', 'to', 'ende', 'end']));
out = [von, bis].filter(Boolean).join(' - ');
} else out = str(v);
// Use a plain hyphen "-" (never en/em dash ""/"—") for the date range.
return normalizeDate(out).replace(/[–—]/g, '-');
}
// After an Anrede ending in a comma, the body continues the sentence, so its
// first word is lowercase — unless it's a noun, proper name or the polite
// "Ihr/Ihre/…". We only downcase clearly-safe sentence openers (prepositions,
// conjunctions, adverbs, lowercase pronouns) to avoid breaking those cases.
const LOWER_OPENERS = new Set([
'mit', 'für', 'über', 'auf', 'in', 'bei', 'durch', 'seit', 'nach', 'aus', 'von', 'zu',
'im', 'am', 'zum', 'zur', 'vor', 'gegen', 'um', 'ohne', 'trotz', 'wegen', 'aufgrund',
'dank', 'während', 'per', 'laut', 'als', 'wie', 'da', 'weil', 'nachdem', 'hiermit',
'anbei', 'beiliegend', 'gerne', 'gern', 'sehr', 'bereits', 'schon', 'momentan',
'derzeit', 'aktuell', 'nun', 'mittlerweile', 'inzwischen', 'ich', 'mein', 'meine',
'meinen', 'meiner', 'meinem', 'meines',
]);
// Capitalize the first letter of a paragraph (every paragraph is a new sentence).
function capitalizeFirstLetter(p) {
return String(p).replace(/^(\s*)([a-zäöüß])/, (_, lead, ch) => lead + ch.toUpperCase());
}
// Lowercase the first word only if it's a clearly-safe sentence opener.
function lowercaseFirstIfOpener(p) {
const m = String(p).match(/^(\s*)([A-Za-zÄÖÜäöüß]+)/);
if (!m) return p;
const word = m[2];
const first = word[0];
const isUpper = (first >= 'A' && first <= 'Z') || 'ÄÖÜ'.includes(first);
if (isUpper && LOWER_OPENERS.has(word.toLowerCase())) {
return m[1] + first.toLowerCase() + p.slice(m[1].length + 1);
}
return p;
}
// Fix paragraph casing: all paragraphs start capitalized (new sentences); the
// first paragraph continues the sentence after the Anrede comma, so a safe
// opener there is lowercased.
function fixParagraphCasing(anrede, absaetze) {
if (!absaetze.length) return absaetze;
const out = absaetze.map(capitalizeFirstLetter);
if (anrede.trim().endsWith(',')) out[0] = lowercaseFirstIfOpener(out[0]);
return out;
}
// Defensive: the salutation must be "Sehr geehrte Frau <Nachname>" /
// "Sehr geehrter Herr <Nachname>" with ONLY the last name — never a first
// name or full name. Strip any leading given names the model may have added,
// but keep academic titles (Dr., Prof., …) and name particles (van, von, …).
const SAL_TITLES = new Set(['dr', 'prof', 'dipl', 'ing', 'rer', 'nat', 'mag', 'phd', 'habil', 'dr.habil']);
const SAL_PARTICLES = new Set(['van', 'de', 'von', 'zu', 'der', 'den', 'ten', 'da', 'di', 'del', 'della', 'la', 'le', 'vom', 'zur', 'mc', 'mac', 'al', 'el']);
function cleanAnrede(anrede) {
return String(anrede).replace(
/^(Sehr geehrte\s+Frau|Sehr geehrter\s+Herr)\s+(.+?)(,?\s*)$/i,
(m, sal, name, tail) => {
const words = String(name).trim().split(/\s+/);
if (words.length <= 1) return m; // already a single name
const kept = words.filter((w, i) => {
if (i === words.length - 1) return true; // last word = surname
const lw = w.toLowerCase().replace(/[.­]/g, '');
return SAL_TITLES.has(lw) || SAL_PARTICLES.has(lw); // keep titles / particles
});
if (kept.length === words.length) return m; // nothing to strip
return `${sal} ${kept.join(' ')}${tail}`;
},
);
}
// Defensive: never let an HR red-flag phrase reach the PDF, even if the model
// ignores the instruction.
const RED_FLAG = /(nicht\s*beendet|abgebrochen|abbruch|ohne\s*abschluss|arbeits(los|suchend)|gek(ü|ue)ndigt|entlassen)/i;
const safeNote = (s) => (RED_FLAG.test(s) ? '' : s);
function mapEdu(e) {
return {
zeitraum: toZeitraum(pick(e, ['zeitraum', 'zeit', 'dauer', 'period', 'jahr']) || e.zeitraum),
abschluss: str(pick(e, ['abschluss', 'titel', 'grad', 'degree', 'qualifikation', 'studiengang', 'fach', 'name'])),
institution: str(pick(e, ['institution', 'schule', 'hochschule', 'universitaet', 'universität', 'einrichtung', 'betrieb', 'organisation', 'ort'])),
zusatz: safeNote(str(pick(e, ['zusatz', 'note', 'hinweis', 'schwerpunkt', 'details']))),
};
}
const eduList = (obj, keys) => pickArray(obj, keys).map(mapEdu).filter((e) => e.abschluss || e.institution);
function normalizeResult(parsed) {
const k = parsed.kontakt || parsed.contact || {};
const a = parsed.anschreiben || parsed.cover_letter || {};
const l = parsed.lebenslauf || parsed.cv || parsed.resume || {};
// Typed education sections; fall back to a lumped "ausbildung"/"bildung" list.
let studium = eduList(l, ['studium', 'hochschulstudium', 'studies']);
let berufsausbildung = eduList(l, ['berufsausbildung', 'ausbildung', 'berufsabschluss', 'apprenticeship']);
let schulbildung = eduList(l, ['schulbildung', 'schulausbildung', 'schule', 'schooling']);
const weiterbildungen = eduList(l, ['weiterbildungen', 'weiterbildung', 'zertifikate', 'certifications', 'fortbildungen']);
if (!studium.length && !berufsausbildung.length && !schulbildung.length) {
const lumped = eduList(l, ['ausbildung', 'bildung', 'education', 'qualifikationen']);
berufsausbildung = lumped;
}
return {
headline: str(pick(parsed, ['headline', 'berufsbezeichnung', 'titel', 'position', 'beruf', 'rolle'])),
kontakt: {
email: str(pick(k, ['email', 'e_mail', 'mail'])),
telefon: str(pick(k, ['telefon', 'phone', 'tel', 'mobil'])),
ort: str(pick(k, ['ort', 'stadt', 'wohnort', 'city'])),
webseite: str(pick(k, ['webseite', 'website', 'web', 'url', 'linkedin'])),
geburtsdatum: str(pick(k, ['geburtsdatum', 'geboren', 'birthdate', 'dob', 'geburt'])),
fuehrerschein: str(pick(k, ['fuehrerschein', 'führerschein', 'driving_license', 'license', 'licence', 'fuehrerscheinklasse'])),
},
anschreiben: (() => {
const anrede = cleanAnrede(str(pick(a, ['anrede', 'salutation'])));
const absaetze = pickArray(a, ['absaetze', 'absätze', 'paragraphs', 'text', 'inhalt']).map(str).filter(Boolean);
const e = a.empfaenger || a.recipient || a.empfänger || {};
return {
empfaenger: {
firma: str(pick(e, ['firma', 'unternehmen', 'company', 'name'])),
adresse: str(pick(e, ['adresse', 'strasse', 'straße', 'street', 'anschrift'])),
ort: str(pick(e, ['ort', 'plz_ort', 'plzort', 'stadt', 'city', 'plz'])),
ansprechpartner: str(pick(e, ['ansprechpartner', 'ansprechperson', 'kontaktperson', 'contact'])),
},
betreff: str(pick(a, ['betreff', 'subject', 'titel'])),
anrede,
absaetze: fixParagraphCasing(anrede, absaetze),
gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])),
};
})(),
email: (() => {
const em = parsed.email || parsed.email_anschreiben || parsed.mail || parsed.begleitmail || {};
if (typeof em === 'string') return { betreff: '', text: str(em) };
return {
betreff: str(pick(em, ['betreff', 'subject', 'titel'])),
text: str(pick(em, ['text', 'body', 'inhalt', 'nachricht', 'mailtext', 'anschreiben'])),
};
})(),
lebenslauf: {
profil: str(pick(l, ['profil', 'kurzprofil', 'zusammenfassung', 'summary', 'ueberblick', 'über_mich'])),
berufserfahrung: pickArray(l, ['berufserfahrung', 'erfahrung', 'work_experience', 'experience', 'stationen'])
.map((e) => ({
zeitraum: toZeitraum(pick(e, ['zeitraum', 'zeit', 'dauer', 'period']) || e.zeitraum),
titel: str(pick(e, ['titel', 'position', 'rolle', 'taetigkeit', 'jobtitel', 'bezeichnung', 'job'])),
firma: str(pick(e, ['firma', 'arbeitgeber', 'unternehmen', 'company', 'organisation'])),
punkte: pickArray(e, ['punkte', 'aufgaben', 'taetigkeiten', 'highlights', 'bullets', 'details'])
.map((p) => safeNote(str(p))).filter(Boolean),
}))
.filter((e) => e.titel || e.firma),
studium,
berufsausbildung,
schulbildung,
weiterbildungen,
kenntnisse: pickArray(l, ['kenntnisse', 'skills', 'faehigkeiten', 'fähigkeiten', 'kompetenzen'])
.map((s) => (typeof s === 'object' ? str(pick(s, ['name', 'skill', 'bezeichnung'])) : str(s)))
.filter(Boolean),
sprachen: pickArray(l, ['sprachen', 'languages'])
.map((e) => (typeof e === 'string'
? { sprache: str(e), niveau: '' }
: { sprache: str(pick(e, ['sprache', 'name', 'language'])), niveau: str(pick(e, ['niveau', 'level', 'stufe'])) }))
.filter((e) => e.sprache),
hobbys: pickArray(l, ['hobbys', 'hobbies', 'interessen', 'interests'])
.map((s) => (typeof s === 'object' ? str(pick(s, ['name', 'bezeichnung'])) : str(s)))
.filter(Boolean),
},
};
}
// ===========================================================================
// 2. Shared PDF primitives
// ===========================================================================
const PT = 0.352778; // pt -> mm
const PAGE = { w: 210, h: 297 };
const MIN_SCALE = 0.6;
// Low-level text writer. `cur` is a {y} cursor; advances it. `dry` = measure only.
function write(doc, S, dry, cur, text, o) {
const pt = o.pt;
const factor = o.factor == null ? 1.3 : o.factor;
if (text == null || text === '') return;
doc.setFont(doc.__sans, o.style || 'normal');
doc.setFontSize(pt * S);
if (o.charSpace) doc.setCharSpace(o.charSpace * S);
const content = o.upper ? String(text).toUpperCase() : String(text);
const lines = doc.splitTextToSize(content, o.width);
for (const ln of lines) {
if (!dry) {
doc.setTextColor(o.color[0], o.color[1], o.color[2]);
const drawX = o.align === 'right' ? o.right : o.x;
doc.text(ln, drawX, cur.y + pt * S * PT * 0.76, { align: o.align || 'left' });
}
cur.y += pt * S * PT * factor;
}
if (o.charSpace) doc.setCharSpace(0);
}
function rule(doc, S, dry, y, x1, x2, color, weight) {
if (dry) return;
doc.setDrawColor(color[0], color[1], color[2]);
doc.setLineWidth(weight * S);
doc.line(x1, y, x2, y);
}
// ===========================================================================
// Resume (Lebenslauf) — modern two-column layout
//
// A tinted left sidebar (photo, contact, competencies, languages, interests)
// and a wide main column (large name header, profile, timeline experience,
// education). This is the canonical premium German Lebenslauf: the recruiter
// reads the whole person in the first glance — face, role, core skills — and
// the career story reads top-to-bottom on a single, deliberate axis.
// ===========================================================================
const RV = { top: 20, bottom: 14 };
// --- Horizontal grid (fixed; only vertical rhythm scales to fit one page) ---
const SB_W = 68; // sidebar width
const SB_X = 10; // sidebar text left
const SB_R = SB_W - 10; // 58 sidebar text right
const SB_CW = SB_R - SB_X; // 48 sidebar content width
const MAIN_X = SB_W + 12; // 80 main column left (section labels)
const MAIN_R = PAGE.w - 15; // 195 main column right (right-aligned dates)
const BODY_X = MAIN_X + 8; // 88 body text left (hanging under labels)
const BODY_W = MAIN_R - BODY_X; // 107
const MAIN_W = MAIN_R - MAIN_X; // 115 full main width (name / heading rules)
// The palette (`t.rc` here, `t.lc` for the letter) comes from lib/design.js and
// is threaded through every draw call as `t`. The base stays a neutral grey
// scale — hierarchy is carried by weight, size and tracking, exactly as in the
// monochrome original. Only the accent (name, section labels, company names,
// markers, photo frame) is the user's choice, so no setting can flatten the
// typographic structure.
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
// Fit an applicant photo into its frame, preserving aspect ratio. The frame size
// is fixed (does not scale with the page-fit S) so the sidebar keeps a stable,
// confident anchor regardless of how much text is below it.
//
// Returns the frame box (w/h — what the layout reserves) *and* the draw box
// (dw/dh — what addImage gets). They differ for the round portrait: the frame is
// a square, and the image is scaled to *cover* it and centred, with the overflow
// clipped away by the circle. Scaling a non-square photo into a square frame
// instead would squash the face.
function fitPhoto(doc, foto, rund = false) {
const MISS = { ok: false, w: 0, h: 0, dw: 0, dh: 0 };
const MAXW = 42, MAXH = 52;
if (!foto || !foto.dataUrl) return MISS;
try {
const p = doc.getImageProperties(foto.dataUrl);
if (!(p.width > 0 && p.height > 0)) return MISS;
const ar = p.width / p.height;
if (rund) {
const D = MAXW; // square frame = circle diameter
const dw = ar >= 1 ? D * ar : D; // cover: the short side matches D
const dh = ar >= 1 ? D : D / ar;
return { ok: true, w: D, h: D, dw, dh };
}
let w = MAXW, h = w / ar;
if (h > MAXH) { h = MAXH; w = h * ar; }
return { ok: true, w, h, dw: w, dh: h };
} catch (e) { return MISS; }
}
// Each entry carries a `typ` so a layout can pick a matching icon; the classic
// sidebar just prints `text` and ignores it.
function buildContactLines(header) {
const lines = [];
if (header.email) lines.push({ typ: 'email', text: header.email });
if (header.telefon) lines.push({ typ: 'telefon', text: header.telefon });
const ort = cityName(header) || header.ort;
if (ort) lines.push({ typ: 'ort', text: ort });
if (header.webseite) lines.push({ typ: 'web', text: header.webseite });
if (header.geburtsdatum) lines.push({ typ: 'geburtstag', text: `Geb. ${header.geburtsdatum}` });
if (header.fuehrerschein) lines.push({ typ: 'fuehrerschein', text: `Führerschein ${header.fuehrerschein}` });
return lines;
}
// Sidebar section label: a compact tracked uppercase accent word. No underline
// rule — the sidebar stays clean; tracking and weight carry the structure.
function sbHeading(doc, t, S, dry, cur, title) {
const pt = 9;
const cs = 1.0 * S;
cur.y += 5 * S;
doc.setFont(doc.__sans, 'bold');
doc.setFontSize(pt * S);
const label = String(title).toUpperCase();
if (!dry) {
doc.setCharSpace(cs);
doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
doc.text(label, SB_X, cur.y + pt * S * PT * 0.76);
doc.setCharSpace(0);
}
cur.y += pt * S * PT + 4.6 * S;
}
function sbBullets(doc, t, S, dry, cur, items, pt = 8.7) {
const tx = SB_X + 3.4;
const tw = SB_CW - 3.4;
for (const it of items) {
doc.setFont(doc.__sans, 'normal');
doc.setFontSize(pt * S);
const lines = doc.splitTextToSize(String(it), tw);
lines.forEach((ln, i) => {
if (!dry) {
if (i === 0) {
const sz = 1.1 * S;
doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
doc.rect(SB_X + 0.2, cur.y + pt * S * PT * 0.42 - sz / 2, sz, sz, 'F');
}
doc.setTextColor(t.rc.onSide[0], t.rc.onSide[1], t.rc.onSide[2]);
doc.text(ln, tx, cur.y + pt * S * PT * 0.76);
}
cur.y += pt * S * PT * 1.32;
});
cur.y += 1.1 * S;
}
}
// Language row: name (left) + level (right-aligned in the sidebar column).
function sbLangRow(doc, t, S, dry, cur, s) {
const pt = 8.7;
doc.setFont(doc.__sans, 'normal');
doc.setFontSize(pt * S);
if (!dry) {
doc.setTextColor(t.rc.onSide[0], t.rc.onSide[1], t.rc.onSide[2]);
doc.text(String(s.sprache), SB_X, cur.y + pt * S * PT * 0.76);
if (s.niveau) {
doc.setTextColor(t.rc.onSideSub[0], t.rc.onSideSub[1], t.rc.onSideSub[2]);
doc.text(String(s.niveau), SB_R, cur.y + pt * S * PT * 0.76, { align: 'right' });
}
}
cur.y += pt * S * PT * 1.62;
}
function composeSidebar(doc, t, S, dry, { cv, header, foto }) {
const cur = { y: RV.top };
const photo = fitPhoto(doc, foto, t.foto.rund);
if (photo.ok) {
const px = (SB_W - photo.w) / 2;
const py = RV.top - 2;
if (!dry) {
doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]);
doc.setLineWidth(0.3 * S);
if (t.foto.rund) {
// Circular portrait: clip to the circle, draw the image centred and
// over-sized (cover), then trace the frame on top of the clipped edge.
const r = photo.w / 2;
const cx = px + r;
const cy = py + r;
doc.saveGraphicsState();
doc.circle(cx, cy, r, null);
doc.clip();
doc.discardPath();
doc.addImage(foto.dataUrl, foto.format || 'PNG',
cx - photo.dw / 2, cy - photo.dh / 2, photo.dw, photo.dh);
doc.restoreGraphicsState();
doc.circle(cx, cy, r, 'S');
} else {
doc.addImage(foto.dataUrl, foto.format || 'PNG', px, py, photo.w, photo.h);
doc.rect(px, py, photo.w, photo.h);
}
}
cur.y = py + photo.h + 7 * S;
}
const contact = buildContactLines(header);
if (contact.length) {
sbHeading(doc, t, S, dry, cur, 'Kontakt');
contact.forEach((line) => write(doc, S, dry, cur, line.text, { pt: 8.6, color: t.rc.onSideSub, x: SB_X, width: SB_CW, factor: 1.5 }));
}
if (cv.kenntnisse.length) {
sbHeading(doc, t, S, dry, cur, 'Kernkompetenzen');
sbBullets(doc, t, S, dry, cur, cv.kenntnisse);
}
if (cv.sprachen.length) {
sbHeading(doc, t, S, dry, cur, 'Sprachen');
cv.sprachen.forEach((s) => sbLangRow(doc, t, S, dry, cur, s));
}
if (cv.hobbys.length) {
sbHeading(doc, t, S, dry, cur, 'Interessen');
write(doc, S, dry, cur, cv.hobbys.join(' · '), { pt: 8.6, color: t.rc.onSide, x: SB_X, width: SB_CW, factor: 1.55 });
}
return cur.y;
}
// ---------------------------------------------------------------------------
// Main column
// ---------------------------------------------------------------------------
// Main section heading: tracked uppercase accent label, outdented over a
// full-width hairline whose first stretch is a heavier accent segment as wide
// as the label word — a precise, repeating anchor down the page.
function mHeading(doc, t, S, dry, cur, title) {
const pt = 11;
const cs = 0.9 * S;
cur.y += 5.5 * S;
doc.setFont(doc.__sans, 'bold');
doc.setFontSize(pt * S);
const label = String(title).toUpperCase();
const lineY = cur.y + pt * S * PT * 1.1;
if (!dry) {
doc.setCharSpace(cs);
doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
doc.text(label, MAIN_X, cur.y + pt * S * PT * 0.76);
doc.setCharSpace(0);
const wordW = doc.getTextWidth(label) + cs * label.length;
doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]);
doc.setLineWidth(0.3 * S);
doc.line(MAIN_X, lineY, MAIN_R, lineY);
doc.setDrawColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
doc.setLineWidth(1.1 * S);
doc.line(MAIN_X, lineY, Math.min(MAIN_X + wordW, MAIN_R), lineY);
}
cur.y += pt * S * PT + 4.6 * S;
}
function cvBullets(doc, t, S, dry, cur, items, x, w, pt = 9.2) {
const tx = x + 3.8;
const tw = w - 3.8;
for (const it of items) {
doc.setFont(doc.__sans, 'normal');
doc.setFontSize(pt * S);
const lines = doc.splitTextToSize(String(it), tw);
lines.forEach((ln, i) => {
if (!dry) {
if (i === 0) {
const sz = 1.15 * S;
doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
doc.rect(x + 0.2, cur.y + pt * S * PT * 0.42 - sz / 2, sz, sz, 'F');
}
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
doc.text(ln, tx, cur.y + pt * S * PT * 0.76);
}
cur.y += pt * S * PT * 1.32;
});
cur.y += 1.0 * S;
}
}
// One experience entry: an accent node on the timeline, bold role title with a
// right-aligned period, the company in accent, then the achievement bullets.
function cvExperience(doc, t, S, dry, cur, e) {
const cx = MAIN_X + 1.4; // node / track centre x
const titlePt = 11, datePt = 9.3;
const dateW = 30;
doc.setFont(doc.__sans, 'bold');
doc.setFontSize(titlePt * S);
const titleLines = doc.splitTextToSize(String(e.titel || ''), BODY_W - dateW);
const titleLineH = titlePt * S * PT * 1.16;
if (!dry) {
doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
doc.circle(cx, cur.y + titlePt * S * PT * 0.44, 1.5 * S, 'F');
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
titleLines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * titleLineH + titlePt * S * PT * 0.76));
if (e.zeitraum) {
doc.setFont(doc.__sans, 'normal');
doc.setFontSize(datePt * S);
doc.setTextColor(t.rc.sub[0], t.rc.sub[1], t.rc.sub[2]);
doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' });
}
}
cur.y += titleLineH * Math.max(1, titleLines.length);
if (e.firma) write(doc, S, dry, cur, e.firma, { pt: 9.6, color: t.rc.accent, x: BODY_X, width: BODY_W, factor: 1.24 });
if (e.punkte && e.punkte.length) { cur.y += 1.4 * S; cvBullets(doc, t, S, dry, cur, e.punkte, BODY_X, BODY_W); }
cur.y += 5 * S;
}
// The full Berufserfahrung section, threaded on a subtle vertical timeline.
// The track is measured with a dry pass so it can be drawn behind the nodes in
// one clean stroke, then the entries render on top.
function composeTimeline(doc, t, S, dry, cur, entries) {
const startY = cur.y;
if (!dry) {
const probe = { y: cur.y };
entries.forEach((e) => cvExperience(doc, t, S, true, probe, e));
doc.setDrawColor(t.rc.track[0], t.rc.track[1], t.rc.track[2]);
doc.setLineWidth(0.5 * S);
doc.line(MAIN_X + 1.4, startY + 2 * S, MAIN_X + 1.4, probe.y - 5 * S);
}
entries.forEach((e) => cvExperience(doc, t, S, dry, cur, e));
}
function cvEducation(doc, t, S, dry, cur, e) {
const pt = 10.5, datePt = 9.3, dateW = 30;
doc.setFont(doc.__sans, 'bold');
doc.setFontSize(pt * S);
const lines = doc.splitTextToSize(String(e.abschluss || ''), BODY_W - dateW);
const lineH = pt * S * PT * 1.16;
if (!dry) {
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
lines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * lineH + pt * S * PT * 0.76));
if (e.zeitraum) {
doc.setFont(doc.__sans, 'normal');
doc.setFontSize(datePt * S);
doc.setTextColor(t.rc.sub[0], t.rc.sub[1], t.rc.sub[2]);
doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' });
}
}
cur.y += lineH * Math.max(1, lines.length);
if (e.institution) write(doc, S, dry, cur, e.institution, { pt: 9.5, color: t.rc.accent, x: BODY_X, width: BODY_W, factor: 1.2 });
if (e.zusatz) write(doc, S, dry, cur, e.zusatz, { pt: 9.3, color: t.rc.sub, x: BODY_X, width: BODY_W, factor: 1.24 });
cur.y += 4 * S;
}
// Big name + target role — the confident head the recruiter's eye lands on
// first. No divider rule here; the first section heading provides the structure.
function composeMainHeader(doc, t, S, dry, cur, header, titel) {
write(doc, S, dry, cur, header.name, { pt: 25, style: 'bold', color: t.rc.accent, x: MAIN_X, width: MAIN_W, factor: 1.06 });
if (titel) write(doc, S, dry, cur, titel, { pt: 12, color: t.rc.sub, x: MAIN_X, width: MAIN_W, factor: 1.36, charSpace: 0.3 });
cur.y += 2 * S;
}
function composeMain(doc, t, S, dry, { cv, header, titel }) {
const cur = { y: RV.top };
composeMainHeader(doc, t, S, dry, cur, header, titel);
if (cv.profil) {
mHeading(doc, t, S, dry, cur, 'Profil');
write(doc, S, dry, cur, cv.profil, { pt: 9.8, color: t.rc.ink, x: BODY_X, width: BODY_W, factor: 1.5 });
}
if (cv.berufserfahrung.length) {
mHeading(doc, t, S, dry, cur, 'Berufserfahrung');
composeTimeline(doc, t, S, dry, cur, cv.berufserfahrung);
}
[['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Weiterbildungen', cv.weiterbildungen]].forEach(([titel, entries]) => {
if (!entries || !entries.length) return;
mHeading(doc, t, S, dry, cur, titel);
entries.forEach((e) => cvEducation(doc, t, S, dry, cur, e));
});
return cur.y;
}
// Draw the sidebar band first, then the main column, then the sidebar text —
// so the tint sits underneath, and the PDF text stream leads with the name and
// career story (better for résumé parsers) while positions stay absolute.
function composeCV(doc, t, S, dry, ctx) {
if (!dry) {
doc.setFillColor(t.rc.sidebarBg[0], t.rc.sidebarBg[1], t.rc.sidebarBg[2]);
doc.rect(0, 0, SB_W, PAGE.h, 'F');
}
const mainBottom = composeMain(doc, t, S, dry, ctx);
const sideBottom = composeSidebar(doc, t, S, dry, ctx);
return Math.max(mainBottom, sideBottom);
}
// The user's font-size choice (t.scale) is the *starting* scale, not the final
// one: we measure at that size and shrink from there if the content would spill
// onto a second page. So "110 %" enlarges a sparse CV but can never break the
// one-page guarantee on a full one.
function renderCV(cv, header, titel, foto, t) {
const doc = makeDoc(t);
const ctx = { cv, header, titel, foto };
const social = t.layout === 'social';
const compose = social ? composeCVSocial : composeCV;
const top = social ? SO.top : RV.top;
const bottom = social ? SO.bottom : RV.bottom;
const S0 = t.scale;
const need = compose(doc, t, S0, true, ctx) - top;
const avail = PAGE.h - top - bottom;
let S = S0;
if (need > avail) S = Math.max(MIN_SCALE, S0 * (avail / need) * 0.99);
compose(doc, t, S, false, ctx);
return Buffer.from(doc.output('arraybuffer'));
}
// ===========================================================================
// "Social media" layout — the playful alternative
//
// A media-kit look: a tinted page, a round portrait, a script greeting over a
// huge name, hearts as section markers, rounded white cards for every entry and
// pill chips for skills and languages. Loud on purpose — meant for creative and
// social-media roles, not for a bank.
//
// It reuses the same machinery as the classic layout (dry-run measuring, the
// one-page auto-scale, the shared `write` helper), so it inherits the same
// guarantees. Only the drawing is different.
// ===========================================================================
const SO = {
mx: 15, // page margin
get r() { return PAGE.w - this.mx; },
get w() { return PAGE.w - 2 * this.mx; },
top: 16,
bottom: 14,
pad: 4.6, // inner padding of a card
radius: 3.2, // corner radius of cards
};
// --- Decorative primitives -------------------------------------------------
// A heart, drawn as two Bézier lobes. `s` is the width in mm; the shape is
// centred on (cx, cy). Used as the section marker and the bullet glyph, because
// the sans faces don't carry a heart glyph we could rely on.
function heart(doc, cx, cy, s, color) {
const w = s, h = s;
doc.setFillColor(color[0], color[1], color[2]);
// Two cubic lobes, drawn from the bottom tip. In jsPDF's `lines`, a segment's
// control points and end point are all relative to that segment's *start*.
// Left lobe: tip -> up the left side -> down into the top notch.
// Right lobe: notch -> up the right side -> back to the tip.
doc.lines(
[
[-w * 0.5, -h * 0.35, -w * 0.5, -h * 1.0, 0, -h * 0.65],
[w * 0.5, -h * 0.35, w * 0.5, h * 0.30, 0, h * 0.65],
],
cx, cy + h * 0.36, // bottom tip (shape sits centred on cy)
[1, 1], 'F', true
);
}
// A four-pointed sparkle, the other motif from the reference: a thin star made
// of four concave points.
function sparkle(doc, cx, cy, s, color) {
const a = s / 2;
doc.setFillColor(color[0], color[1], color[2]);
// Four points, each a cubic whose control points both sit at the centre — that
// pulls the edges inwards and gives the concave, twinkling star shape.
doc.lines(
[
[0, a, 0, a, a, a], // top -> right
[-a, 0, -a, 0, -a, a], // right -> bottom
[0, -a, 0, -a, -a, -a], // bottom -> left
[a, 0, a, 0, a, -a], // left -> top
],
cx, cy - a, [1, 1], 'F', true
);
}
// Contact icons, drawn as strokes so they scale with the page-fit and never
// depend on an icon font being present. Each one is centred on (cx, cy) and fits
// inside a square of side `s`.
function icon(doc, typ, cx, cy, s, color, S) {
const lw = Math.max(0.18, 0.16 * s);
doc.setDrawColor(color[0], color[1], color[2]);
doc.setFillColor(color[0], color[1], color[2]);
doc.setLineWidth(lw);
const h = s / 2;
if (typ === 'email') {
// Envelope: a rectangle with the flap drawn as two strokes to the centre.
const w = s, hh = s * 0.72;
const x = cx - w / 2, y = cy - hh / 2;
doc.roundedRect(x, y, w, hh, lw, lw, 'S');
doc.lines([[w / 2, hh * 0.52], [w / 2, -hh * 0.52]], x, y + lw * 0.4, [1, 1], 'S');
return;
}
if (typ === 'telefon') {
// Handset: a rounded body with the earpiece/mouthpiece implied by the fill.
const w = s * 0.66, hh = s * 0.96;
doc.roundedRect(cx - w / 2, cy - hh / 2, w, hh, w * 0.28, w * 0.28, 'S');
doc.circle(cx, cy + hh * 0.28, lw * 0.9, 'F');
doc.line(cx - w * 0.22, cy - hh * 0.3, cx + w * 0.22, cy - hh * 0.3);
return;
}
if (typ === 'ort') {
// Map pin: a circle with an open V tapering to the point. Drawn open (not a
// closed triangle) so no horizontal edge cuts across the circle.
const r = s * 0.32;
const top = cy - s * 0.16;
doc.circle(cx, top, r, 'S');
doc.lines(
[[r * 0.72, r * 1.55], [r * 0.72, -r * 1.55]],
cx - r * 0.72, top + r * 0.5, [1, 1], 'S', false
);
doc.circle(cx, top, r * 0.3, 'F');
return;
}
if (typ === 'web') {
// Globe: circle, equator, and one narrow ellipse as the meridian. Two
// mirrored arcs would cross into an "X" at this size.
const r = s * 0.46;
doc.circle(cx, cy, r, 'S');
doc.line(cx - r, cy, cx + r, cy);
doc.ellipse(cx, cy, r * 0.44, r, 'S');
return;
}
if (typ === 'geburtstag') {
// Calendar: a sheet with two rings and a header rule.
const w = s * 0.92, hh = s * 0.82;
const x = cx - w / 2, y = cy - hh / 2 + s * 0.08;
doc.roundedRect(x, y, w, hh, lw, lw, 'S');
doc.line(x, y + hh * 0.32, x + w, y + hh * 0.32);
doc.line(x + w * 0.28, y - s * 0.12, x + w * 0.28, y + hh * 0.08);
doc.line(x + w * 0.72, y - s * 0.12, x + w * 0.72, y + hh * 0.08);
return;
}
if (typ === 'fuehrerschein') {
// ID card: a landscape card with a portrait block and two text rules.
const w = s, hh = s * 0.68;
const x = cx - w / 2, y = cy - hh / 2;
doc.roundedRect(x, y, w, hh, lw, lw, 'S');
doc.circle(x + w * 0.28, y + hh * 0.42, hh * 0.17, 'S');
doc.line(x + w * 0.52, y + hh * 0.36, x + w * 0.84, y + hh * 0.36);
doc.line(x + w * 0.52, y + hh * 0.62, x + w * 0.84, y + hh * 0.62);
return;
}
// Fallback: a small dot, so an unknown type never draws garbage.
doc.circle(cx, cy, h * 0.3, 'F');
}
// A rounded card. Draws the frame only — the caller fills it.
function card(doc, t, S, x, y, w, h) {
doc.setFillColor(t.rc.cardBg[0], t.rc.cardBg[1], t.rc.cardBg[2]);
doc.setDrawColor(t.rc.cardBorder[0], t.rc.cardBorder[1], t.rc.cardBorder[2]);
doc.setLineWidth(0.4 * S);
doc.roundedRect(x, y, w, h, SO.radius * S, SO.radius * S, 'FD');
}
// A pill chip ("FOLLOWER" in the reference; here: periods, skills, languages,
// contact details). `item` is a plain string, or { text, icon } to prefix it
// with one of the contact icons. Returns its size so chips can be flowed.
function pill(doc, t, S, dry, x, y, item, opts = {}) {
const text = String((item && item.text !== undefined) ? item.text : item);
const ikon = (item && item.icon) || opts.icon || null;
const pt = opts.pt || 8.2;
const padX = 2.6 * S;
const h = pt * S * PT * 1.85;
const iconS = ikon ? pt * S * PT * 0.95 : 0;
const iconGap = ikon ? 1.5 * S : 0;
doc.setFont(doc.__sans, opts.style || 'bold');
doc.setFontSize(pt * S);
const w = doc.getTextWidth(text) + padX * 2 + iconS + iconGap;
if (!dry) {
const bg = opts.bg || t.rc.pillBg;
const ink = opts.ink || t.rc.pillInk;
doc.setFillColor(bg[0], bg[1], bg[2]);
doc.roundedRect(x, y, w, h, h / 2, h / 2, 'F');
if (ikon) icon(doc, ikon, x + padX + iconS / 2, y + h / 2, iconS, ink, S);
doc.setTextColor(ink[0], ink[1], ink[2]);
doc.text(text, x + padX + iconS + iconGap, y + h / 2 + pt * S * PT * 0.34);
}
return { w, h };
}
// Flow pill chips across the available width, wrapping into new rows.
function pillRow(doc, t, S, dry, cur, items, x, maxW, opts = {}) {
let cx = x;
const gap = 2.0 * S;
let rowH = 0;
for (const it of items) {
const probe = pill(doc, t, S, true, 0, 0, it, opts);
if (cx > x && cx + probe.w > x + maxW) { // wrap
cx = x;
cur.y += rowH + gap;
rowH = 0;
}
const p = pill(doc, t, S, dry, cx, cur.y, it, opts);
cx += p.w + gap;
rowH = Math.max(rowH, p.h);
}
cur.y += rowH;
}
// Section heading: centred, tracked, flanked by hairlines and a heart on each
// side — the reference's "♥ MEINE SOCIAL MEDIA KANÄLE ♥".
function soHeading(doc, t, S, dry, cur, title) {
const pt = 10.5;
cur.y += 6 * S;
const label = String(title).toUpperCase();
doc.setFont(doc.__sans, 'bold');
doc.setFontSize(pt * S);
const cs = 0.7 * S;
const midY = cur.y + pt * S * PT * 0.5;
if (!dry) {
// Centre the label by hand. jsPDF's align:'center' measures with
// getTextWidth(), which ignores charSpace, but *renders* with it: the text
// drifted right, so the left heart sat visibly further from the word than
// the right one. Measure the ink ourselves — the letter-spacing counts only
// between glyphs (n-1 gaps); the trailing one PDF emits after the last glyph
// is not ink and must not shift the centre.
const inkW = doc.getTextWidth(label) + cs * Math.max(0, label.length - 1);
const cx = PAGE.w / 2;
const textX = cx - inkW / 2;
doc.setCharSpace(cs);
doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
doc.text(label, textX, cur.y + pt * S * PT * 0.76);
doc.setCharSpace(0);
// Hearts anchored to the real ink edges, so both gaps are identical.
const gap = 5.2 * S;
const heartS = 3.1 * S;
const heartR = heartS / 2;
const leftHeartX = textX - gap - heartR;
const rightHeartX = textX + inkW + gap + heartR;
heart(doc, leftHeartX, midY, heartS, t.rc.accent);
heart(doc, rightHeartX, midY, heartS, t.rc.accent);
doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]);
doc.setLineWidth(0.4 * S);
const lineGap = 3.4 * S;
doc.line(SO.mx + 4 * S, midY, leftHeartX - heartR - lineGap, midY);
doc.line(rightHeartX + heartR + lineGap, midY, SO.r - 4 * S, midY);
}
cur.y += pt * S * PT + 5 * S;
}
// Bullet list with heart glyphs instead of squares.
function soBullets(doc, t, S, dry, cur, items, x, w, pt = 9) {
const tx = x + 4.4 * S;
const tw = w - 4.4 * S;
for (const it of items) {
doc.setFont(doc.__sans, 'normal');
doc.setFontSize(pt * S);
const lines = doc.splitTextToSize(String(it), tw);
lines.forEach((ln, i) => {
if (!dry) {
if (i === 0) heart(doc, x + 1.5 * S, cur.y + pt * S * PT * 0.45, 2.3 * S, t.rc.accent);
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
doc.text(ln, tx, cur.y + pt * S * PT * 0.76);
}
cur.y += pt * S * PT * 1.34;
});
cur.y += 1.1 * S;
}
}
// Measure a block by running its draw function dry, then draw the card behind it
// and the content on top. This is how every entry card gets an exact height
// without hard-coding one.
function soCard(doc, t, S, dry, cur, inner) {
const startY = cur.y;
const probe = { y: startY + SO.pad * S };
inner(probe, true);
const h = (probe.y - startY) + SO.pad * S;
if (!dry) {
card(doc, t, S, SO.mx, startY, SO.w, h);
inner({ y: startY + SO.pad * S }, false);
}
cur.y = startY + h + 3.2 * S;
}
// --- Résumé sections -------------------------------------------------------
// The hero: round portrait on the left, script greeting + huge name on the right,
// with the target role as a tracked, dot-separated line underneath.
function soHero(doc, t, S, dry, cur, { header, titel, foto }) {
const startY = cur.y;
const hasPhoto = Boolean(foto && foto.dataUrl);
const D = 44 * S; // portrait diameter
const px = SO.mx + 2 * S;
const textX = hasPhoto ? px + D + 9 * S : SO.mx + 2 * S;
const textW = SO.r - textX;
if (hasPhoto && !dry) {
const p = fitPhoto(doc, foto, true);
if (p.ok) {
const r = D / 2;
const cx = px + r;
const cy = startY + r;
// Soft blob behind the portrait, like the reference's organic shape.
doc.setFillColor(t.rc.cardBorder[0], t.rc.cardBorder[1], t.rc.cardBorder[2]);
doc.circle(cx + 1.6 * S, cy + 1.2 * S, r + 1.8 * S, 'F');
const dw = D * (p.dw / p.w);
const dh = D * (p.dh / p.h);
doc.saveGraphicsState();
doc.circle(cx, cy, r, null);
doc.clip();
doc.discardPath();
doc.addImage(foto.dataUrl, foto.format || 'PNG', cx - dw / 2, cy - dh / 2, dw, dh);
doc.restoreGraphicsState();
doc.setDrawColor(255, 255, 255);
doc.setLineWidth(1.2 * S);
doc.circle(cx, cy, r, 'S');
}
}
const cur2 = { y: startY + 2 * S };
// Script line — the one handwritten touch.
if (!dry) {
doc.setFont(doc.__script, 'normal');
doc.setFontSize(14 * S);
doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
doc.text('Hallo, ich bin', textX, cur2.y + 14 * S * PT * 0.76);
}
cur2.y += 14 * S * PT * 1.25;
// The name, as big as it fits.
const name = String(header.name || '').toUpperCase();
let namePt = 30;
doc.setFont(doc.__sans, 'bold');
for (; namePt > 15; namePt -= 0.5) {
doc.setFontSize(namePt * S);
if (doc.getTextWidth(name) <= textW) break;
}
write(doc, S, dry, cur2, name, { pt: namePt, style: 'bold', color: t.rc.accent, x: textX, width: textW, factor: 1.02 });
// Role line: "SYSTEMADMINISTRATOR · LINUX · NETZWERKE"
if (titel) {
cur2.y += 1.5 * S;
write(doc, S, dry, cur2, titel, {
pt: 8.8, style: 'bold', color: t.rc.sub, x: textX, width: textW,
factor: 1.35, charSpace: 0.55, upper: true,
});
}
if (!dry) {
sparkle(doc, SO.r - 6 * S, startY + 3 * S, 5.2 * S, t.rc.deko);
sparkle(doc, SO.r - 13 * S, startY + 9 * S, 3.0 * S, t.rc.deko);
}
const photoBottom = hasPhoto ? startY + D + 2 * S : startY;
cur.y = Math.max(cur2.y, photoBottom) + 2 * S;
}
function soExperience(doc, t, S, dry, cur, e) {
soCard(doc, t, S, dry, cur, (c, isDry) => {
const x = SO.mx + SO.pad * S;
const w = SO.w - 2 * SO.pad * S;
// Period as a pill, right-aligned on the first line.
let pillW = 0;
if (e.zeitraum) {
const probe = pill(doc, t, S, true, 0, 0, e.zeitraum);
pillW = probe.w;
pill(doc, t, S, isDry, SO.mx + SO.w - SO.pad * S - probe.w, c.y - 0.6 * S, e.zeitraum);
}
write(doc, S, isDry, c, e.titel || '', {
pt: 11.4, style: 'bold', color: t.rc.ink, x, width: w - pillW - 3 * S, factor: 1.2,
});
if (e.firma) write(doc, S, isDry, c, e.firma, { pt: 9.4, style: 'bold', color: t.rc.accent, x, width: w, factor: 1.3 });
if (e.punkte && e.punkte.length) {
c.y += 1.4 * S;
soBullets(doc, t, S, isDry, c, e.punkte, x, w);
}
});
}
function soEducation(doc, t, S, dry, cur, e) {
soCard(doc, t, S, dry, cur, (c, isDry) => {
const x = SO.mx + SO.pad * S;
const w = SO.w - 2 * SO.pad * S;
let pillW = 0;
if (e.zeitraum) {
const probe = pill(doc, t, S, true, 0, 0, e.zeitraum);
pillW = probe.w;
pill(doc, t, S, isDry, SO.mx + SO.w - SO.pad * S - probe.w, c.y - 0.6 * S, e.zeitraum);
}
write(doc, S, isDry, c, e.abschluss || '', {
pt: 10.6, style: 'bold', color: t.rc.ink, x, width: w - pillW - 3 * S, factor: 1.2,
});
if (e.institution) write(doc, S, isDry, c, e.institution, { pt: 9.2, style: 'bold', color: t.rc.accent, x, width: w, factor: 1.28 });
if (e.zusatz) write(doc, S, isDry, c, e.zusatz, { pt: 8.8, color: t.rc.sub, x, width: w, factor: 1.28 });
});
}
function composeCVSocial(doc, t, S, dry, ctx) {
const { cv, header, titel, foto } = ctx;
if (!dry && t.rc.pageBg) {
doc.setFillColor(t.rc.pageBg[0], t.rc.pageBg[1], t.rc.pageBg[2]);
doc.rect(0, 0, PAGE.w, PAGE.h, 'F');
}
const cur = { y: SO.top };
soHero(doc, t, S, dry, cur, { header, titel, foto });
// Contact + profile in one card — the "Willkommen in meiner bunten Welt" block.
const kontakt = buildContactLines(header);
if (cv.profil || kontakt.length) {
soCard(doc, t, S, dry, cur, (c, isDry) => {
const x = SO.mx + SO.pad * S;
const w = SO.w - 2 * SO.pad * S;
if (cv.profil) write(doc, S, isDry, c, cv.profil, { pt: 9.6, color: t.rc.ink, x, width: w, factor: 1.5 });
if (kontakt.length) {
c.y += 2 * S;
const chips = kontakt.map((k) => ({ text: k.text, icon: k.typ }));
pillRow(doc, t, S, isDry, c, chips, x, w, { style: 'normal', pt: 8.4 });
}
});
}
if (cv.berufserfahrung.length) {
soHeading(doc, t, S, dry, cur, 'Berufserfahrung');
cv.berufserfahrung.forEach((e) => soExperience(doc, t, S, dry, cur, e));
}
[['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Weiterbildungen', cv.weiterbildungen]]
.forEach(([label, entries]) => {
if (!entries || !entries.length) return;
soHeading(doc, t, S, dry, cur, label);
entries.forEach((e) => soEducation(doc, t, S, dry, cur, e));
});
if (cv.kenntnisse.length) {
soHeading(doc, t, S, dry, cur, 'Kernkompetenzen');
const c = { y: cur.y };
pillRow(doc, t, S, dry, c, cv.kenntnisse, SO.mx + 1 * S, SO.w - 2 * S);
cur.y = c.y + 2 * S;
}
if (cv.sprachen.length) {
soHeading(doc, t, S, dry, cur, 'Sprachen');
const c = { y: cur.y };
const items = cv.sprachen.map((s) => (s.niveau ? `${s.sprache}${s.niveau}` : s.sprache));
pillRow(doc, t, S, dry, c, items, SO.mx + 1 * S, SO.w - 2 * S, { style: 'normal' });
cur.y = c.y + 2 * S;
}
if (cv.hobbys.length) {
soHeading(doc, t, S, dry, cur, 'Interessen');
const c = { y: cur.y };
pillRow(doc, t, S, dry, c, cv.hobbys, SO.mx + 1 * S, SO.w - 2 * S, { style: 'normal' });
cur.y = c.y + 2 * S;
}
return cur.y;
}
// ===========================================================================
// Cover letter — clean, single-column DIN-5008 business letter (monochrome)
// ===========================================================================
const LET = { mx: 25, mr: 20, top: 24, bottom: 24 }; // DIN-ish margins (bottom reserves the footer strip)
const LET_R = PAGE.w - LET.mr; // 190
const LET_W = LET_R - LET.mx; // 165
// The letter shares the résumé's accent (see lib/design.js), so cover letter and
// CV keep reading as one deliberately designed set whatever the user picks.
// Extract just the town from a possibly-full address ("Musterstraße 5, 10115
// Berlin" / "10115 Berlin" / "Berlin" → "Berlin").
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) {
const s = settings || {};
// Persönliche Kontaktfelder kommen verbindlich aus den Settings (unter "Vorlagen"
// gepflegt) sie gewinnen gegenüber dem vom Modell extrahierten Wert. Ein leeres
// Settings-Feld fällt auf den KI-Wert zurück, damit nichts verloren geht.
return {
name: s.name || '',
adresse: s.adresse || '',
headline,
email: s.email || (kontakt && kontakt.email) || '',
telefon: s.telefon || (kontakt && kontakt.telefon) || '',
ort: s.ort || (kontakt && kontakt.ort) || '',
webseite: s.webseite || (kontakt && kontakt.webseite) || '',
geburtsdatum: s.geburtsdatum || (kontakt && kontakt.geburtsdatum) || '',
fuehrerschein: kontakt && kontakt.fuehrerschein,
};
}
// `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);
}
// `dokumente` = which documents to produce, e.g. ['anschreiben'] or both
// (the default). Anything not requested is neither asked of the model nor
// rendered — and, crucially, is not announced as an enclosure in the letter.
async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null, prompts = null, design = null, dokumente = null }) {
const doks = normalizeDokumente(dokumente);
const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen, prompts, dokumente: doks });
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 cvGewuenscht = doks.includes('lebenslauf');
const briefGewuenscht = doks.includes('anschreiben');
const cvPresent = cvGewuenscht && hasLebenslauf(data.lebenslauf);
// "Anlagen" list for the letter: the Lebenslauf only when one is actually
// produced, then any static extras. Never promise an enclosure we don't send.
const anlagen = [
...(cvPresent ? ['Lebenslauf'] : []),
...zusatzAnlagen.filter(Boolean),
];
if (briefGewuenscht && 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 { host: ollamaHost, model: ollamaModel, timeoutMs: ollamaTimeoutMs, apiKey } = config.ollama();
if (!apiKey) throw new Error('Kein Ollama-API-Schlüssel konfiguriert (unter „Einstellungen“ eintragen).');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ollamaTimeoutMs);
let res;
try {
res = await fetch(`${ollamaHost}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: ollamaModel,
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(ollamaTimeoutMs / 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, ... }.
// `typ` = 'antwort' (default) for a normal reply, or 'absage' for a polite, short
// rejection because the applicant accepted another position.
//
// `hinweise` is what the user typed into the prompt field above the reply form
// ("Termin am Dienstag zusagen, nach dem Gehaltsrahmen fragen"). It is the point
// of the whole call: without it the model can only produce a generic reply, so it
// is passed as a binding instruction, not as background colour.
//
// `entwurf` is the draft currently in the reply box. When present, the model
// *revises* that text along the new instructions instead of starting over — that
// is how the field is actually used ("gleiche Antwort, aber kürzer und förmlicher"),
// and re-generating from scratch would throw the user's own edits away.
async function generateEmailReply({
incoming, job = {}, settings = {}, hinweise = '', entwurf = '', typ = 'antwort', prompts = null,
}) {
const name = (settings && settings.name) || 'der Bewerber';
const isAbsage = typ === 'absage';
const anweisung = String(hinweise || '').trim().slice(0, 2000);
const bisher = String(entwurf || '').trim().slice(0, 6000);
// Beide Blöcke stehen NACH der eingegangenen Mail und VOR der Aufgabe: das
// Zuletztgelesene wiegt beim Modell am schwersten, und genau das soll die
// Anweisung des Nutzers sein.
const anweisungsBlock = anweisung
? `# Anweisungen von ${name} für diese Antwort (verbindlich)\n` +
`${anweisung}\n\n` +
`Setze diese Anweisungen vollständig um - sie bestimmen den Inhalt der E-Mail und haben ` +
`Vorrang vor allgemeinen Höflichkeitsformeln. Was dort nicht steht und sich nicht aus der ` +
`eingegangenen Nachricht ergibt, gehört nicht in die Antwort: keine zusätzlichen Zusagen, ` +
`Termine oder Angaben dazuerfinden. Ist eine Anweisung unklar oder fehlt eine konkrete ` +
`Angabe, setze einen Platzhalter in eckigen Klammern, statt etwas anzunehmen.\n\n`
: '';
const entwurfsBlock = bisher
? `# Bisheriger Entwurf\n${bisher}\n\n` +
`Überarbeite DIESEN Entwurf gemäß den Anweisungen oben. Behalte bei, was passt - schreibe ` +
`nicht ohne Not alles neu, und verwirf keine inhaltlichen Punkte des Entwurfs, sofern die ` +
`Anweisungen das nicht verlangen.\n\n`
: '';
// 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` +
anweisungsBlock +
entwurfsBlock +
`# Aufgabe\n` +
(bisher ? 'Gib den überarbeiteten Entwurf als vollständige E-Mail zurück. ' : '') +
`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` +
anweisungsBlock +
entwurfsBlock +
`# Aufgabe\n` +
(bisher ? 'Gib den überarbeiteten Entwurf als vollständige E-Mail zurück. ' : '') +
`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'])),
};
}
// ===========================================================================
// AI job-search guard rails ("Feinschliff")
// ===========================================================================
const FEINSCHLIFF_SCHEMA = {
type: 'object',
properties: {
berufsbezeichnungen: { type: 'array', items: { type: 'string' } },
zusatz_begriffe: { type: 'array', items: { type: 'string' } },
ausschluesse: { type: 'array', items: { type: 'string' } },
},
required: ['berufsbezeichnungen', 'zusatz_begriffe', 'ausschluesse'],
};
// How many terms per field we keep. The two lists end up verbatim in the search
// agent's prompt, so a long one dilutes the search instead of sharpening it.
const FEINSCHLIFF_MAX = 8;
// Suggest the two guard-rail lists of a Suchprofil ("Zusätzlich berücksichtigen"
// / "Ausschließen") from the applicant's own base documents. `unterlagen` is the
// concatenated text of their Vorlagen (Lebenslauf, Kurzprofil, ...), `profil` the
// current Suchprofil (modus + cities give the model the search's frame).
// Returns { zusatz_begriffe, ausschluesse } as comma-separated strings — exactly
// the shape the two textareas and the DB hold.
async function generateFeinschliff({ unterlagen, profil = {}, prompts = null }) {
const text = String(unterlagen || '').trim();
if (!text) {
throw new Error('Keine Basis-Unterlagen hinterlegt - ohne sie kann die KI nichts ableiten.');
}
const modusText = {
regional: 'regional (nur Stellen mit Arbeitsort in den genannten Städten)',
remote: '100 % Remote (deutschlandweit, ortsunabhängig)',
beides: 'regional in den genannten Städten und zusätzlich 100 % Remote deutschlandweit',
}[profil.modus] || 'regional';
const staedte = (profil.staedte || []).join(', ');
const system = promptStore.get(prompts, 'jobsuche_feinschliff');
const user =
`# Basis-Unterlagen des Bewerbers\n${text.slice(0, 6000)}\n\n` +
`# Rahmen der Suche\n` +
`Suchmodus: ${modusText}\n` +
(staedte ? `Städte: ${staedte}\n` : '') +
`\n# Aufgabe\n` +
`Schlage die drei Listen der Stellensuche vor:\n` +
`- "berufsbezeichnungen": die Stellentitel, unter denen der Bewerber suchen sollte - das sind die ` +
`Suchanfragen und damit das Wichtigste. Nimm die Titel, unter denen SEINE Tätigkeit real ` +
`ausgeschrieben wird, und decke die üblichen Varianten derselben Rolle ab (z. B. ` +
`"Systemadministrator", "IT-Administrator", "Fachinformatiker Systemintegration"). Bleib bei dem, ` +
`was sein Erfahrungsniveau hergibt - keine Titel, für die er erkennbar zu wenig oder zu viel ` +
`Erfahrung hat, und keine reinen Technologie-Wörter.\n` +
`- "zusatz_begriffe": KEINE Stellentitel, sondern Kontext, der einen Treffer zusätzlich attraktiv ` +
`macht und aus den Unterlagen belegbar ist - Branchen, Arbeitsumfelder, Arbeitszeitmodelle ` +
`(z. B. "Rechenzentrum", "Systemhaus", "Teilzeit").\n` +
`- "ausschluesse": Kriterien, bei denen ein Treffer verworfen werden soll (z. B. Anstellungsformen ` +
`oder Rollen, die erkennbar nicht zum Profil passen).\n` +
`Höchstens ${FEINSCHLIFF_MAX} Einträge je Liste, nach Wichtigkeit sortiert. Jeder Eintrag ist ein ` +
`kurzer Begriff (1-4 Wörter), kein Satz. Verwende ausschließlich den einfachen Bindestrich "-". ` +
`Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, ohne Markdown, ohne Code-Fences.`;
const parsed = await ollamaChatJSON({ system, user, schema: FEINSCHLIFF_SCHEMA, temperature: 0.4 });
// The model honours the schema keys only loosely; accept the usual synonyms and
// flatten each list into the comma-separated free text the profile stores. A
// model that answers with one comma-separated string instead of an array is
// split back apart rather than dropped.
const liste = (keys) => {
const roheListe = pickArray(parsed, keys);
const werte = roheListe.length ? roheListe : String(pick(parsed, keys) || '').split(/[,;\n]/);
const out = [];
for (const roh of werte) {
const v = str(roh).replace(/^[-•*\s]+/, '').replace(/[,;]+$/, '').trim();
if (!v) continue;
if (out.some((x) => x.toLowerCase() === v.toLowerCase())) continue;
out.push(v);
if (out.length >= FEINSCHLIFF_MAX) break;
}
return out.join(', ');
};
return {
berufsbezeichnungen: liste(['berufsbezeichnungen', 'berufsbezeichnung', 'stellentitel', 'rollen', 'jobtitel', 'titel']),
zusatz_begriffe: liste(['zusatz_begriffe', 'zusatzbegriffe', 'zusatz', 'begriffe', 'einschluesse']),
ausschluesse: liste(['ausschluesse', 'ausschlüsse', 'ausschlusskriterien', 'exclude', 'ausschluss']),
};
}
// ===========================================================================
// 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,
generateFeinschliff,
generateTailoredTexts,
renderLebenslaufPdf,
renderAnschreibenPdf,
renderDesignVorschau,
DOKUMENT_TYPEN,
normalizeDokumente,
};