Zweites Lebenslauf-Design "Social Media" + Anschreiben folgt dem Layout

Der Lebenslauf kann jetzt zwischen zwei Layouts waehlen, und das Anschreiben
uebernimmt immer das Layout des Lebenslaufs - beide Dokumente kommen als ein Set
beim Unternehmen an.

"Social Media" ist die verspielte Variante nach der Media-Kit-Vorlage: getoente
Seite, rundes Portrait mit Blob, Schreibschrift-Zeile ueber einem grossen Namen,
Herzchen als Abschnittsmarker und Bullets, abgerundete Karten je Station,
Pill-Chips fuer Kompetenzen und Sprachen, Glitzer-Sterne als Deko. Bewusst laut -
gedacht fuer kreative und Social-Media-Stellen.

Die gesamte Farbwelt (Seite, Karten, Pills, Deko) wird aus der Akzentfarbe
abgeleitet, das Layout funktioniert also auch in Violett oder Tiefblau. Neue
Akzente Pink und Violett; das Layout bringt eigene Defaults mit (pink, rundes
Foto), sodass ein Wechsel sofort stimmig aussieht.

Neue Schriften (SIL OFL): Poppins fuer die geometrische Sans, Pacifico fuer die
Schreibschrift. Es werden nur die Fonts eingebettet, die das gewaehlte Layout
nutzt - ein klassischer Lebenslauf traegt Poppins/Pacifico nicht mit.

Herz und Sparkle sind als Bezier-Pfade gezeichnet, weil sich auf ein Herz-Glyph
in der Schrift nicht verlassen werden kann. Das neue Layout nutzt dieselbe
Mess- und Skalier-Mechanik wie das klassische, erbt also die Ein-Seiten-Garantie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 01:15:02 +02:00
co-authored by Claude Opus 4.8
parent 9f7349af4e
commit 0e1d9b3322
9 changed files with 917 additions and 75 deletions
+574 -32
View File
@@ -12,19 +12,38 @@ const path = require('path');
const promptStore = require('./prompts');
const designStore = require('./design');
// Embedded professional typeface for both cover letter and résumé: Lato
// (SIL Open Font License, see lib/fonts/OFL.txt). The base64 TTFs are read
// once at module load and registered per jsPDF instance.
const LATO_REGULAR_B64 = fs.readFileSync(path.join(__dirname, 'fonts', 'Lato-Regular.ttf')).toString('base64');
const LATO_BOLD_B64 = fs.readFileSync(path.join(__dirname, 'fonts', 'Lato-Bold.ttf')).toString('base64');
// 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')])
);
function makeDoc() {
// `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' });
doc.addFileToVFS('Lato-Regular.ttf', LATO_REGULAR_B64);
doc.addFileToVFS('Lato-Bold.ttf', LATO_BOLD_B64);
doc.addFont('Lato-Regular.ttf', 'Lato', 'normal');
doc.addFont('Lato-Bold.ttf', 'Lato', 'bold');
doc.setFont('Lato');
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;
}
@@ -599,7 +618,7 @@ 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('Lato', o.style || 'normal');
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);
@@ -704,7 +723,7 @@ function sbHeading(doc, t, S, dry, cur, title) {
const pt = 9;
const cs = 1.0 * S;
cur.y += 5 * S;
doc.setFont('Lato', 'bold');
doc.setFont(doc.__sans, 'bold');
doc.setFontSize(pt * S);
const label = String(title).toUpperCase();
if (!dry) {
@@ -720,7 +739,7 @@ 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('Lato', 'normal');
doc.setFont(doc.__sans, 'normal');
doc.setFontSize(pt * S);
const lines = doc.splitTextToSize(String(it), tw);
lines.forEach((ln, i) => {
@@ -742,7 +761,7 @@ function sbBullets(doc, t, S, dry, cur, items, pt = 8.7) {
// 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('Lato', 'normal');
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]);
@@ -818,7 +837,7 @@ function mHeading(doc, t, S, dry, cur, title) {
const pt = 11;
const cs = 0.9 * S;
cur.y += 5.5 * S;
doc.setFont('Lato', 'bold');
doc.setFont(doc.__sans, 'bold');
doc.setFontSize(pt * S);
const label = String(title).toUpperCase();
const lineY = cur.y + pt * S * PT * 1.1;
@@ -842,7 +861,7 @@ 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('Lato', 'normal');
doc.setFont(doc.__sans, 'normal');
doc.setFontSize(pt * S);
const lines = doc.splitTextToSize(String(it), tw);
lines.forEach((ln, i) => {
@@ -867,7 +886,7 @@ 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('Lato', 'bold');
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;
@@ -877,7 +896,7 @@ function cvExperience(doc, t, S, dry, cur, e) {
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('Lato', 'normal');
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' });
@@ -906,7 +925,7 @@ function composeTimeline(doc, t, S, dry, cur, entries) {
function cvEducation(doc, t, S, dry, cur, e) {
const pt = 10.5, datePt = 9.3, dateW = 30;
doc.setFont('Lato', 'bold');
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;
@@ -914,7 +933,7 @@ function cvEducation(doc, t, S, dry, cur, e) {
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('Lato', 'normal');
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' });
@@ -971,17 +990,374 @@ function composeCV(doc, t, S, dry, ctx) {
// 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();
const doc = makeDoc(t);
const ctx = { cv, header, titel, foto };
const social = t.layout === 'social';
const compose = social ? composeCVSocial : composeCV;
const top = social ? SO.top : RV.top;
const bottom = social ? SO.bottom : RV.bottom;
const S0 = t.scale;
const need = composeCV(doc, t, S0, true, ctx) - RV.top;
const avail = PAGE.h - RV.top - RV.bottom;
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);
composeCV(doc, t, S, false, ctx);
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
);
}
// 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).
// Returns its width so chips can be flowed into rows.
function pill(doc, t, S, dry, x, y, text, opts = {}) {
const pt = opts.pt || 8.2;
const padX = 2.6 * S;
const h = pt * S * PT * 1.85;
doc.setFont(doc.__sans, opts.style || 'bold');
doc.setFontSize(pt * S);
const w = doc.getTextWidth(String(text)) + padX * 2;
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');
doc.setTextColor(ink[0], ink[1], ink[2]);
doc.text(String(text), x + padX, 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) {
doc.setCharSpace(cs);
const tw = doc.getTextWidth(label) + cs * label.length;
const cx = PAGE.w / 2;
doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
doc.text(label, cx, cur.y + pt * S * PT * 0.76, { align: 'center' });
doc.setCharSpace(0);
const gap = 5.5 * S;
const hx = tw / 2 + gap;
heart(doc, cx - hx, midY, 3.1 * S, t.rc.accent);
heart(doc, cx + hx, midY, 3.1 * S, t.rc.accent);
doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]);
doc.setLineWidth(0.4 * S);
const lineGap = hx + 3.6 * S;
doc.line(SO.mx + 4 * S, midY, cx - lineGap, midY);
doc.line(cx + 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;
pillRow(doc, t, S, isDry, c, kontakt, x, w, { style: 'normal', pt: 8.4 });
}
});
}
if (cv.berufserfahrung.length) {
soHeading(doc, t, S, dry, cur, 'Berufserfahrung');
cv.berufserfahrung.forEach((e) => soExperience(doc, t, S, dry, cur, e));
}
[['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Weiterbildungen', cv.weiterbildungen]]
.forEach(([label, entries]) => {
if (!entries || !entries.length) return;
soHeading(doc, t, S, dry, cur, label);
entries.forEach((e) => soEducation(doc, t, S, dry, cur, e));
});
if (cv.kenntnisse.length) {
soHeading(doc, t, S, dry, cur, 'Kernkompetenzen');
const c = { y: cur.y };
pillRow(doc, t, S, dry, c, cv.kenntnisse, SO.mx + 1 * S, SO.w - 2 * S);
cur.y = c.y + 2 * S;
}
if (cv.sprachen.length) {
soHeading(doc, t, S, dry, cur, 'Sprachen');
const c = { y: cur.y };
const items = cv.sprachen.map((s) => (s.niveau ? `${s.sprache}${s.niveau}` : s.sprache));
pillRow(doc, t, S, dry, c, items, SO.mx + 1 * S, SO.w - 2 * S, { style: 'normal' });
cur.y = c.y + 2 * S;
}
if (cv.hobbys.length) {
soHeading(doc, t, S, dry, cur, 'Interessen');
const c = { y: cur.y };
pillRow(doc, t, S, dry, c, cv.hobbys, SO.mx + 1 * S, SO.w - 2 * S, { style: 'normal' });
cur.y = c.y + 2 * S;
}
return cur.y;
}
// ===========================================================================
// Cover letter — clean, single-column DIN-5008 business letter (monochrome)
// ===========================================================================
@@ -1105,7 +1481,7 @@ function composeLetter(doc, t, S, dry, cur, { letter, header, job, anlagen, sign
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('Lato', 'normal');
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' });
@@ -1113,15 +1489,178 @@ function composeLetter(doc, t, S, dry, cur, { letter, header, job, anlagen, sign
}
}
// ===========================================================================
// Cover letter, playful variant — mirrors the "social" résumé
//
// Same page tint, same accent, same cards, hearts and pills, same script line.
// The letter's *substance* is untouched: recipient block, date, subject,
// salutation, body, closing, signature and enclosures all stay where a German
// reader expects them. It's the same letter, dressed like the CV it arrives
// with — a playful résumé must never show up with a stiff monochrome letter.
// ===========================================================================
function composeLetterSocial(doc, t, S, dry, cur, { letter, header, job, anlagen, signatur }) {
const x = SO.mx + 4 * S;
const right = SO.r - 4 * S;
const width = right - x;
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
const betreff = letter.betreff || (job.stelle ? `Bewerbung als ${job.stelle}` : 'Bewerbung');
const stadt = cityName(header);
if (!dry && t.lc.pageBg) {
doc.setFillColor(t.lc.pageBg[0], t.lc.pageBg[1], t.lc.pageBg[2]);
doc.rect(0, 0, PAGE.w, PAGE.h, 'F');
}
// --- Letterhead: script greeting + big name, echoing the résumé's hero ---
if (!dry) {
doc.setFont(doc.__script, 'normal');
doc.setFontSize(13 * S);
doc.setTextColor(t.lc.accent[0], t.lc.accent[1], t.lc.accent[2]);
doc.text('Hallo, ich bin', x, cur.y + 13 * S * PT * 0.76);
sparkle(doc, right - 4 * S, cur.y + 2 * S, 5 * S, t.lc.deko);
}
cur.y += 13 * S * PT * 1.25;
const name = String(header.name || '').toUpperCase();
let namePt = 24;
doc.setFont(doc.__sans, 'bold');
for (; namePt > 13; namePt -= 0.5) {
doc.setFontSize(namePt * S);
if (doc.getTextWidth(name) <= width) break;
}
write(doc, S, dry, cur, name, { pt: namePt, style: 'bold', color: t.lc.accent, x, width, factor: 1.04 });
if (header.headline) {
write(doc, S, dry, cur, header.headline, {
pt: 8.6, style: 'bold', color: t.lc.muted, x, width, factor: 1.35, charSpace: 0.55, upper: true,
});
}
// Heart-flanked divider instead of the classic hairline.
cur.y += 4 * S;
if (!dry) {
doc.setDrawColor(t.lc.hair[0], t.lc.hair[1], t.lc.hair[2]);
doc.setLineWidth(0.4 * S);
doc.line(x, cur.y, right - 7 * S, cur.y);
heart(doc, right - 3 * S, cur.y, 3.4 * S, t.lc.accent);
}
cur.y += 9 * S;
// --- Recipient in a rounded card, with the date as a pill on the right ---
const emp = letter.empfaenger || {};
const empFirma = emp.firma || job.firma || '';
const empOrt = emp.ort || job.ort || '';
const empLines = [
empFirma,
emp.ansprechpartner ? `z. Hd. ${emp.ansprechpartner}` : '',
emp.adresse || '',
empOrt,
].filter(Boolean);
if (empLines.length) {
const startY = cur.y;
const inner = (c, isDry) => {
empLines.forEach((l) => write(doc, S, isDry, c, l, {
pt: 10, color: t.lc.ink, x: x + SO.pad * S, width: width - 2 * SO.pad * S - 34 * S, factor: 1.34,
}));
};
const probe = { y: startY + SO.pad * S };
inner(probe, true);
const h = (probe.y - startY) + SO.pad * S;
if (!dry) {
doc.setFillColor(t.lc.cardBg[0], t.lc.cardBg[1], t.lc.cardBg[2]);
doc.setDrawColor(t.lc.cardBorder[0], t.lc.cardBorder[1], t.lc.cardBorder[2]);
doc.setLineWidth(0.4 * S);
doc.roundedRect(x, startY, width, h, SO.radius * S, SO.radius * S, 'FD');
inner({ y: startY + SO.pad * S }, false);
const d = stadt ? `${stadt}, ${today}` : today;
const p = pill(doc, t, S, true, 0, 0, d, { pt: 8.4 });
pill(doc, t, S, false, x + width - SO.pad * S - p.w, startY + SO.pad * S, d, { pt: 8.4 });
}
cur.y = startY + h + 7 * S;
}
// --- Subject ---
write(doc, S, dry, cur, betreff, { pt: 12, style: 'bold', color: t.lc.accent, x, width, factor: 1.3 });
// --- Salutation + body ---
cur.y += 5 * S;
if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.4 }); cur.y += 2.6 * S; }
letter.absaetze.forEach((p, i) => {
if (i > 0) cur.y += 3 * S;
write(doc, S, dry, cur, p, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.52 });
});
// --- Closing + signature ---
cur.y += 5 * S;
if (letter.gruss) write(doc, S, dry, cur, letter.gruss, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.3 });
let sigOk = false, sigW = 0, sigH = 0;
if (signatur && signatur.dataUrl) {
try {
const props = doc.getImageProperties(signatur.dataUrl);
const maxW = 52 * S, maxH = 22 * S;
sigW = maxW;
sigH = sigW * props.height / props.width;
if (sigH > maxH) { sigH = maxH; sigW = sigH * props.width / props.height; }
sigOk = props.width > 0 && props.height > 0;
} catch (e) { sigOk = false; }
}
if (sigOk) {
cur.y += 2.5 * S;
if (!dry) doc.addImage(signatur.dataUrl, signatur.format || 'PNG', x, cur.y, sigW, sigH);
cur.y += sigH;
} else {
cur.y += 12 * S;
write(doc, S, dry, cur, header.name, { pt: 10.2, style: 'bold', color: t.lc.accent, x, width, factor: 1.2 });
}
// --- Enclosures as pills ---
if (anlagen && anlagen.length) {
cur.y += 6 * S;
write(doc, S, dry, cur, 'Anlagen', { pt: 8.4, style: 'bold', color: t.lc.muted, x, width, factor: 1.4, charSpace: 0.5, upper: true });
cur.y += 1 * S;
const c = { y: cur.y };
pillRow(doc, t, S, dry, c, anlagen, x, width, { style: 'normal' });
cur.y = c.y;
}
// --- Footer contact strip, centred under a hairline ---
if (!dry) {
const parts = [];
if (header.adresse) {
const adr = String(header.adresse).split(/[\n,]/).map((s) => s.trim()).filter(Boolean).join(', ');
if (adr) parts.push(adr);
}
if (header.telefon) parts.push(header.telefon);
if (header.email) parts.push(header.email);
if (parts.length) {
const fy = PAGE.h - 15;
doc.setDrawColor(t.lc.hair[0], t.lc.hair[1], t.lc.hair[2]);
doc.setLineWidth(0.4 * S);
doc.line(x, fy, right, fy);
doc.setFont(doc.__sans, 'normal');
doc.setFontSize(8 * S);
doc.setTextColor(t.lc.muted[0], t.lc.muted[1], t.lc.muted[2]);
doc.text(parts.join(' · '), (x + right) / 2, fy + 4.4 * S, { align: 'center' });
heart(doc, x + 1 * S, fy, 2.6 * S, t.lc.accent);
heart(doc, right - 1 * S, fy, 2.6 * S, t.lc.accent);
}
}
}
function renderSingleColumn(compose, t) {
const doc = makeDoc();
const doc = makeDoc(t);
const social = t.layout === 'social';
const top = social ? SO.top : LET.top;
const bottom = social ? SO.bottom : LET.bottom;
const S0 = t.scale;
const m = { y: LET.top }; compose(doc, t, S0, true, m);
const need = m.y - LET.top;
const avail = PAGE.h - LET.top - LET.bottom;
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: LET.top });
compose(doc, t, S, false, { y: top });
return Buffer.from(doc.output('arraybuffer'));
}
@@ -1141,10 +1680,13 @@ function renderLebenslaufPdf(cv, header, foto, design = null) {
return renderCV(cv, header, header.headline || '', t.foto.anzeigen ? foto : null, t);
}
// The letter always follows the résumé's layout — that is the contract of the
// design setting: one application, one look.
function renderAnschreibenPdf(letter, header, job, anlagen, signatur, design = null) {
const t = designStore.resolve(design || {});
const compose = t.layout === 'social' ? composeLetterSocial : composeLetter;
return renderSingleColumn(
(doc, th, S, dry, cur) => composeLetter(doc, th, S, dry, cur, { letter, header, job, anlagen, signatur }),
(doc, th, S, dry, cur) => compose(doc, th, S, dry, cur, { letter, header, job, anlagen, signatur }),
t
);
}