Files
jobbi-bewerbung/lib/documents.js
T
thomasandClaude Opus 4.8 d20a265ebc Neues Layout "Tupfen": dezent, mit verstreuten Punkten
Weiße Seite, Name groß und mittig in Tinte, luftiger Blocksatz — und
korallfarbene Punkte in den Rändern, teils vom Seitenrand angeschnitten,
als einzige Farbe des Blattes. Der Lebenslauf greift das Motiv auf: die
Punkte markieren die Abschnitte, führen die Bullets und bilden als
Fünfer-Reihe die Sprach-Skala (gespeist aus niveauLevel(), also aus der
Formulierung des Lebenslaufs selbst).

Die Punkte skalieren bewusst nicht mit der Schrift: sie gehören zur Seite
wie die Ränder. Das Anschreiben reserviert dafür unten ein Band, in das
der Text nicht hineinläuft.

Der Brief behält das DIN-Anschriftenfeld, das der Vorlage fehlt — es sitzt
in deren zweispaltiger Meta-Zeile (Empfänger links, Datum und Kontakt
rechts), so bleibt die Komposition erhalten und die Bewerbung deutsch.

Dazu:
- neue Akzentfarbe "Koralle"
- write() kann optional Blocksatz. jsPDF stretcht eine Zeile nur, wenn es
  weiß, dass eine weitere folgt — der Absatz geht deshalb in einem Aufruf
  rüber, wodurch die Schlusszeile ungestretcht bleibt.
- Infobox unter /vorlagen beschreibt jetzt auch Sunny (fehlte) und Tupfen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:15:20 +02:00

3407 lines
150 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.
//
// `o.justify` sets the block flush on both edges, but never its closing line — a
// justified last line is stretched across the full measure and tears the
// paragraph apart. Opt-in, so the layouts that want a ragged right keep it.
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);
const lead = pt * S * PT * factor;
if (o.justify && !o.align) {
// The whole block goes over in a single call, because jsPDF only stretches a
// line once it knows another one follows: hand it one line at a time and it
// justifies nothing. Handed the array, it sets every line flush but the
// closing one — which is exactly right, a stretched last line tears the
// paragraph apart. Its own line advance has to be taught our leading first.
if (!dry) {
const vorher = doc.getLineHeightFactor();
doc.setLineHeightFactor(factor);
doc.setTextColor(o.color[0], o.color[1], o.color[2]);
doc.text(lines, o.x, cur.y + pt * S * PT * 0.76, { align: 'justify', maxWidth: o.width });
doc.setLineHeightFactor(vorher);
}
cur.y += lines.length * lead;
} else {
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 += lead;
}
}
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');
}
// 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;
}
// ===========================================================================
// "Tupfen" layout — the quiet one with a wink
//
// A white page, ink on white, generous air — and coral dots scattered through
// the margins, a few of them cut off by the edge of the sheet. The dots are the
// only colour on the page, and they are also the layout's alphabet: they mark
// the sections, they lead the bullets, and five of them in a row make a language
// meter. Restrained enough for any office; nobody mistakes it for a template.
//
// Same machinery as the other layouts (dry-run measuring, one-page auto-scale,
// the shared `write` helper); only the drawing differs.
// ===========================================================================
const TU = {
mx: 20, // page margin
get r() { return PAGE.w - this.mx; },
get w() { return PAGE.w - 2 * this.mx; },
gap: 9, // gutter between the two competency columns
get col() { return (this.w - this.gap) / 2; },
datumW: 28, // the résumé's narrow left date column
top: 18,
bottom: 24, // reserves the résumé's (small) bottom dot band
};
// The letter needs a wider foot: in the reference the body stops well above the
// big dots at the bottom of the sheet. Reserving the band here means the one-page
// auto-scaler treats the confetti as page furniture and keeps the text clear of
// it, instead of the two colliding. The head gets a little more air too.
const TU_BRIEF = { top: 24, bottom: 34 };
// --- The motif -------------------------------------------------------------
// The scatter is hand-placed, not seeded-random. That buys three things at once:
// the PDF stays byte-reproducible, no dot can ever land in the text column, and
// the result reads as *composed* — random placement gives an even sprinkle, which
// is precisely the wallpaper look the reference avoids.
//
// Coordinates are absolute page millimetres. The rule kept by hand:
// • the top band, clear of the centred name
// • the two outer margins (a dot's right edge stays left of the text column,
// its left edge right of it)
// • the bottom band that the layout's `bottom` margin reserves
// Never inside the text column. Some centres sit off the sheet on purpose, so the
// dot is cut by the page edge exactly as in the reference — jsPDF clips them.
//
// `ton`: 0 = the base tone, 1 = the paler one. Two tones give the scatter depth;
// one tone gives it polka dots.
const TUPFEN_BRIEF = [
{ x: 24, y: -2, r: 8.5, ton: 0 }, // cut off by the top edge
{ x: 19, y: 13, r: 1.4, ton: 1 },
{ x: 37, y: 8, r: 2.4, ton: 0 },
{ x: 58, y: 12, r: 1.1, ton: 1 },
{ x: 79, y: 7, r: 2.0, ton: 0 },
{ x: 101, y: 13, r: 0.8, ton: 1 },
{ x: 147, y: 6, r: 1.7, ton: 1 },
{ x: 169, y: 12, r: 0.9, ton: 0 },
{ x: 191, y: 4, r: 2.6, ton: 0 },
{ x: 0, y: 27, r: 3.2, ton: 0 }, // cut off by the left edge
{ x: 204, y: 23, r: 1.3, ton: 1 },
{ x: 8, y: 76, r: 1.5, ton: 1 }, // …from here down: the outer margins only
{ x: 199, y: 97, r: 2.2, ton: 0 },
{ x: 13, y: 133, r: 0.9, ton: 0 },
{ x: 210, y: 151, r: 2.8, ton: 0 }, // cut off by the right edge
{ x: 7, y: 187, r: 2.0, ton: 0 },
{ x: 197, y: 206, r: 1.1, ton: 1 },
{ x: 11, y: 233, r: 0.8, ton: 1 },
{ x: 129, y: 271, r: 4.6, ton: 0 }, // the bottom band the margin reserves
{ x: 9, y: 274, r: 1.7, ton: 1 },
{ x: 61, y: 279, r: 2.3, ton: 0 },
{ x: 158, y: 284, r: 1.2, ton: 1 },
{ x: 187, y: 271, r: 0.9, ton: 1 },
{ x: 100, y: 290, r: 1.0, ton: 1 },
{ x: 46, y: 297, r: 6.5, ton: 0 }, // cut off by the bottom edge
{ x: 206, y: 292, r: 3.4, ton: 0 }, // …and by the corner
];
// The résumé's own scatter: denser at the top around the portrait, thinner down
// the sides, because the page below is full of text. A different composition on
// purpose — the pair should look designed, not copy-pasted.
const TUPFEN_CV = [
{ x: 12, y: 9, r: 2.8, ton: 0 },
{ x: 31, y: 19, r: 1.2, ton: 1 },
{ x: 45, y: 7, r: 1.6, ton: 1 },
{ x: 57, y: 26, r: 2.1, ton: 0 },
{ x: 0, y: 55, r: 4.0, ton: 0 }, // cut off by the left edge
{ x: 152, y: 12, r: 1.0, ton: 1 },
{ x: 166, y: 25, r: 2.6, ton: 0 },
{ x: 183, y: 8, r: 1.3, ton: 1 },
{ x: 198, y: 40, r: 1.5, ton: 0 },
{ x: 210, y: 18, r: 3.6, ton: 0 }, // cut off by the right edge
{ x: 8, y: 92, r: 1.1, ton: 1 },
{ x: 202, y: 84, r: 0.9, ton: 1 },
{ x: 12, y: 121, r: 2.2, ton: 0 },
{ x: 199, y: 139, r: 1.8, ton: 0 },
{ x: 6, y: 168, r: 0.8, ton: 1 },
{ x: 205, y: 191, r: 1.2, ton: 1 },
{ x: 10, y: 214, r: 1.9, ton: 0 },
{ x: 200, y: 236, r: 2.4, ton: 0 },
{ x: 15, y: 262, r: 1.0, ton: 1 },
{ x: 196, y: 279, r: 1.4, ton: 1 },
{ x: 3, y: 290, r: 3.0, ton: 0 }, // the bottom corners close the frame
{ x: 210, y: 297, r: 5.5, ton: 0 },
];
// Drawn once, as the very first thing, straight onto the page — and deliberately
// *not* scaled by S. The confetti belongs to the sheet, like the margins do; a
// densely packed résumé that shrinks its type to fit must not also shrink its
// decoration, or the page would quietly change character with its content.
function tupfen(doc, p, set) {
for (const d of set) {
const c = d.ton ? p.dekoHell : p.deko;
doc.setFillColor(c[0], c[1], c[2]);
doc.circle(d.x, d.y, d.r, 'F');
}
}
// Centred text with letter-spacing. jsPDF's own `align: 'center'` measures the
// string *without* the character spacing it then applies, so a tracked line comes
// out visibly off-centre. Measuring it here keeps it centred.
function tuCentered(doc, S, dry, cur, text, o) {
const label = o.upper ? String(text).toUpperCase() : String(text);
if (!label) return;
const cs = (o.charSpace || 0) * S;
doc.setFont(doc.__sans, o.style || 'normal');
doc.setFontSize(o.pt * S);
const w = doc.getTextWidth(label) + cs * Math.max(0, label.length - 1);
if (!dry) {
if (cs) doc.setCharSpace(cs);
doc.setTextColor(o.color[0], o.color[1], o.color[2]);
doc.text(label, PAGE.w / 2 - w / 2, cur.y + o.pt * S * PT * 0.76);
if (cs) doc.setCharSpace(0);
}
cur.y += o.pt * S * PT * (o.factor == null ? 1.3 : o.factor);
}
// Section label: the motif in miniature (a filled accent dot), a tracked capital
// label, and a hairline running out to the right margin. The dot is what makes it
// this layout's heading and not a generic one.
function tuHeading(doc, t, S, dry, cur, title) {
const pt = 8.6;
const cs = 1.1 * S;
cur.y += 7 * S;
const label = String(title).toUpperCase();
doc.setFont(doc.__sans, 'bold');
doc.setFontSize(pt * S);
const tw = doc.getTextWidth(label) + cs * Math.max(0, label.length - 1);
const r = 1.35 * S;
const textX = TU.mx + 2 * r + 2.6 * S;
const midY = cur.y + pt * S * PT * 0.42;
if (!dry) {
doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
doc.circle(TU.mx + r, midY, r, 'F');
doc.setCharSpace(cs);
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
doc.text(label, textX, cur.y + pt * S * PT * 0.76);
doc.setCharSpace(0);
const lx = textX + tw + 4 * S;
if (lx < TU.r - 6 * S) rule(doc, S, dry, midY, lx, TU.r, t.rc.hair, 0.3);
}
cur.y += pt * S * PT * 1.25 + 2.4 * S;
}
// Bullets led by a small accent dot — the same mark as the section heading's, a
// size down.
function tuBullets(doc, t, S, dry, cur, items, x, w, pt = 8.8) {
const ind = 3.6 * 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 r = 0.7 * S;
doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
doc.circle(x + r + 0.3 * S, cur.y + pt * S * PT * 0.42, r, '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.36;
});
cur.y += 1.1 * S;
}
}
// Three dots, centred: the separator under the head. A rule would be the obvious
// move — and would say nothing about this layout.
function tuTrio(doc, p, S, dry, cur) {
const r = 1.1 * S;
const gap = 4.4 * S;
if (!dry) {
for (const i of [-1, 0, 1]) {
const c = i === 0 ? p.accent : p.deko;
doc.setFillColor(c[0], c[1], c[2]);
doc.circle(PAGE.w / 2 + i * gap, cur.y + r, r, 'F');
}
}
cur.y += 2 * r;
}
// A language level as five dots — the motif doing actual work. The fill comes from
// niveauLevel(), i.e. from the wording the CV itself uses; nothing is invented.
function tuLangRow(doc, t, S, dry, cur, x, w, s) {
const pt = 8.8;
const N = 5;
const r = 1.5 * S;
const gap = 2.4 * S;
const meterW = N * 2 * r + (N - 1) * gap;
const voll = Math.max(1, Math.round(niveauLevel(s.niveau) * N));
const midY = cur.y + pt * S * PT * 0.42;
if (!dry) {
doc.setFont(doc.__sans, 'normal');
doc.setFontSize(pt * S);
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
doc.text(String(s.sprache || ''), x, cur.y + pt * S * PT * 0.76, { maxWidth: w - meterW - 4 * S });
const first = x + w - meterW + r;
for (let i = 0; i < N; i += 1) {
const c = i < voll ? t.rc.accent : t.rc.track;
doc.setFillColor(c[0], c[1], c[2]);
doc.circle(first + i * (2 * r + gap), midY, r, 'F');
}
}
cur.y += pt * S * PT * 1.9;
}
// One entry: the dates in a narrow column on the left, the substance on the right.
// A single, calm column down the page — the confetti is the only thing allowed to
// be playful here.
function tuEintrag(doc, t, S, dry, cur, { zeitraum, titel, unter, zusatz, punkte }) {
const dw = TU.datumW * S;
const bx = TU.mx + dw + 5 * S;
const bw = TU.r - bx;
const start = cur.y;
const dc = { y: start + 0.8 * S };
write(doc, S, dry, dc, zeitraum || '', { pt: 8, color: t.rc.sub, x: TU.mx, width: dw, factor: 1.28 });
const bc = { y: start };
write(doc, S, dry, bc, titel || '', { pt: 10, style: 'bold', color: t.rc.ink, x: bx, width: bw, factor: 1.24 });
if (unter) write(doc, S, dry, bc, unter, { pt: 8.8, color: t.rc.sub, x: bx, width: bw, factor: 1.3 });
if (zusatz) write(doc, S, dry, bc, zusatz, { pt: 8.4, color: t.rc.sub, x: bx, width: bw, factor: 1.3 });
if (punkte && punkte.length) {
bc.y += 1.6 * S;
tuBullets(doc, t, S, dry, bc, punkte, bx, bw);
}
cur.y = Math.max(dc.y, bc.y) + 3.4 * S;
}
// The head: portrait, name, role, contact — all centred, as in the reference. The
// round portrait is simply the largest dot on the page.
function tuKopf(doc, t, S, dry, cur, { header, titel, foto }) {
const cx = PAGE.w / 2;
const photo = fitPhoto(doc, foto, t.foto.rund);
if (photo.ok) {
const D = 30 * S;
const fx = cx - D / 2;
const fy = cur.y;
if (!dry) {
// Cover the frame and clip the overflow, so the face keeps its proportions.
const k = Math.max(D / photo.dw, D / photo.dh);
const dw = photo.dw * k;
const dh = photo.dh * k;
doc.saveGraphicsState();
if (t.foto.rund) doc.circle(cx, fy + D / 2, D / 2, null);
else doc.roundedRect(fx, fy, D, D, 2.4 * S, 2.4 * S, null);
doc.clip();
doc.discardPath();
doc.addImage(foto.dataUrl, foto.format || 'PNG', fx + (D - dw) / 2, fy + (D - dh) / 2, dw, dh);
doc.restoreGraphicsState();
}
cur.y += D + 5.5 * S;
}
const name = String(header.name || '');
let namePt = 26;
doc.setFont(doc.__sans, 'bold');
for (; namePt > 15; namePt -= 0.5) {
doc.setFontSize(namePt * S);
if (doc.getTextWidth(name) <= TU.w * 0.92) break;
}
tuCentered(doc, S, dry, cur, name, { pt: namePt, style: 'bold', color: t.rc.ink, factor: 1.08 });
if (titel) {
cur.y += 1.6 * S;
tuCentered(doc, S, dry, cur, titel, {
pt: 8.8, style: 'bold', color: t.rc.accent, factor: 1.35, upper: true, charSpace: 1.3,
});
}
const kontakt = buildContactLines(header).map((k) => k.text).join(' · ');
if (kontakt) {
cur.y += 1.8 * S;
// Centred and wrapping: a full contact set can outrun one line, and jsPDF's
// own centring is exact here (no letter-spacing in play).
write(doc, S, dry, cur, kontakt,
{ pt: 8.2, color: t.rc.sub, x: cx, width: TU.w, factor: 1.35, align: 'center' });
}
cur.y += 3.2 * S;
tuTrio(doc, t.rc, S, dry, cur);
cur.y += 1.5 * S;
}
function composeCVTupfen(doc, t, S, dry, ctx) {
const { cv, header, titel, foto } = ctx;
if (!dry) tupfen(doc, t.rc, TUPFEN_CV);
const cur = { y: TU.top };
tuKopf(doc, t, S, dry, cur, { header, titel, foto });
if (cv.profil) {
write(doc, S, dry, cur, cv.profil,
{ pt: 9, color: t.rc.ink, x: TU.mx, width: TU.w, factor: 1.5, justify: true });
}
if (cv.berufserfahrung.length) {
tuHeading(doc, t, S, dry, cur, 'Berufserfahrung');
for (const e of cv.berufserfahrung) {
tuEintrag(doc, t, S, dry, cur,
{ zeitraum: e.zeitraum, titel: e.titel, unter: e.firma, punkte: e.punkte });
}
}
// One "Ausbildung" heading for degrees, apprenticeship and courses alike — each
// entry names its own qualification, so three headings would only cost air.
const ausbildung = [
...(cv.studium || []),
...(cv.berufsausbildung || []),
...(cv.weiterbildungen || []),
];
if (ausbildung.length) {
tuHeading(doc, t, S, dry, cur, 'Ausbildung');
for (const e of ausbildung) {
tuEintrag(doc, t, S, dry, cur,
{ zeitraum: e.zeitraum, titel: e.abschluss, unter: e.institution, zusatz: e.zusatz });
}
}
if (cv.kenntnisse.length) {
tuHeading(doc, t, S, dry, cur, 'Kenntnisse');
// Two columns of dotted bullets: the list is short items, and a single column
// of them would leave half the page empty.
const mitte = Math.ceil(cv.kenntnisse.length / 2);
const startY = cur.y;
const links = { y: startY };
tuBullets(doc, t, S, dry, links, cv.kenntnisse.slice(0, mitte), TU.mx, TU.col);
const rechts = { y: startY };
tuBullets(doc, t, S, dry, rechts, cv.kenntnisse.slice(mitte), TU.mx + TU.col + TU.gap, TU.col);
cur.y = Math.max(links.y, rechts.y);
}
if (cv.sprachen.length) {
tuHeading(doc, t, S, dry, cur, 'Sprachen');
// Two languages per row, so the meters pair up instead of running down one
// side of an otherwise empty band.
for (let i = 0; i < cv.sprachen.length; i += 2) {
const zeile = cv.sprachen.slice(i, i + 2);
let unten = cur.y;
zeile.forEach((s, j) => {
const c = { y: cur.y };
tuLangRow(doc, t, S, dry, c, TU.mx + j * (TU.col + TU.gap), TU.col, s);
unten = Math.max(unten, c.y);
});
cur.y = unten;
}
}
if (cv.hobbys.length) {
tuHeading(doc, t, S, dry, cur, 'Interessen');
write(doc, S, dry, cur, cv.hobbys.join(' · '),
{ pt: 8.8, color: t.rc.sub, x: TU.mx, width: TU.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 = 22;
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');
inner({ y: bandY }, false);
}
cur.y = bandY + bandH + 9 * S;
// --- Recipient (Anschriftfeld) with the date on the same line — DIN keeps its
// address block; the date sits right-aligned in the accent, a quiet line
// rather than a pastel chip, so the letter's head is the only block of
// colour on the page.
const emp = letter.empfaenger || {};
const empFirma = emp.firma || job.firma || '';
const empOrt = emp.ort || job.ort || '';
const datum = stadt ? `${stadt}, ${today}` : today;
if (!dry) {
doc.setFont(doc.__sans, 'normal');
doc.setFontSize(8.6 * S);
doc.setTextColor(t.lc.accent[0], t.lc.accent[1], t.lc.accent[2]);
// Align the date's baseline with the first recipient line so the two read
// as one row rather than two floats at different heights.
doc.text(datum, right, cur.y + 10.2 * S * PT * 0.76, { align: 'right' });
}
const empW = width * 0.6; // keep the address clear of the date line
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: 13.5, style: 'bold', color: t.lc.ink, x, width, factor: 1.18 });
// --- Salutation + body. The salutation is set bold in ink rather than the
// accent — the bar above the subject is the page's one colour cue, and the
// letter's voice stays quiet so the résumé can be the loud twin.
cur.y += 5.5 * S;
if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.2, style: 'bold', color: t.lc.ink, 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 stands alone in the accent with a
// short hairline beneath it — a quiet flourish in place of the missing
// handwriting, instead of the poster's loud pastel marker swipe.
cur.y += 12 * S;
const pt = 10.6;
const baseY = cur.y + pt * S * PT * 0.76;
write(doc, S, dry, cur, header.name, { pt, style: 'bold', color: t.lc.accent, x, width, factor: 1.25 });
if (!dry) {
doc.setFont(doc.__sans, 'bold');
doc.setFontSize(pt * S);
const w = Math.min(doc.getTextWidth(String(header.name || '')), 42 * S);
doc.setDrawColor(t.lc.accent[0], t.lc.accent[1], t.lc.accent[2]);
doc.setLineWidth(0.35 * S);
doc.line(x, baseY + 1.6 * S, x + w, baseY + 1.6 * S);
}
}
// --- 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: a single hairline above a quiet, centred contact line — the
// letter's footer stays as contact information, not a second band of
// colour echoing the head. The poster résumé can carry the dots; the
// letter closes with a rule instead.
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.setDrawColor(t.lc.hair[0], t.lc.hair[1], t.lc.hair[2]);
doc.setLineWidth(0.4 * S);
doc.line(SU.mx, fy, SU.r, fy);
// Fit the strip to the line. A long address plus a long e-mail address
// overruns it at a fixed size. 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 - 6 * S;
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.muted[0], t.lc.muted[1], t.lc.muted[2]);
doc.text(strip, PAGE.w / 2, fy + fh / 2 + fpt * S * PT * 0.35, { align: 'center' });
}
}
}
// ===========================================================================
// Cover letter, Tupfen variant — the layout's centrepiece
//
// The reference is a letter, and this is the sheet it was drawn for: the name
// centred and set large in plain ink, a two-column meta row under it, an airy
// justified body — and the confetti in the margins, some of it running off the
// page. No band, no card, no pill: the dots carry the entire design, so the text
// can be as plain as a letter should be.
//
// Where it departs from the reference: the recipient block stays. The original
// has none, and a German application without an Anschriftenfeld reads as sloppy
// no matter how well it is set. It slots into the reference's own two-column
// grid — recipient left, date and contact right — so the composition survives.
// ===========================================================================
function composeLetterTupfen(doc, t, S, dry, cur, { letter, header, job, anlagen, signatur }) {
if (!dry) tupfen(doc, t.lc, TUPFEN_BRIEF);
const x = TU.mx + 5 * S; // ≈25 mm — the DIN gutter
const right = TU.r - 5 * 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);
// --- The name, centred and large, in ink. Not in the accent: the dots are the
// page's only colour, and a coral name would make them redundant.
const name = String(header.name || '');
let namePt = 26;
doc.setFont(doc.__sans, 'bold');
for (; namePt > 15; namePt -= 0.5) {
doc.setFontSize(namePt * S);
if (doc.getTextWidth(name) <= width * 0.9) break;
}
tuCentered(doc, S, dry, cur, name, { pt: namePt, style: 'bold', color: t.lc.ink, factor: 1.1 });
// --- The meta row: recipient left, sender and date right, both starting on the
// same line — the reference's grid, with the DIN block folded into it.
cur.y += 13 * S;
const emp = letter.empfaenger || {};
const empFirma = emp.firma || job.firma || '';
const empOrt = emp.ort || job.ort || '';
const spaltenW = width * 0.5 - 4 * S;
const links = { y: cur.y };
const zeile = { pt: 9.4, color: t.lc.ink, x, width: spaltenW, factor: 1.34 };
if (empFirma) write(doc, S, dry, links, empFirma, { ...zeile, style: 'bold' });
if (emp.ansprechpartner) write(doc, S, dry, links, `z. Hd. ${emp.ansprechpartner}`, zeile);
if (emp.adresse) write(doc, S, dry, links, emp.adresse, zeile);
if (empOrt) write(doc, S, dry, links, empOrt, zeile);
// Right column, right-aligned: the sender's own details, closed by the date —
// which the reference sets bold, the one piece of emphasis in the whole row.
const rechts = { y: cur.y };
const rZeile = { pt: 9.4, color: t.lc.muted, x, right, width: spaltenW, factor: 1.34, align: 'right' };
if (header.adresse) {
const adr = String(header.adresse).split(/[\n,]/).map((s) => s.trim()).filter(Boolean).join(', ');
if (adr) write(doc, S, dry, rechts, adr, rZeile);
}
if (header.telefon) write(doc, S, dry, rechts, header.telefon, rZeile);
if (header.email) write(doc, S, dry, rechts, header.email, rZeile);
rechts.y += 1.6 * S;
write(doc, S, dry, rechts, stadt ? `${stadt}, ${today}` : today,
{ ...rZeile, style: 'bold', color: t.lc.ink });
cur.y = Math.max(links.y, rechts.y);
// --- Subject, led by a single accent dot: the same mark that opens every
// section of the résumé, and the only colour inside the text block.
cur.y += 11 * S;
const bpt = 12.5;
const br = 1.5 * S;
const bx = x + 2 * br + 3 * S;
if (!dry) {
doc.setFillColor(t.lc.accent[0], t.lc.accent[1], t.lc.accent[2]);
doc.circle(x + br, cur.y + bpt * S * PT * 0.42, br, 'F');
}
write(doc, S, dry, cur, betreff,
{ pt: bpt, style: 'bold', color: t.lc.ink, x: bx, width: right - bx, factor: 1.2 });
// --- Salutation and body: justified and generously leaded, as in the reference.
cur.y += 7 * S;
if (letter.anrede) {
write(doc, S, dry, cur, letter.anrede, { pt: 10, color: t.lc.ink, x, width, factor: 1.4 });
cur.y += 3.4 * S;
}
letter.absaetze.forEach((p, i) => {
if (i > 0) cur.y += 3.6 * S;
write(doc, S, dry, cur, p, { pt: 10, color: t.lc.ink, x, width, factor: 1.55, justify: true });
});
// --- Closing and signature ---
cur.y += 6 * S;
if (letter.gruss) write(doc, S, dry, cur, letter.gruss, { pt: 10, 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 {
cur.y += 11 * S;
}
// The typed name closes the letter in either case — under the signature image
// where there is one, standing in for it where there is not.
write(doc, S, dry, cur, header.name, { pt: 10, color: t.lc.ink, x, width, factor: 1.3 });
// --- Enclosures: a tracked little label and one dot-separated line. Chips or
// pills here would be a second motif, and this page already has one.
if (anlagen && anlagen.length) {
cur.y += 8 * S;
write(doc, S, dry, cur, anlagen.length > 1 ? 'Anlagen' : 'Anlage',
{ pt: 7.6, style: 'bold', color: t.lc.muted, x, width, factor: 1.5, charSpace: 0.9, upper: true });
write(doc, S, dry, cur, anlagen.join(' · '),
{ pt: 9, color: t.lc.ink, x, width, factor: 1.35 });
}
// No footer strip: the reference closes on white and confetti, and the sender's
// details are already in the meta row. A rule here would only fence them in.
}
// ---------------------------------------------------------------------------
// 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 },
tupfen: { compose: composeCVTupfen, box: TU },
};
const LETTER_LAYOUT = {
sidebar: { compose: composeLetter, box: LET },
social: { compose: composeLetterSocial, box: SO },
sunny: { compose: composeLetterSunny, box: SU },
tupfen: { compose: composeLetterTupfen, box: TU_BRIEF },
};
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,
};