// 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 fallback 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();
}

// The current user's preselection (Einstellungen → GEN_DOKUMENTE_DEFAULT): which
// documents are ticked when a generation is started without an explicit choice.
// An empty / unknown setting falls back to both.
function standardDokumente() {
  return normalizeDokumente(String(config.get('GEN_DOKUMENTE_DEFAULT') || '').split(','));
}

// 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: 3–4 kurze, überzeugende Absätze (kein Adressblock, kein Datum, ` +
    `keine Grußformel hier). Sachlich und positiv, in Aktiv-Sätzen, ohne Floskeln und ` +
    `Weichspüler ("Ich würde gerne …"). KEINE Gehaltsvorstellungen, KEINE Kündigungsgründe, ` +
    `KEINE Erwähnung von Lücken/Arbeitslosigkeit, keine Selbstzweifel. Keine Platzhalter — echte Angaben nutzen. ` +
    `Nenne den AKTUELLEN Arbeitgeber NIE beim Namen im Anschreiben (keine Firmenbezeichnung wie z. B. ` +
    `"IT-Problemlöser GmbH") — beschreibe höchstens die Tätigkeit/Rolle allgemein, falls überhaupt relevant. ` +
    `Der Lebenslauf nennt die Arbeitgeber wie gehabt; diese Regel gilt nur für das Anschreiben. ` +
    `Berücksichtige die beigefügten Anlagen (siehe Abschnitt "Beigefügte Anlagen"): Du darfst im Text ` +
    `natürlich auf relevante Anlagen verweisen (z. B. "wie meine beigefügten Arbeitszeugnisse zeigen"), ` +
    `aber erfinde KEINE Anlagen, die dort nicht aufgeführt sind, und stütze keine Aussage auf einen Nachweis, ` +
    `der nicht beiliegt. Erstelle KEINE eigene "Anlagen:"-Auflistung im Text — die Anlagenliste wird separat erzeugt. ` +
    `Nur der ERSTE Absatz beginnt mit einem KLEINbuchstaben (die Anrede endet mit Komma, der Satz wird ` +
    `fortgesetzt), z. B. "mit …" oder "als …" — außer das erste Wort ist ein Substantiv, ein Eigenname oder die ` +
    `Höflichkeitsform "Ihre/Ihr/Ihnen". Alle WEITEREN Absätze sind neue Sätze und beginnen normal mit GROSSbuchstaben.\n` +
    `- email: ein SEHR KURZER Begleittext für die E-Mail, mit der die Bewerbung samt Anhängen ` +
    `(Lebenslauf, Anschreiben, ggf. Zeugnisse) versendet wird. Es ist NUR die E-Mail-Begleitnotiz, NICHT das ` +
    `vollständige Anschreiben - dessen Inhalt NICHT wiederholen. Ein HR-Experte überfliegt diese E-Mail im ` +
    `Vorbeigehen: max. 3 Sätze gesamt, auf das absolute Minimum reduziert. KEINE Motivationssätze, KEINE ` +
    `Qualifikationsauflistungen, KEINE Wiederholung des Anschreibens. ` +
    `email.betreff: "Bewerbung als <Stelle>" (ggf. Referenz-/Kennnummer anhängen). ` +
    `email.text: sofort versendbar, exakt diese Struktur - (1) dieselbe Anrede wie im Anschreiben; ` +
    `(2) ein Satz: auf welche Stelle man sich bewirbt UND dass die vollständigen Bewerbungsunterlagen im Anhang ` +
    `beiliegen; (3) ein kurzer, freundlicher Schlusssatz mit Ausblick auf ein persönliches Gespräch; ` +
    `(4) Grußformel "Mit freundlichen Grüßen"; (5) vollständiger Name in der letzten Zeile. ` +
    `Trenne Anrede, Text, Grußformel und Name jeweils durch eine Leerzeile (\\n\\n). ` +
    `Höflich, sachlich, in Aktiv-Sätzen, ohne Floskeln und Weichspüler, keine Platzhalter.\n` +
    `- profil: 2–3 Sätze Kurzprofil, konkret auf die Zielstelle zugeschnitten (der individuelle Text zur Stelle).\n` +
    `- Stil/Sprache (gilt für Anschreiben, Profil UND E-Mail): Schreibe so natürlich, wie ein deutscher ` +
    `Muttersprachler tatsächlich schreibt - konkrete Verben, kurze klare Sätze. KEINE leeren Floskeln oder ` +
    `Buzzword-Paare (also NICHT "serviceorientiert und klar", "motiviert und zuverlässig", "teamfähig und ` +
    `belastbar", "kommuniziere ich serviceorientiert und klar - sowohl mit Anwendern als auch im Team"). Jede ` +
    `genannte Eigenschaft MUSS aus einer konkreten Tätigkeit oder einem Ergebnis hervorgehen, sonst weglassen. ` +
    `KEINE Meta-Aussagen über die eigene Person oder Sprache wie "Als Muttersprachler ..." - Sprachkenntnisse ` +
    `gehören ausschließlich in die Sprachen-Sektion des Lebenslaufs, niemals in den Fließtext. Formuliere aus ` +
    `der konkreten Aufgabe heraus statt über abstrakte Selbstetiketten.\n` +
    `- berufserfahrung: ALLE Stationen aus den Unterlagen, neueste zuerst — lückenloser Verlauf, ` +
    `keine Station weglassen, die sonst eine Lücke hinterlässt (Überschneidungen sind ok). ` +
    `"firma" = "Arbeitgeber, Ort". Je jünger/relevanter eine Station, desto mehr Stichpunkte: die ` +
    `2–3 jüngsten erhalten je 2–3 knappe "punkte", mittelalte 0–1 Stichpunkt, ältere ein leeres ` +
    `Array (nur noch Zeile mit Zeitraum, Titel, Firma). Irrelevante alte Stationen dürfen als ` +
    `letzte knappe Zeile stehenbleiben, wenn sie für einen lückenlosen Verlauf nötig sind — nicht ausschmücken.\n` +
    `- Bildung: Hochschulstudium → studium; Berufsausbildung/Ausbildungsberuf → berufsausbildung; ` +
    `Zertifikate/Fortbildungen → weiterbildungen. Schulabschlüsse (Mittlere Reife, Fachoberschulreife, ` +
    `Fachhochschulreife, Abitur u. ä.) NICHT in den Lebenslauf aufnehmen — komplett weglassen. ` +
    `Grundschule ebenfalls weglassen.\n` +
    `- Studium ohne Abschluss: ehrlich, aber neutral als Eintrag in "studium" führen — NIE einen ` +
    `akademischen Grad voransetzen (kein "B.Sc.", "M.Sc.", "Diplom", "Dr." o. Ä.), der nicht erworben ` +
    `wurde. "abschluss" = reiner Studiengangname (z. B. "Praktische Informatik"), "zusatz" bleibt leer ` +
    `(kein "ohne Abschluss"/"nicht beendet"/"abgebrochen"). Ein kurzer, sachlicher Eintrag reicht; ` +
    `bei vorhandener Berufserfahrung trägt diese den Lebenslauf, das Studium ist eine Nebenzeile.\n` +
    `- Nur auf die Stelle bezogene Inhalte: alles weglassen, was nicht zur Zielstelle passt.\n` +
    `- kenntnisse: NUR Fähigkeiten, die für DIESE Stelle wirklich NOTWENDIG sind — d. h. die in der ` +
    `Stellenanzeige gefordert werden oder ohne die die Position faktisch nicht auszufüllen ist. Keine ` +
    `"nice-to-have"- oder Allgemein-Plätze (z. B. keine "Teamfähigkeit", "MS Office", "Zuverlässigkeit"), ` +
    `kein Auffüllen. Lieber 4–6 konkrete, harte Pflichtfähigkeiten als 12 weiche. Jedes Stichwort muss ` +
    `konkret benennbar sein (Technologie, Methode, Werkzeug, Zertifizierung) und aus den Basis-Unterlagen ` +
    `belegbar bleiben — nichts erfinden. Reihenfolge: geforderte Pflichtfähigkeiten zuerst.\n` +
    `- sprachen: IMMER aus den Basis-Unterlagen übernehmen (Sprachkenntnisse gehören in jeden ` +
    `deutschen Lebenslauf, unabhängig davon, ob die Stelle sie explizit fordert). Deutsch als ` +
    `Muttersprache sowie alle weiteren dort genannten Sprachen — insbesondere Englisch — ` +
    `vollständig aufführen, mit Niveau laut Unterlagen. Nichts erfinden, nichts weglassen.\n` +
    `- weiterbildungen: NUR falls in den Unterlagen vorhanden UND als Anforderung in der ` +
    `Stellenanzeige genannt bzw. für die Stelle zwingend. Sonst weglassen.\n` +
    `- hobbys: NUR aufnehmen, wenn sie direkt für den Beruf nützlich sind (z. B. Open-Source-Engagement, ` +
    `vereinsinterne IT/Verwaltung, ehrenamtliche Projektarbeit mit Bezug zur Stelle); sonst komplett weglassen.\n\n` +
    `WICHTIG — keine HR-Red-Flags:\n` +
    `- Lückenloser Zeitverlauf: übernimm ALLE Zeiträume aus den Unterlagen, sodass keine unerklärten ` +
    `Lücken entstehen. Lasse keine Stationen weg, die sonst eine Lücke hinterlassen (Überschneidungen sind ok).\n` +
    `- Keine negativen/entwertenden Vermerke: NIEMALS "nicht beendet", "abgebrochen", "ohne Abschluss", ` +
    `"arbeitslos", "arbeitssuchend", "gekündigt" o. Ä. Lasse "zusatz" in solchen Fällen leer und nenne nur ` +
    `Zeitraum, Bezeichnung und Institution neutral.\n` +
    `- Sachlich, positiv, konsistente Datumsformate (MM/YYYY), z. B. "02/2025 - heute".\n` +
    `- Trennzeichen: in ALLEN Texten (Anschreiben UND Lebenslauf) ausschließlich den einfachen Bindestrich ` +
    `"-" verwenden - niemals den Halbgeviertstrich (–) oder Geviertstrich (—). Gilt auch für Datumsangaben, ` +
    `Wertebereiche (z. B. "2-3 Jahre") und Aufzählungen.\n\n` +
    `Leere Felder als leerer String bzw. leeres Array. Antworte AUSSCHLIESSLICH mit dem ` +
    `JSON-Objekt, ohne Markdown, ohne Code-Fences, ohne weiteren Text.`;

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 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' };
  // Straße und PLZ/Ort stehen untereinander wie in einer Anschrift — der Block ist
  // hier ohnehin eine Zeilenspalte, ein Komma dazwischen ließe ihn gedrängt wirken.
  String(header.adresse || '')
    .split(/[\n,]/)
    .map((s) => s.trim())
    .filter(Boolean)
    .forEach((zeile) => write(doc, S, dry, rechts, zeile, rZeile));
  if (header.telefon) write(doc, S, dry, rechts, header.telefon, rZeile);
  if (header.email) write(doc, S, dry, rechts, header.email, rZeile);
  // Deutlicher Abstand zur Kontaktspalte: Ort/Datum ist eine eigene Aussage,
  // keine weitere Kontaktzeile.
  rechts.y += 9.4 * 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,
  standardDokumente,
};
