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:
+102
-44
@@ -3,7 +3,7 @@
|
||||
// Same contract as lib/prompts.js: the defaults live here, an override is a row
|
||||
// in the `design` table, and deleting the row restores the default.
|
||||
//
|
||||
// Two layouts ship today:
|
||||
// Three layouts ship today:
|
||||
//
|
||||
// sidebar — the classic: neutral greys, tinted sidebar, DIN-5008 letter. The
|
||||
// accent is a single restrained colour; hierarchy comes from weight
|
||||
@@ -13,15 +13,21 @@
|
||||
// headline, a full-page tinted background). Right for creative,
|
||||
// social-media and design-adjacent roles — and deliberately loud.
|
||||
//
|
||||
// sunny — an editorial poster look: white page, oversized display headings
|
||||
// in a deep accent, pastel accent bars that alternate left/right, a
|
||||
// photo breaking out of a tinted hero band, a solid role badge and
|
||||
// level bars for the languages. Bold but tidy — right for marketing,
|
||||
// media and design roles that still want a readable, gridded page.
|
||||
//
|
||||
// The cover letter always follows the résumé's layout, so both documents arrive
|
||||
// as one coherent set — that is the whole point of picking a design here.
|
||||
//
|
||||
// The palette stays closed (no free colour picker): every tone is one that still
|
||||
// works when an HR department prints it in greyscale.
|
||||
|
||||
// Accent colours. In `sidebar` the accent is the *only* colour; in `social` the
|
||||
// whole page palette (background, cards, pills) is derived from it, so the
|
||||
// playful layout works in pink as well as in deep blue.
|
||||
// Accent colours. In `sidebar` the accent is the *only* colour; in `social` and
|
||||
// `sunny` the whole page palette (bands, cards, pills) is derived from it, so
|
||||
// those layouts work in berry as well as in deep blue.
|
||||
const AKZENTE = [
|
||||
{ key: 'anthrazit', label: 'Anthrazit', rgb: [23, 23, 23] },
|
||||
{ key: 'tiefblau', label: 'Tiefblau', rgb: [30, 58, 95] },
|
||||
@@ -29,6 +35,7 @@ const AKZENTE = [
|
||||
{ key: 'waldgruen', label: 'Waldgrün', rgb: [27, 67, 50] },
|
||||
{ key: 'stahlblau', label: 'Stahlblau', rgb: [51, 65, 85] },
|
||||
{ key: 'kupfer', label: 'Kupfer', rgb: [124, 62, 24] },
|
||||
{ key: 'beere', label: 'Beere (Sunny)', rgb: [109, 24, 74] },
|
||||
{ key: 'pink', label: 'Pink (verspielt)', rgb: [232, 54, 143] },
|
||||
{ key: 'violett', label: 'Violett (verspielt)', rgb: [147, 51, 187] },
|
||||
];
|
||||
@@ -36,6 +43,7 @@ const AKZENTE = [
|
||||
const LAYOUTS = [
|
||||
{ key: 'sidebar', label: 'Klassisch mit Sidebar (Standard)' },
|
||||
{ key: 'social', label: 'Social Media (verspielt)' },
|
||||
{ key: 'sunny', label: 'Sunny (plakativ, Farbbänder)' },
|
||||
];
|
||||
|
||||
const SIDEBAR_VARIANTEN = [
|
||||
@@ -63,6 +71,9 @@ const SCHRIFT_STUFEN = [
|
||||
const LAYOUT_DEFAULTS = {
|
||||
sidebar: { akzent: 'anthrazit', foto_form: 'eckig' },
|
||||
social: { akzent: 'pink', foto_form: 'rund' },
|
||||
// Sunny is built around the berry/pastel pairing of its reference; the photo
|
||||
// sits in a square frame that breaks out of the hero band, so square it is.
|
||||
sunny: { akzent: 'beere', foto_form: 'eckig' },
|
||||
};
|
||||
|
||||
const DEFAULTS = {
|
||||
@@ -129,18 +140,36 @@ function settings(overrides = {}) {
|
||||
function resolve(overrides = {}) {
|
||||
const s = settings(overrides);
|
||||
const accent = findOption(AKZENTE, s.akzent, 'anthrazit').rgb;
|
||||
const social = s.layout === 'social';
|
||||
|
||||
let sidebarBg;
|
||||
if (s.sidebar === 'weiss') sidebarBg = [255, 255, 255];
|
||||
else if (s.sidebar === 'getoent') sidebarBg = tint(accent, 0.08);
|
||||
else sidebarBg = NEUTRAL_SIDEBAR;
|
||||
|
||||
// The playful layout derives its whole palette from the accent: a tinted page,
|
||||
// near-white cards with a soft border, pill chips, and a warm dark ink that is
|
||||
// the accent shaded down rather than plain black.
|
||||
const rc = social
|
||||
? {
|
||||
// Résumé palettes, one per layout. Every key exists in every palette so the
|
||||
// renderer can read `t.rc.<x>` without knowing which layout it is drawing.
|
||||
const RC = {
|
||||
sidebar: {
|
||||
ink: [33, 33, 33],
|
||||
sub: [92, 96, 100],
|
||||
hair: [208, 210, 214],
|
||||
accent,
|
||||
pageBg: null, // no page tint in the classic layout
|
||||
cardBg: [255, 255, 255],
|
||||
cardBorder: [208, 210, 214],
|
||||
deko: [208, 210, 214], // the classic layout draws no sparkles; kept for shape parity
|
||||
band: tint(accent, 0.14),
|
||||
pillBg: tint(accent, 0.12),
|
||||
pillInk: accent,
|
||||
sidebarBg,
|
||||
track: [199, 202, 208],
|
||||
onSide: [38, 40, 44],
|
||||
onSideSub: [96, 100, 106],
|
||||
},
|
||||
// The playful layout derives its whole palette from the accent: a tinted
|
||||
// page, near-white cards with a soft border, pill chips, and a warm dark ink
|
||||
// that is the accent shaded down rather than plain black.
|
||||
social: {
|
||||
ink: shade(accent, 0.35),
|
||||
sub: tint(shade(accent, 0.55), 0.75),
|
||||
hair: tint(accent, 0.30),
|
||||
@@ -151,46 +180,41 @@ function resolve(overrides = {}) {
|
||||
// Sparkles/blob sit on the tinted page, so they need more punch than the
|
||||
// card border or they simply vanish.
|
||||
deko: tint(accent, 0.45),
|
||||
band: tint(accent, 0.22),
|
||||
pillBg: tint(accent, 0.18),
|
||||
pillInk: shade(accent, 0.6),
|
||||
sidebarBg, // unused in this layout, kept so the shape stays stable
|
||||
track: tint(accent, 0.45),
|
||||
onSide: shade(accent, 0.35),
|
||||
onSideSub: tint(shade(accent, 0.55), 0.75),
|
||||
}
|
||||
: {
|
||||
ink: [33, 33, 33],
|
||||
sub: [92, 96, 100],
|
||||
hair: [208, 210, 214],
|
||||
},
|
||||
// Sunny keeps the page white and puts the colour into solid blocks: pastel
|
||||
// bands and rails on one side, the full-strength accent in the display
|
||||
// headings and the role badge. Only two tones, used at maximum contrast.
|
||||
sunny: {
|
||||
ink: [40, 36, 42],
|
||||
sub: [124, 118, 126],
|
||||
hair: tint(accent, 0.22),
|
||||
accent,
|
||||
pageBg: null, // no page tint in the classic layout
|
||||
pageBg: null, // white page — the colour lives in the bands
|
||||
cardBg: [255, 255, 255],
|
||||
cardBorder: [208, 210, 214],
|
||||
deko: [208, 210, 214], // the classic layout draws no sparkles; kept for shape parity
|
||||
pillBg: tint(accent, 0.12),
|
||||
pillInk: accent,
|
||||
sidebarBg,
|
||||
track: [199, 202, 208],
|
||||
onSide: [38, 40, 44],
|
||||
onSideSub: [96, 100, 106],
|
||||
cardBorder: tint(accent, 0.22),
|
||||
deko: tint(accent, 0.30),
|
||||
band: tint(accent, 0.24), // the pastel hero band and section bars
|
||||
pillBg: accent, // solid badge…
|
||||
pillInk: [255, 255, 255], // …with the text knocked out white
|
||||
sidebarBg, // unused
|
||||
track: tint(accent, 0.16), // rail behind a language level bar
|
||||
onSide: [40, 36, 42],
|
||||
onSideSub: [124, 118, 126],
|
||||
},
|
||||
};
|
||||
const rc = RC[s.layout] || RC.sidebar;
|
||||
|
||||
// The cover letter always mirrors the résumé, so the two documents read as one
|
||||
// set — a playful CV never arrives with a stiff monochrome letter.
|
||||
const lc = social
|
||||
? {
|
||||
ink: rc.ink,
|
||||
muted: rc.sub,
|
||||
hair: rc.hair,
|
||||
accent,
|
||||
pageBg: rc.pageBg,
|
||||
cardBg: rc.cardBg,
|
||||
cardBorder: rc.cardBorder,
|
||||
deko: rc.deko,
|
||||
pillBg: rc.pillBg,
|
||||
pillInk: rc.pillInk,
|
||||
}
|
||||
: {
|
||||
const LC = {
|
||||
sidebar: {
|
||||
ink: [26, 26, 26],
|
||||
muted: [92, 96, 100],
|
||||
hair: [206, 208, 212],
|
||||
@@ -199,18 +223,52 @@ function resolve(overrides = {}) {
|
||||
cardBg: [255, 255, 255],
|
||||
cardBorder: [206, 208, 212],
|
||||
deko: [206, 208, 212],
|
||||
band: rc.band,
|
||||
pillBg: tint(accent, 0.12),
|
||||
pillInk: accent,
|
||||
},
|
||||
social: {
|
||||
ink: rc.ink,
|
||||
muted: rc.sub,
|
||||
hair: rc.hair,
|
||||
accent,
|
||||
pageBg: rc.pageBg,
|
||||
cardBg: rc.cardBg,
|
||||
cardBorder: rc.cardBorder,
|
||||
deko: rc.deko,
|
||||
band: rc.band,
|
||||
pillBg: rc.pillBg,
|
||||
pillInk: rc.pillInk,
|
||||
},
|
||||
sunny: {
|
||||
ink: rc.ink,
|
||||
muted: rc.sub,
|
||||
hair: rc.hair,
|
||||
accent,
|
||||
pageBg: rc.pageBg,
|
||||
cardBg: rc.cardBg,
|
||||
cardBorder: rc.cardBorder,
|
||||
deko: rc.deko,
|
||||
band: rc.band,
|
||||
pillBg: rc.pillBg,
|
||||
pillInk: rc.pillInk,
|
||||
},
|
||||
};
|
||||
const lc = LC[s.layout] || LC.sidebar;
|
||||
|
||||
// Which typefaces the renderer registers and draws with. The playful layout
|
||||
// adds a script face for its "Hallo, ich bin" line; Sunny lives off Poppins
|
||||
// Bold alone (its whole hierarchy is size, not style); the classic one stays
|
||||
// on Lato and embeds nothing extra.
|
||||
const FONTS = {
|
||||
sidebar: { sans: 'Lato', script: null },
|
||||
social: { sans: 'Poppins', script: 'Pacifico' },
|
||||
sunny: { sans: 'Poppins', script: null },
|
||||
};
|
||||
|
||||
return {
|
||||
layout: s.layout,
|
||||
// Which typefaces the renderer registers and draws with. The playful layout
|
||||
// uses a geometric sans plus a script face for the "Hallo, ich bin" line;
|
||||
// the classic one stays on Lato and embeds nothing extra.
|
||||
fonts: social
|
||||
? { sans: 'Poppins', script: 'Pacifico' }
|
||||
: { sans: 'Lato', script: null },
|
||||
fonts: FONTS[s.layout] || FONTS.sidebar,
|
||||
rc,
|
||||
lc,
|
||||
foto: {
|
||||
|
||||
+537
-8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user