Design: drittes Layout "Sunny" für Anschreiben und Lebenslauf

Plakatives Layout nach Vorlage eines Referenz-Lebenslaufs: weiße Seite,
Pastellband im Kopf mit überstehendem Foto, Name groß daneben, Zielposition
als solides Badge, überdimensionale Display-Überschriften unter Pastellbalken,
die abwechselnd links und rechts stehen, zweispaltige Einträge und Level-Balken
für die Sprachen.

Die Balkenlänge kommt aus dem im Lebenslauf genannten Niveau (Muttersprache,
C1, B1 …) – es wird kein Prozentwert erfunden, der nicht in den Unterlagen
steht. Studium, Berufsausbildung und Weiterbildungen laufen unter einer
gemeinsamen Überschrift "Ausbildung": drei Überschriften dieser Größe
hintereinander würden die Seite auffressen.

Neue Akzentfarbe "Beere" als Standard für das Layout. Die Layout-Weiche steckt
jetzt in einer Tabelle (CV_LAYOUT / LETTER_LAYOUT) statt in Ternären – ein
weiteres Layout ist eine Zeile dort plus seine Palette in lib/design.js.
Sidebar und Social bleiben unverändert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 17:25:20 +02:00
co-authored by Claude Opus 4.8
parent 9a40f395c7
commit 1f2e299c55
2 changed files with 641 additions and 54 deletions
+537 -8
View File
@@ -1072,10 +1072,9 @@ function composeCV(doc, t, S, dry, ctx) {
function renderCV(cv, header, titel, foto, t) {
const doc = makeDoc(t);
const ctx = { cv, header, titel, foto };
const social = t.layout === 'social';
const compose = social ? composeCVSocial : composeCV;
const top = social ? SO.top : RV.top;
const bottom = social ? SO.bottom : RV.bottom;
const { 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;
@@ -1532,6 +1531,393 @@ function composeCVSocial(doc, t, S, dry, ctx) {
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.
function suBar(doc, t, 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(t.rc.band[0], t.rc.band[1], t.rc.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, 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, S, dry, cur.y, 'left', 0.28);
cur.y += 2.4 * S + 3 * S;
write(doc, S, dry, cur, `Interessen: ${cv.hobbys.join(' · ')}`,
{ pt: 8.8, color: t.rc.sub, x: SU.mx, width: SU.w, factor: 1.35 });
}
return cur.y;
}
// ===========================================================================
// Cover letter — clean, single-column DIN-5008 business letter (monochrome)
// ===========================================================================
@@ -1823,11 +2209,154 @@ function composeLetterSocial(doc, t, S, dry, cur, { letter, header, job, anlagen
}
}
// ===========================================================================
// 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: name and role badge on the pastel band ---
const bandY = cur.y;
const inner = (c, isDry) => {
c.y += SU.pad * S;
const name = String(header.name || '');
let namePt = 26;
doc.setFont(doc.__sans, 'bold');
for (; namePt > 14; namePt -= 0.5) {
doc.setFontSize(namePt * S);
if (doc.getTextWidth(name) <= width) break;
}
write(doc, S, isDry, c, name, { pt: namePt, style: 'bold', color: t.lc.accent, x, width, factor: 1.04 });
if (header.headline) {
c.y += 2.4 * S;
suBadge(doc, t.lc, S, isDry, c, header.headline, x, width);
}
c.y += 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 + 10 * S;
// --- Recipient (Anschriftfeld) ---
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.2, style: 'bold', color: t.lc.ink, x, width, factor: 1.32 });
if (emp.ansprechpartner) write(doc, S, dry, cur, `z. Hd. ${emp.ansprechpartner}`, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.32 });
if (emp.adresse) write(doc, S, dry, cur, emp.adresse, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.32 });
if (empOrt) write(doc, S, dry, cur, empOrt, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.32 });
// --- Date (right-aligned) ---
cur.y += 7 * S;
write(doc, S, dry, cur, stadt ? `${stadt}, den ${today}` : today,
{ pt: 9.6, color: t.lc.muted, x, width, align: 'right', right, factor: 1.2 });
// --- Subject, under a short bar: the section motif, letter-sized ---
cur.y += 6 * S;
suBar(doc, t, S, dry, cur.y, 'left', 0.22);
cur.y += 2.4 * S + 3.6 * S;
write(doc, S, dry, cur, betreff, { pt: 13, style: 'bold', color: t.lc.accent, x, width, factor: 1.24 });
// --- Salutation + body ---
cur.y += 5.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.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 {
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 ---
if (anlagen && anlagen.length) {
cur.y += 7 * S;
write(doc, S, dry, cur, (anlagen.length > 1 ? 'Anlagen: ' : 'Anlage: ') + anlagen.join(', '),
{ pt: 8.8, color: t.lc.muted, x, width, factor: 1.3 });
}
// --- Footer: the contact strip on a pastel bar, in the reserved bottom margin ---
if (!dry) {
const parts = [];
if (header.adresse) {
const adr = String(header.adresse).split(/[\n,]/).map((s) => s.trim()).filter(Boolean).join(', ');
if (adr) parts.push(adr);
}
if (header.telefon) parts.push(header.telefon);
if (header.email) parts.push(header.email);
if (parts.length) {
const fh = 7.5 * S;
const fy = PAGE.h - SU.bottom - fh;
doc.setFillColor(t.lc.band[0], t.lc.band[1], t.lc.band[2]);
doc.rect(SU.mx, fy, SU.w, fh, 'F');
doc.setFont(doc.__sans, 'normal');
doc.setFontSize(8 * S);
doc.setTextColor(t.lc.accent[0], t.lc.accent[1], t.lc.accent[2]);
doc.text(parts.join(' · '), PAGE.w / 2, fy + fh / 2 + 8 * S * PT * 0.35, { align: 'center' });
}
}
}
// ---------------------------------------------------------------------------
// Layout dispatch — the only place that knows which layout draws what.
// Adding a layout means adding a row here (plus its palette in lib/design.js).
// ---------------------------------------------------------------------------
const CV_LAYOUT = {
sidebar: { compose: composeCV, box: RV },
social: { compose: composeCVSocial, box: SO },
sunny: { compose: composeCVSunny, box: SU },
};
const LETTER_LAYOUT = {
sidebar: { compose: composeLetter, box: LET },
social: { compose: composeLetterSocial, box: SO },
sunny: { compose: composeLetterSunny, box: SU },
};
function renderSingleColumn(compose, t) {
const doc = makeDoc(t);
const social = t.layout === 'social';
const top = social ? SO.top : LET.top;
const bottom = social ? SO.bottom : LET.bottom;
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;
@@ -1867,7 +2396,7 @@ function renderLebenslaufPdf(cv, header, foto, design = null) {
// design setting: one application, one look.
function renderAnschreibenPdf(letter, header, job, anlagen, signatur, design = null) {
const t = designStore.resolve(design || {});
const compose = t.layout === 'social' ? composeLetterSocial : composeLetter;
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