From 1f2e299c5503afcc56a04eaae988592998c56859 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Tue, 14 Jul 2026 17:25:20 +0200 Subject: [PATCH] =?UTF-8?q?Design:=20drittes=20Layout=20"Sunny"=20f=C3=BCr?= =?UTF-8?q?=20Anschreiben=20und=20Lebenslauf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/design.js | 150 +++++++++---- lib/documents.js | 545 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 641 insertions(+), 54 deletions(-) diff --git a/lib/design.js b/lib/design.js index 24d037d..354acb6 100644 --- a/lib/design.js +++ b/lib/design.js @@ -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.` 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: { diff --git a/lib/documents.js b/lib/documents.js index 563902b..458d3b4 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -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