Files
jobbi-bewerbung/lib/documents.js
T
thomasandClaude Opus 4.8 21608b8ca7 Sunny: Anschreiben als Gegenstück zum Poster-Lebenslauf gestalten
Der Brief trug bisher nur dieselbe Farbe wie der Lebenslauf, nicht dieselbe
Sprache. Jetzt bekommt er denselben Kopf: Name plakativ auf dem Farbband, Ziel-
position als solides Badge, Kontaktdaten als weiße Chips mit Icons rechts
gestapelt, dazu Konfetti in den Randstreifen. Das Datum ist ein Pastell-Chip
neben dem Anschriftfeld statt einer einsamen rechtsbündigen Zeile, der Betreff
bekommt Balken und Größe einer Sektionsüberschrift, die Anrede die Akzentfarbe,
die Anlagen werden zu Chips, und der getippte Name liegt auf einem Pastell-
Streifen wie mit dem Marker gezogen – der Ersatz für die fehlende Handschrift.

DIN-5008 bleibt unangetastet: Anschriftfeld, Datum, Betreff, Anrede, Fließtext,
Gruß, Unterschrift und Anlagen stehen, wo ein deutscher Leser sie erwartet.

Zwei Dinge, die erst im Render auffielen:

- Die Konfetti-Punkte saßen in der linken Rinne direkt neben dem Namen und lasen
  sich als Aufzählungszeichen davor statt als Deko. Sie liegen jetzt rechts; nur
  ein Funkeln bleibt links, tief im Band und weit weg vom Namen.
- Die Kontaktzeile der Fußzeile lief bei langer Anschrift plus langer E-Mail über
  das Band hinaus und schob sich über die Punkte. Sie skaliert jetzt passend und
  lässt notfalls die Anschrift weg, damit Telefon und E-Mail lesbar bleiben.

suBar las seine Farbe fest aus der Lebenslauf-Palette (t.rc), obwohl der Brief
t.lc benutzt – das ging nur gut, weil beide Werte zufällig gleich sind. Die
Palette wird jetzt übergeben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:35:23 +02:00

2891 lines
129 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 { compose, box } = CV_LAYOUT[t.layout] || CV_LAYOUT.sidebar;
const top = box.top;
const bottom = box.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;
}
// ===========================================================================
// "Sunny" layout — the editorial poster
//
// White page, no sidebar. A pastel band across the top with the photo breaking
// out of it and the name set huge next to it; a solid badge for the target
// role; then the sections, each announced by an oversized display heading that
// sits under a pastel bar and alternates between the left and the right edge —
// so the page reads as a zig-zag rather than a list. Entries flow in two
// columns, languages get level bars.
//
// Same machinery as the other layouts (dry-run measuring, one-page auto-scale,
// the shared `write` helper); only the drawing differs.
// ===========================================================================
const SU = {
mx: 14, // page margin
get r() { return PAGE.w - this.mx; },
get w() { return PAGE.w - 2 * this.mx; },
top: 13,
bottom: 12,
gap: 8, // gutter between the two content columns
get col() { return (this.w - this.gap) / 2; },
pad: 5, // inner padding of the hero band
radius: 2.4,
};
// Column x for the two-column entry flow.
const suColX = (i) => SU.mx + (i % 2) * (SU.col + SU.gap);
// A pastel bar — the layout's one repeating motif. Sits on the same edge as the
// heading it announces and runs about two thirds of the way across, so the
// opposite corner stays open and the page keeps breathing. `p` is the palette
// (t.rc for the résumé, t.lc for the letter), so both documents draw the same
// motif from their own colours.
function suBar(doc, p, S, dry, y, align, frac = 0.62) {
if (dry) return;
const w = SU.w * frac;
const x = align === 'right' ? SU.r - w : SU.mx;
doc.setFillColor(p.band[0], p.band[1], p.band[2]);
doc.rect(x, y, w, 2.4 * S, 'F');
}
// A filled dot. The confetti scattered through the Sunny documents is built from
// these — a designer's flourish, kept to the margins where no text can collide.
function suDot(doc, cx, cy, r, color) {
doc.setFillColor(color[0], color[1], color[2]);
doc.circle(cx, cy, r, 'F');
}
// Confetti in the band's gutters — the strips between its edges and the text
// column, empty by construction, so the decoration can never run into a word.
//
// It all lives on the *right*: dots down the left gutter sat directly beside the
// name and read as bullet points in front of it, not as decoration. The only
// thing on the left is a sparkle low in the band, well clear of the name.
function suKonfetti(doc, p, S, bandY, bandH) {
const gx = SU.r - 3 * S;
suDot(doc, gx, bandY + bandH * 0.26, 1.4 * S, p.accent);
suDot(doc, gx, bandY + bandH * 0.48, 0.9 * S, [255, 255, 255]);
suDot(doc, gx, bandY + bandH * 0.66, 1.9 * S, [255, 255, 255]);
sparkle(doc, SU.mx + 3.4 * S, bandY + bandH - 5.5 * S, 4.4 * S, [255, 255, 255]);
}
// Display heading: the poster type. Set as large as the layout's rhythm allows
// and shrunk only if a long word (Berufsausbildung…) would not otherwise fit.
function suHeading(doc, t, S, dry, cur, title, align = 'left') {
cur.y += 6.5 * S;
suBar(doc, t.rc, S, dry, cur.y, align);
cur.y += 2.4 * S + 4.5 * S;
const label = String(title);
let pt = 27;
doc.setFont(doc.__sans, 'bold');
for (; pt > 15; pt -= 0.5) {
doc.setFontSize(pt * S);
if (doc.getTextWidth(label) <= SU.w) break;
}
write(doc, S, dry, cur, label, {
pt, style: 'bold', color: t.rc.accent, x: SU.mx, width: SU.w, factor: 1.1,
align: align === 'right' ? 'right' : 'left', right: SU.r,
});
cur.y += 3.4 * S;
}
// Bullets: a round accent marker hanging outside the text block. Left-aligned
// only — the right-aligned sections (Ausbildung) carry no bullets.
function suBullets(doc, t, S, dry, cur, items, x, w, pt = 8.6) {
const ind = 3.4 * S;
const tw = w - ind;
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.2 * S;
doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
doc.circle(x + 0.4 * S + sz / 2, cur.y + pt * S * PT * 0.42, sz / 2, 'F');
}
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
doc.text(ln, x + ind, cur.y + pt * S * PT * 0.76);
}
cur.y += pt * S * PT * 1.34;
});
cur.y += 1.2 * S;
}
}
// The role badge: a solid accent pill with the text knocked out white. Reused by
// the letter, so it takes its palette (`p` = t.rc or t.lc) as an argument.
function suBadge(doc, p, S, dry, cur, text, x, maxW, pt = 10) {
const label = String(text || '').trim();
if (!label) return;
const padX = 5 * S;
const h = pt * S * PT * 2.05;
doc.setFont(doc.__sans, 'bold');
doc.setFontSize(pt * S);
// Long headlines would otherwise run out of the badge — shrink to fit rather
// than wrap, a badge is one line by definition.
let size = pt;
for (; size > 6.5; size -= 0.3) {
doc.setFontSize(size * S);
if (doc.getTextWidth(label) + 2 * padX <= maxW) break;
}
const w = Math.min(doc.getTextWidth(label) + 2 * padX, maxW);
if (!dry) {
doc.setFillColor(p.pillBg[0], p.pillBg[1], p.pillBg[2]);
doc.roundedRect(x, cur.y, w, h, h / 2, h / 2, 'F');
doc.setTextColor(p.pillInk[0], p.pillInk[1], p.pillInk[2]);
doc.text(label, x + w / 2, cur.y + h / 2 + size * S * PT * 0.35, { align: 'center' });
}
cur.y += h;
}
// How full a language's level bar is drawn. The fraction comes from the user's
// own niveau text — nothing is invented; an unknown wording lands on a neutral
// middle so the bar never claims more than the CV says. Ordered longest-match
// first ("sehr gut" before "gut").
const NIVEAU_STUFEN = [
[/mutter|native|c2/i, 1],
[/verhandlungssicher|fliess?end|fließend|c1/i, 0.9],
[/sehr gut|b2/i, 0.78],
[/\bgut\b|b1/i, 0.62],
[/grund|basis|a2/i, 0.45],
[/a1|anfänger|anfaenger/i, 0.3],
];
function niveauLevel(niveau) {
const s = String(niveau || '');
for (const [re, v] of NIVEAU_STUFEN) if (re.test(s)) return v;
return 0.7;
}
// One language row: the name right-aligned against a rail, the level as a filled
// rounded bar — the reference's skill meters, driven by real data.
function suLangRow(doc, t, S, dry, cur, x, w, s) {
const pt = 9;
const labelW = w * 0.44;
const barX = x + labelW + 3 * S;
const barW = w - labelW - 3 * S;
const barH = 3.4 * S;
const baseline = cur.y + pt * S * PT * 0.76;
if (!dry) {
doc.setFont(doc.__sans, 'bold');
doc.setFontSize(pt * S);
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
doc.text(String(s.sprache || ''), x + labelW, baseline, { align: 'right' });
const barY = cur.y + pt * S * PT * 0.42 - barH / 2;
doc.setFillColor(t.rc.track[0], t.rc.track[1], t.rc.track[2]);
doc.setDrawColor(t.rc.cardBorder[0], t.rc.cardBorder[1], t.rc.cardBorder[2]);
doc.setLineWidth(0.3 * S);
doc.roundedRect(barX, barY, barW, barH, barH / 2, barH / 2, 'FD');
const fill = Math.max(barH, barW * niveauLevel(s.niveau));
doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
doc.roundedRect(barX, barY, fill, barH, barH / 2, barH / 2, 'F');
}
cur.y += pt * S * PT * 1.95;
}
// Flow entries into two columns: measure both cells of a row dry, draw them, and
// advance by the taller one — so the next row starts on a clean baseline even
// when one entry has three bullets and its neighbour has one.
function suTwoColumns(doc, t, S, dry, cur, entries, drawCell) {
for (let i = 0; i < entries.length; i += 2) {
const row = entries.slice(i, i + 2);
let rowBottom = cur.y;
row.forEach((e, j) => {
const c = { y: cur.y };
drawCell(doc, t, S, dry, c, e, suColX(j), SU.col);
rowBottom = Math.max(rowBottom, c.y);
});
cur.y = rowBottom + 3.5 * S;
}
}
function suExperience(doc, t, S, dry, cur, e, x, w) {
write(doc, S, dry, cur, e.titel || '', { pt: 12.5, style: 'bold', color: t.rc.accent, x, width: w, factor: 1.14 });
if (e.zeitraum) write(doc, S, dry, cur, e.zeitraum, { pt: 8.6, color: t.rc.sub, x, width: w, factor: 1.3 });
if (e.firma) write(doc, S, dry, cur, e.firma, { pt: 9, style: 'bold', color: t.rc.ink, x, width: w, factor: 1.32 });
if (e.punkte && e.punkte.length) {
cur.y += 1.8 * S;
suBullets(doc, t, S, dry, cur, e.punkte, x, w);
}
}
// Education mirrors the experience cell but is set right-aligned, following the
// section's own alignment — the zig-zag runs all the way down to the entries.
function suEducation(doc, t, S, dry, cur, e, x, w) {
const o = { x, width: w, align: 'right', right: x + w };
write(doc, S, dry, cur, e.abschluss || '', { pt: 11.5, style: 'bold', color: t.rc.accent, factor: 1.14, ...o });
const zeile = [e.institution, e.zeitraum].filter(Boolean).join(' | ');
if (zeile) write(doc, S, dry, cur, zeile, { pt: 9, color: t.rc.ink, factor: 1.34, ...o });
if (e.zusatz) write(doc, S, dry, cur, e.zusatz, { pt: 8.6, color: t.rc.sub, factor: 1.32, ...o });
}
// The hero: a pastel band with a white card inside it, the photo breaking out of
// the band top and bottom on the left, name + role badge + profile on the right.
// The contact details ride above the band as a right-aligned icon row.
function suHero(doc, t, S, dry, cur, { header, titel, foto, profil }) {
const contact = buildContactLines(header);
const photo = fitPhoto(doc, foto, t.foto.rund);
const hasPhoto = photo.ok;
const OVER = 6 * S; // how far the portrait overhangs the band, top and bottom
// --- Contact row above the band, right-aligned, wrapping if it must ---
if (contact.length) {
const pt = 8.4;
const iconS = 3.2 * S;
const gap = 6 * S;
doc.setFont(doc.__sans, 'normal');
doc.setFontSize(pt * S);
const items = contact.map((k) => ({ ...k, w: iconS + 1.8 * S + doc.getTextWidth(k.text) }));
const rows = [[]];
let used = 0;
for (const it of items) {
const need = it.w + (rows[rows.length - 1].length ? gap : 0);
if (used + need > SU.w && rows[rows.length - 1].length) { rows.push([]); used = 0; }
else used += need;
rows[rows.length - 1].push(it);
}
for (const row of rows) {
const rowW = row.reduce((a, it) => a + it.w, 0) + gap * (row.length - 1);
let x = SU.r - rowW;
const midY = cur.y + pt * S * PT * 0.5;
for (const it of row) {
if (!dry) {
icon(doc, it.typ, x + iconS / 2, midY, iconS, t.rc.accent, S);
doc.setTextColor(t.rc.sub[0], t.rc.sub[1], t.rc.sub[2]);
doc.text(it.text, x + iconS + 1.8 * S, cur.y + pt * S * PT * 0.76);
}
x += it.w + gap;
}
cur.y += pt * S * PT * 1.7;
}
// Clear the portrait's overhang: the photo is drawn above the band's top
// edge, and would otherwise run straight through the contact line.
cur.y += hasPhoto ? OVER + 2 * S : 2 * S;
}
// --- The band, sized to whatever the text column needs ---
const PW = 40 * S; // photo column width
const bandY = cur.y;
const px = SU.mx + 6 * S; // photo left edge
// With a photo the white card starts *behind* it: the portrait is opaque, so
// the card's left edge is never seen, and no white sliver escapes to the left
// of the photo — there, the pastel band shows through, as it should.
const cardX = hasPhoto ? px + PW * 0.5 : SU.mx + SU.pad * S;
const cardR = SU.r - SU.pad * S;
const textX = hasPhoto ? px + PW + 6 * S : cardX + SU.pad * S;
const textW = cardR - SU.pad * S - textX;
// Measure the text column first: the band grows around it.
const measure = (c, isDry) => {
c.y += SU.pad * S;
const name = String(header.name || '');
let namePt = 30;
doc.setFont(doc.__sans, 'bold');
for (; namePt > 16; namePt -= 0.5) {
doc.setFontSize(namePt * S);
// Fit the *longest word*, not the whole name: "Sarah Bertram" is allowed
// to break into two lines, but a single word must never be clipped.
const longest = name.split(/\s+/).reduce((a, b) => (doc.getTextWidth(a) > doc.getTextWidth(b) ? a : b), '');
if (doc.getTextWidth(longest) <= textW) break;
}
write(doc, S, isDry, c, name, { pt: namePt, style: 'bold', color: t.rc.accent, x: textX, width: textW, factor: 1.0 });
if (titel) {
c.y += 2.6 * S;
suBadge(doc, t.rc, S, isDry, c, titel, textX, textW);
}
if (profil) {
c.y += 3 * S;
write(doc, S, isDry, c, profil, { pt: 8.8, color: t.rc.ink, x: textX, width: textW, factor: 1.45 });
}
c.y += SU.pad * S;
};
const probe = { y: bandY };
measure(probe, true);
let bandH = probe.y - bandY;
const minH = hasPhoto ? 46 * S : 0; // the band must never be shorter than the portrait's shoulders
bandH = Math.max(bandH, minH);
if (!dry) {
doc.setFillColor(t.rc.band[0], t.rc.band[1], t.rc.band[2]);
doc.rect(SU.mx, bandY, SU.w, bandH, 'F');
doc.setFillColor(t.rc.cardBg[0], t.rc.cardBg[1], t.rc.cardBg[2]);
const inset = SU.pad * S * 0.6;
doc.roundedRect(cardX, bandY + inset, cardR - cardX, bandH - 2 * inset,
SU.radius * S, SU.radius * S, 'F');
}
// The portrait sits on top of both, overhanging the band — the move that makes
// the header read as a poster rather than a header row. Square by default; a
// round portrait is centred on the band instead and ringed in white, so the
// photo-shape setting still means something here.
if (hasPhoto && !dry) {
const rund = t.foto.rund;
const fw = PW;
const fh = rund ? PW : bandH + 2 * OVER;
const fx = px;
const fy = rund ? bandY + (bandH - fh) / 2 : bandY - OVER;
// Cover the frame: scale to the larger ratio and clip the overflow away, so
// the face keeps its proportions in a frame of any shape.
const k = Math.max(fw / photo.dw, fh / photo.dh);
const dw = photo.dw * k;
const dh = photo.dh * k;
doc.saveGraphicsState();
if (rund) doc.circle(fx + fw / 2, fy + fh / 2, fw / 2, null);
else doc.rect(fx, fy, fw, fh, null);
doc.clip();
doc.discardPath();
doc.addImage(foto.dataUrl, foto.format || 'PNG', fx + (fw - dw) / 2, fy + (fh - dh) / 2, dw, dh);
doc.restoreGraphicsState();
if (rund) {
doc.setDrawColor(255, 255, 255);
doc.setLineWidth(1.2 * S);
doc.circle(fx + fw / 2, fy + fh / 2, fw / 2, 'S');
}
}
if (!dry) measure({ y: bandY }, false);
// The photo overhangs the bottom too — the first section must clear it.
cur.y = bandY + bandH + (hasPhoto ? OVER + 2 * S : 3 * S);
}
function composeCVSunny(doc, t, S, dry, ctx) {
const { cv, header, titel, foto } = ctx;
const cur = { y: SU.top };
// The profile is printed inside the hero band, not as a section of its own.
suHero(doc, t, S, dry, cur, { header, titel, foto, profil: cv.profil });
// Sections alternate their edge, and every heading picks up the alignment of
// the entries under it.
let flip = 0;
const seite = () => (flip++ % 2 === 0 ? 'left' : 'right');
if (cv.berufserfahrung.length) {
suHeading(doc, t, S, dry, cur, 'Berufserfahrung', seite());
suTwoColumns(doc, t, S, dry, cur, cv.berufserfahrung, suExperience);
}
const ausbildung = [
...(cv.studium || []),
...(cv.berufsausbildung || []),
...(cv.weiterbildungen || []),
];
if (ausbildung.length) {
// One "Ausbildung" heading for all of it — three display headings of this
// size in a row would eat the page, and the entries name their own degree.
suHeading(doc, t, S, dry, cur, 'Ausbildung', seite());
suTwoColumns(doc, t, S, dry, cur, ausbildung, suEducation);
}
if (cv.kenntnisse.length || cv.sprachen.length) {
suHeading(doc, t, S, dry, cur, 'Fähigkeiten', seite());
const startY = cur.y;
let bottom = startY;
// Left: the language meters. Right: the competencies as a bullet list —
// the reference's two-column skills block.
const hatSprachen = cv.sprachen.length > 0;
if (hatSprachen) {
const c = { y: startY };
cv.sprachen.forEach((s) => suLangRow(doc, t, S, dry, c, suColX(0), SU.col, s));
bottom = Math.max(bottom, c.y);
}
if (cv.kenntnisse.length) {
const c = { y: startY };
// Without languages the competencies get the full width, two columns deep.
const x = hatSprachen ? suColX(1) : SU.mx;
const w = hatSprachen ? SU.col : SU.w;
suBullets(doc, t, S, dry, c, cv.kenntnisse, x, w);
bottom = Math.max(bottom, c.y);
}
cur.y = bottom;
}
if (cv.hobbys.length) {
cur.y += 5 * S;
suBar(doc, t.rc, S, dry, cur.y, 'left', 0.28);
cur.y += 2.4 * S + 3 * S;
write(doc, S, dry, cur, `Interessen: ${cv.hobbys.join(' · ')}`,
{ pt: 8.8, color: t.rc.sub, x: SU.mx, width: SU.w, factor: 1.35 });
}
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);
}
}
}
// ===========================================================================
// Cover letter, Sunny variant — mirrors the poster résumé
//
// The same pastel band across the head, the same oversized name, the same badge
// and bars. The letter's substance stays DIN-5008: recipient block, date,
// subject, salutation, body, closing, signature, enclosures — a poster CV must
// still arrive with a letter a German HR department can read at a glance.
// ===========================================================================
function composeLetterSunny(doc, t, S, dry, cur, { letter, header, job, anlagen, signatur }) {
const x = SU.mx + 6 * S;
const right = SU.r - 6 * 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);
// --- Head: the CV's hero, minus the portrait. Name set as a poster on the
// pastel band, the role as a solid badge, the contact details as white
// chips stacked down the right — the letter is the résumé's twin, so a
// recruiter sees one designed set, not two documents that share a colour.
const bandY = cur.y;
const chips = buildContactLines(header)
.filter((k) => k.typ === 'email' || k.typ === 'telefon' || k.typ === 'ort' || k.typ === 'web')
.map((k) => ({ text: k.text, icon: k.typ }));
// Size the chips first: a long e-mail address must not push the name column
// into a one-word-per-line sliver. Shrink the chips until they fit their half.
let chipPt = 8.4;
let chipW = 0;
if (chips.length) {
const maxW = width * 0.46;
for (; chipPt > 6; chipPt -= 0.2) {
chipW = Math.max(...chips.map((c) => pill(doc, t, S, true, 0, 0, c, { style: 'normal', pt: chipPt }).w));
if (chipW <= maxW) break;
}
chipW = Math.min(chipW, maxW);
}
const nameW = width - (chips.length ? chipW + 5 * S : 0);
const inner = (c, isDry) => {
const top = c.y + SU.pad * S;
const cl = { y: top };
const name = String(header.name || '');
let namePt = 27;
doc.setFont(doc.__sans, 'bold');
for (; namePt > 14; namePt -= 0.5) {
doc.setFontSize(namePt * S);
// Fit the longest word: a two-part name may break, a single word may not.
const longest = name.split(/\s+/).reduce((a, b) => (doc.getTextWidth(a) > doc.getTextWidth(b) ? a : b), '');
if (doc.getTextWidth(longest) <= nameW) break;
}
write(doc, S, isDry, cl, name, { pt: namePt, style: 'bold', color: t.lc.accent, x, width: nameW, factor: 1.02 });
if (header.headline) {
cl.y += 2.6 * S;
suBadge(doc, t.lc, S, isDry, cl, header.headline, x, nameW);
}
const cr = { y: top + 1 * S };
for (const ch of chips) {
const probe = pill(doc, t, S, true, 0, 0, ch, { style: 'normal', pt: chipPt });
pill(doc, t, S, isDry, right - probe.w, cr.y, ch,
{ style: 'normal', pt: chipPt, bg: t.lc.cardBg, ink: t.lc.accent });
cr.y += probe.h + 1.8 * S;
}
c.y = Math.max(cl.y, cr.y - 1.8 * S) + SU.pad * S;
};
const probe = { y: bandY };
inner(probe, true);
const bandH = probe.y - bandY;
if (!dry) {
doc.setFillColor(t.lc.band[0], t.lc.band[1], t.lc.band[2]);
doc.rect(SU.mx, bandY, SU.w, bandH, 'F');
suKonfetti(doc, t.lc, S, bandY, bandH);
inner({ y: bandY }, false);
}
cur.y = bandY + bandH + 9 * S;
// --- Recipient (Anschriftfeld) with the date as a pastel chip on the same
// line — DIN keeps its address block, the date just stops being a lonely
// right-aligned string and becomes part of the layout's chip language.
const emp = letter.empfaenger || {};
const empFirma = emp.firma || job.firma || '';
const empOrt = emp.ort || job.ort || '';
const datum = stadt ? `${stadt}, ${today}` : today;
if (!dry) {
const p = pill(doc, t, S, true, 0, 0, datum, { pt: 8.6, style: 'normal' });
pill(doc, t, S, false, right - p.w, cur.y - 0.5 * S, datum,
{ pt: 8.6, style: 'normal', bg: t.lc.band, ink: t.lc.accent });
}
const empW = width * 0.6; // keep the address clear of the date chip
if (empFirma) write(doc, S, dry, cur, empFirma, { pt: 10.2, style: 'bold', color: t.lc.ink, x, width: empW, factor: 1.32 });
if (emp.ansprechpartner) write(doc, S, dry, cur, `z. Hd. ${emp.ansprechpartner}`, { pt: 10.2, color: t.lc.ink, x, width: empW, factor: 1.32 });
if (emp.adresse) write(doc, S, dry, cur, emp.adresse, { pt: 10.2, color: t.lc.ink, x, width: empW, factor: 1.32 });
if (empOrt) write(doc, S, dry, cur, empOrt, { pt: 10.2, color: t.lc.ink, x, width: empW, factor: 1.32 });
// --- Subject as a display heading under the bar: the same motif that opens
// every section of the résumé, so the letter's one headline lands with the
// same weight as "Berufserfahrung" does over there.
cur.y += 9 * S;
suBar(doc, t.lc, S, dry, cur.y, 'left', 0.34);
cur.y += 2.4 * S + 4.2 * S;
write(doc, S, dry, cur, betreff, { pt: 15, style: 'bold', color: t.lc.accent, x, width, factor: 1.18 });
// --- Salutation + body. The salutation carries the accent: it is the moment
// the letter starts speaking, and it echoes the coloured entry titles of
// the résumé.
cur.y += 5.5 * S;
if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.2, style: 'bold', color: t.lc.accent, x, width, factor: 1.4 }); cur.y += 2.8 * 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.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 = 54 * S, maxH = 23 * 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 {
// No signature image: the typed name gets a pastel highlight swiped behind
// it, like a marker stroke — the flourish that stands in for the missing
// handwriting instead of leaving an empty gap.
cur.y += 12 * S;
const pt = 10.6;
if (!dry) {
doc.setFont(doc.__sans, 'bold');
doc.setFontSize(pt * S);
const w = doc.getTextWidth(String(header.name || ''));
// Centre the swipe on the glyphs: `write` puts the baseline at
// cur.y + pt*PT*0.76, so a box hung off cur.y alone sits too low and the
// capitals poke out over its top edge.
const h = pt * S * PT * 1.2;
const y = cur.y + pt * S * PT * 0.76 - h * 0.72;
doc.setFillColor(t.lc.band[0], t.lc.band[1], t.lc.band[2]);
doc.roundedRect(x - 1.8 * S, y, w + 3.6 * S, h, 1.4 * S, 1.4 * S, 'F');
}
write(doc, S, dry, cur, header.name, { pt, style: 'bold', color: t.lc.accent, x, width, factor: 1.25 });
}
// --- Enclosures as chips, in the same pill language as the contact details ---
if (anlagen && anlagen.length) {
cur.y += 7 * S;
write(doc, S, dry, cur, anlagen.length > 1 ? 'Anlagen' : 'Anlage',
{ pt: 8.2, style: 'bold', color: t.lc.muted, x, width, factor: 1.4, charSpace: 0.5, upper: true });
cur.y += 1.4 * S;
const c = { y: cur.y };
pillRow(doc, t, S, dry, c, anlagen, x, width,
{ style: 'normal', pt: 8.4, bg: t.lc.band, ink: t.lc.accent });
cur.y = c.y;
}
// --- Footer: the contact strip on a pastel bar, in the reserved bottom margin ---
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 fh = 7.5 * S;
const fy = PAGE.h - SU.bottom - fh;
doc.setFillColor(t.lc.band[0], t.lc.band[1], t.lc.band[2]);
doc.rect(SU.mx, fy, SU.w, fh, 'F');
// Dots closing the strip at both ends — the head's confetti, answered.
suDot(doc, SU.mx + 4 * S, fy + fh / 2, 1.1 * S, t.lc.accent);
suDot(doc, SU.r - 4 * S, fy + fh / 2, 1.1 * S, t.lc.accent);
// Fit the strip to the band. A long address plus a long e-mail address
// overruns it at a fixed size — the line then slides out over the dots and
// off the band's edge. Shrink to fit; if even the floor is too wide, drop
// the postal address and keep the two ways to actually reach the person.
const raum = SU.w - 16 * S; // band minus the dots at both ends
doc.setFont(doc.__sans, 'normal');
const passt = (text, pt) => {
doc.setFontSize(pt * S);
return doc.getTextWidth(text) <= raum;
};
const fit = (text) => {
for (let pt = 8; pt > 5.4; pt -= 0.2) if (passt(text, pt)) return pt;
return null;
};
let strip = parts.join(' · ');
let fpt = fit(strip);
if (fpt === null && parts.length > 1) {
strip = parts.slice(1).join(' · '); // ohne Anschrift
fpt = fit(strip);
}
if (fpt === null) fpt = 5.4; // Notnagel: lieber klein als über den Rand
doc.setFontSize(fpt * S);
doc.setTextColor(t.lc.accent[0], t.lc.accent[1], t.lc.accent[2]);
doc.text(strip, PAGE.w / 2, fy + fh / 2 + fpt * S * PT * 0.35, { align: 'center' });
}
}
}
// ---------------------------------------------------------------------------
// Layout dispatch — the only place that knows which layout draws what.
// Adding a layout means adding a row here (plus its palette in lib/design.js).
// ---------------------------------------------------------------------------
const CV_LAYOUT = {
sidebar: { compose: composeCV, box: RV },
social: { compose: composeCVSocial, box: SO },
sunny: { compose: composeCVSunny, box: SU },
};
const LETTER_LAYOUT = {
sidebar: { compose: composeLetter, box: LET },
social: { compose: composeLetterSocial, box: SO },
sunny: { compose: composeLetterSunny, box: SU },
};
function renderSingleColumn(compose, t) {
const doc = makeDoc(t);
const { box } = LETTER_LAYOUT[t.layout] || LETTER_LAYOUT.sidebar;
const top = box.top;
const bottom = box.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 } = LETTER_LAYOUT[t.layout] || LETTER_LAYOUT.sidebar;
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,
};