From d20a265ebc8e4eea23d5e4028f44d1e216527418 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Tue, 14 Jul 2026 22:15:20 +0200 Subject: [PATCH] Neues Layout "Tupfen": dezent, mit verstreuten Punkten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weiße Seite, Name groß und mittig in Tinte, luftiger Blocksatz — und korallfarbene Punkte in den Rändern, teils vom Seitenrand angeschnitten, als einzige Farbe des Blattes. Der Lebenslauf greift das Motiv auf: die Punkte markieren die Abschnitte, führen die Bullets und bilden als Fünfer-Reihe die Sprach-Skala (gespeist aus niveauLevel(), also aus der Formulierung des Lebenslaufs selbst). Die Punkte skalieren bewusst nicht mit der Schrift: sie gehören zur Seite wie die Ränder. Das Anschreiben reserviert dafür unten ein Band, in das der Text nicht hineinläuft. Der Brief behält das DIN-Anschriftenfeld, das der Vorlage fehlt — es sitzt in deren zweispaltiger Meta-Zeile (Empfänger links, Datum und Kontakt rechts), so bleibt die Komposition erhalten und die Bewerbung deutsch. Dazu: - neue Akzentfarbe "Koralle" - write() kann optional Blocksatz. jsPDF stretcht eine Zeile nur, wenn es weiß, dass eine weitere folgt — der Absatz geht deshalb in einem Aufruf rüber, wodurch die Schlusszeile ungestretcht bleibt. - Infobox unter /vorlagen beschreibt jetzt auch Sunny (fehlte) und Tupfen Co-Authored-By: Claude Opus 4.8 --- lib/design.js | 65 +++++- lib/documents.js | 544 ++++++++++++++++++++++++++++++++++++++++++++- views/vorlagen.ejs | 5 + 3 files changed, 609 insertions(+), 5 deletions(-) diff --git a/lib/design.js b/lib/design.js index f200d70..f18f4b5 100644 --- a/lib/design.js +++ b/lib/design.js @@ -19,6 +19,13 @@ // level bars for the languages. Bold but tidy — right for marketing, // media and design roles that still want a readable, gridded page. // +// tupfen — the quiet one with a wink: a white page, the name centred and set +// large in plain ink, airy body text — and scattered coral dots in +// the margins, some running off the page edge. The dots are the only +// colour on the sheet; the résumé picks the motif back up as section +// markers, bullets and five-dot language meters. Restrained enough +// for any office, but nobody mistakes it for a template. +// // 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. // @@ -36,6 +43,10 @@ const AKZENTE = [ { 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] }, + // Deliberately deeper than the pastel dots of Tupfen's reference: in `sidebar` + // the accent *is* the text colour, and a pale coral would be unreadable there. + // Tupfen's dots are tinted up from this tone, which lands them back at pastel. + { key: 'koralle', label: 'Koralle (Tupfen)', rgb: [222, 106, 106] }, { key: 'pink', label: 'Pink (verspielt)', rgb: [232, 54, 143] }, { key: 'violett', label: 'Violett (verspielt)', rgb: [147, 51, 187] }, ]; @@ -44,6 +55,7 @@ const LAYOUTS = [ { key: 'sidebar', label: 'Klassisch mit Sidebar (Standard)' }, { key: 'social', label: 'Social Media (verspielt)' }, { key: 'sunny', label: 'Sunny (plakativ, Farbbänder)' }, + { key: 'tupfen', label: 'Tupfen (dezent, verspielte Punkte)' }, ]; const SIDEBAR_VARIANTEN = [ @@ -74,6 +86,9 @@ const LAYOUT_DEFAULTS = { // 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' }, + // Tupfen scatters coral dots; a round portrait is simply the largest of them, + // so it falls in with the motif instead of fighting it. + tupfen: { akzent: 'koralle', foto_form: 'rund' }, }; const DEFAULTS = { @@ -158,6 +173,7 @@ function resolve(overrides = {}) { cardBg: [255, 255, 255], cardBorder: [208, 210, 214], deko: [208, 210, 214], // the classic layout draws no sparkles; kept for shape parity + dekoHell: [230, 232, 235], band: tint(accent, 0.14), pillBg: tint(accent, 0.12), pillInk: accent, @@ -180,6 +196,7 @@ 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), + dekoHell: tint(accent, 0.22), band: tint(accent, 0.22), pillBg: tint(accent, 0.18), pillInk: shade(accent, 0.6), @@ -200,6 +217,7 @@ function resolve(overrides = {}) { cardBg: [255, 255, 255], cardBorder: tint(accent, 0.22), deko: tint(accent, 0.30), + dekoHell: tint(accent, 0.16), 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 @@ -208,6 +226,29 @@ function resolve(overrides = {}) { onSide: [40, 36, 42], onSideSub: [124, 118, 126], }, + // Tupfen is a monochrome page that happens to have confetti on it. The text + // is ink on white throughout — the accent never touches a paragraph, only + // the dots: the scattered ones in the margins, the section markers, the + // bullets, the language meters. The hairline stays a *neutral* grey rather + // than a tinted one, or the restraint collapses and the page turns pink. + tupfen: { + ink: [26, 26, 28], + sub: [112, 114, 120], + hair: [226, 228, 232], + accent, + pageBg: null, // white page — the dots are the whole design + cardBg: [255, 255, 255], + cardBorder: [226, 228, 232], + deko: tint(accent, 0.62), // the dots' base tone + dekoHell: tint(accent, 0.34), // …and the paler ones behind them, for depth + band: tint(accent, 0.14), + pillBg: tint(accent, 0.12), + pillInk: shade(accent, 0.72), + sidebarBg, // unused + track: tint(accent, 0.20), // an unfilled dot of a language meter + onSide: [26, 26, 28], + onSideSub: [112, 114, 120], + }, }; const rc = RC[s.layout] || RC.sidebar; @@ -223,6 +264,7 @@ function resolve(overrides = {}) { cardBg: [255, 255, 255], cardBorder: [206, 208, 212], deko: [206, 208, 212], + dekoHell: [230, 232, 235], band: rc.band, pillBg: tint(accent, 0.12), pillInk: accent, @@ -236,6 +278,7 @@ function resolve(overrides = {}) { cardBg: rc.cardBg, cardBorder: rc.cardBorder, deko: rc.deko, + dekoHell: rc.dekoHell, band: rc.band, pillBg: rc.pillBg, pillInk: rc.pillInk, @@ -249,6 +292,7 @@ function resolve(overrides = {}) { cardBg: rc.cardBg, cardBorder: rc.cardBorder, deko: rc.deko, + dekoHell: rc.dekoHell, band: rc.band, // The letter's role badge sits on the pastel head band, so a solid accent // slug reads too loud next to the résumé's poster. A white pill with the @@ -257,17 +301,36 @@ function resolve(overrides = {}) { pillBg: rc.cardBg, pillInk: accent, }, + // Tupfen's letter is not the quieter twin of anything — the résumé is already + // quiet. Both sheets are the same white page with the same confetti, so the + // letter simply takes the résumé's tones unchanged. + tupfen: { + ink: rc.ink, + muted: rc.sub, + hair: rc.hair, + accent, + pageBg: rc.pageBg, + cardBg: rc.cardBg, + cardBorder: rc.cardBorder, + deko: rc.deko, + dekoHell: rc.dekoHell, + 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. + // on Lato and embeds nothing extra. Tupfen takes Poppins too — the reference's + // geometric grotesk, whose round, wide letterforms rhyme with the dots. const FONTS = { sidebar: { sans: 'Lato', script: null }, social: { sans: 'Poppins', script: 'Pacifico' }, sunny: { sans: 'Poppins', script: null }, + tupfen: { sans: 'Poppins', script: null }, }; return { diff --git a/lib/documents.js b/lib/documents.js index fd2baee..6d3ba04 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -692,6 +692,10 @@ const PAGE = { w: 210, h: 297 }; const MIN_SCALE = 0.6; // Low-level text writer. `cur` is a {y} cursor; advances it. `dry` = measure only. +// +// `o.justify` sets the block flush on both edges, but never its closing line — a +// justified last line is stretched across the full measure and tears the +// paragraph apart. Opt-in, so the layouts that want a ragged right keep it. function write(doc, S, dry, cur, text, o) { const pt = o.pt; const factor = o.factor == null ? 1.3 : o.factor; @@ -701,13 +705,31 @@ function write(doc, S, dry, cur, text, o) { if (o.charSpace) doc.setCharSpace(o.charSpace * S); const content = o.upper ? String(text).toUpperCase() : String(text); const lines = doc.splitTextToSize(content, o.width); - for (const ln of lines) { + const lead = pt * S * PT * factor; + + if (o.justify && !o.align) { + // The whole block goes over in a single call, because jsPDF only stretches a + // line once it knows another one follows: hand it one line at a time and it + // justifies nothing. Handed the array, it sets every line flush but the + // closing one — which is exactly right, a stretched last line tears the + // paragraph apart. Its own line advance has to be taught our leading first. if (!dry) { + const vorher = doc.getLineHeightFactor(); + doc.setLineHeightFactor(factor); doc.setTextColor(o.color[0], o.color[1], o.color[2]); - const drawX = o.align === 'right' ? o.right : o.x; - doc.text(ln, drawX, cur.y + pt * S * PT * 0.76, { align: o.align || 'left' }); + doc.text(lines, o.x, cur.y + pt * S * PT * 0.76, { align: 'justify', maxWidth: o.width }); + doc.setLineHeightFactor(vorher); + } + cur.y += lines.length * lead; + } else { + for (const ln of lines) { + if (!dry) { + doc.setTextColor(o.color[0], o.color[1], o.color[2]); + const drawX = o.align === 'right' ? o.right : o.x; + doc.text(ln, drawX, cur.y + pt * S * PT * 0.76, { align: o.align || 'left' }); + } + cur.y += lead; } - cur.y += pt * S * PT * factor; } if (o.charSpace) doc.setCharSpace(0); } @@ -1920,6 +1942,387 @@ function composeCVSunny(doc, t, S, dry, ctx) { return cur.y; } +// =========================================================================== +// "Tupfen" layout — the quiet one with a wink +// +// A white page, ink on white, generous air — and coral dots scattered through +// the margins, a few of them cut off by the edge of the sheet. The dots are the +// only colour on the page, and they are also the layout's alphabet: they mark +// the sections, they lead the bullets, and five of them in a row make a language +// meter. Restrained enough for any office; nobody mistakes it for a template. +// +// Same machinery as the other layouts (dry-run measuring, one-page auto-scale, +// the shared `write` helper); only the drawing differs. +// =========================================================================== + +const TU = { + mx: 20, // page margin + get r() { return PAGE.w - this.mx; }, + get w() { return PAGE.w - 2 * this.mx; }, + gap: 9, // gutter between the two competency columns + get col() { return (this.w - this.gap) / 2; }, + datumW: 28, // the résumé's narrow left date column + top: 18, + bottom: 24, // reserves the résumé's (small) bottom dot band +}; + +// The letter needs a wider foot: in the reference the body stops well above the +// big dots at the bottom of the sheet. Reserving the band here means the one-page +// auto-scaler treats the confetti as page furniture and keeps the text clear of +// it, instead of the two colliding. The head gets a little more air too. +const TU_BRIEF = { top: 24, bottom: 34 }; + +// --- The motif ------------------------------------------------------------- + +// The scatter is hand-placed, not seeded-random. That buys three things at once: +// the PDF stays byte-reproducible, no dot can ever land in the text column, and +// the result reads as *composed* — random placement gives an even sprinkle, which +// is precisely the wallpaper look the reference avoids. +// +// Coordinates are absolute page millimetres. The rule kept by hand: +// • the top band, clear of the centred name +// • the two outer margins (a dot's right edge stays left of the text column, +// its left edge right of it) +// • the bottom band that the layout's `bottom` margin reserves +// Never inside the text column. Some centres sit off the sheet on purpose, so the +// dot is cut by the page edge exactly as in the reference — jsPDF clips them. +// +// `ton`: 0 = the base tone, 1 = the paler one. Two tones give the scatter depth; +// one tone gives it polka dots. +const TUPFEN_BRIEF = [ + { x: 24, y: -2, r: 8.5, ton: 0 }, // cut off by the top edge + { x: 19, y: 13, r: 1.4, ton: 1 }, + { x: 37, y: 8, r: 2.4, ton: 0 }, + { x: 58, y: 12, r: 1.1, ton: 1 }, + { x: 79, y: 7, r: 2.0, ton: 0 }, + { x: 101, y: 13, r: 0.8, ton: 1 }, + { x: 147, y: 6, r: 1.7, ton: 1 }, + { x: 169, y: 12, r: 0.9, ton: 0 }, + { x: 191, y: 4, r: 2.6, ton: 0 }, + { x: 0, y: 27, r: 3.2, ton: 0 }, // cut off by the left edge + { x: 204, y: 23, r: 1.3, ton: 1 }, + { x: 8, y: 76, r: 1.5, ton: 1 }, // …from here down: the outer margins only + { x: 199, y: 97, r: 2.2, ton: 0 }, + { x: 13, y: 133, r: 0.9, ton: 0 }, + { x: 210, y: 151, r: 2.8, ton: 0 }, // cut off by the right edge + { x: 7, y: 187, r: 2.0, ton: 0 }, + { x: 197, y: 206, r: 1.1, ton: 1 }, + { x: 11, y: 233, r: 0.8, ton: 1 }, + { x: 129, y: 271, r: 4.6, ton: 0 }, // the bottom band the margin reserves + { x: 9, y: 274, r: 1.7, ton: 1 }, + { x: 61, y: 279, r: 2.3, ton: 0 }, + { x: 158, y: 284, r: 1.2, ton: 1 }, + { x: 187, y: 271, r: 0.9, ton: 1 }, + { x: 100, y: 290, r: 1.0, ton: 1 }, + { x: 46, y: 297, r: 6.5, ton: 0 }, // cut off by the bottom edge + { x: 206, y: 292, r: 3.4, ton: 0 }, // …and by the corner +]; + +// The résumé's own scatter: denser at the top around the portrait, thinner down +// the sides, because the page below is full of text. A different composition on +// purpose — the pair should look designed, not copy-pasted. +const TUPFEN_CV = [ + { x: 12, y: 9, r: 2.8, ton: 0 }, + { x: 31, y: 19, r: 1.2, ton: 1 }, + { x: 45, y: 7, r: 1.6, ton: 1 }, + { x: 57, y: 26, r: 2.1, ton: 0 }, + { x: 0, y: 55, r: 4.0, ton: 0 }, // cut off by the left edge + { x: 152, y: 12, r: 1.0, ton: 1 }, + { x: 166, y: 25, r: 2.6, ton: 0 }, + { x: 183, y: 8, r: 1.3, ton: 1 }, + { x: 198, y: 40, r: 1.5, ton: 0 }, + { x: 210, y: 18, r: 3.6, ton: 0 }, // cut off by the right edge + { x: 8, y: 92, r: 1.1, ton: 1 }, + { x: 202, y: 84, r: 0.9, ton: 1 }, + { x: 12, y: 121, r: 2.2, ton: 0 }, + { x: 199, y: 139, r: 1.8, ton: 0 }, + { x: 6, y: 168, r: 0.8, ton: 1 }, + { x: 205, y: 191, r: 1.2, ton: 1 }, + { x: 10, y: 214, r: 1.9, ton: 0 }, + { x: 200, y: 236, r: 2.4, ton: 0 }, + { x: 15, y: 262, r: 1.0, ton: 1 }, + { x: 196, y: 279, r: 1.4, ton: 1 }, + { x: 3, y: 290, r: 3.0, ton: 0 }, // the bottom corners close the frame + { x: 210, y: 297, r: 5.5, ton: 0 }, +]; + +// Drawn once, as the very first thing, straight onto the page — and deliberately +// *not* scaled by S. The confetti belongs to the sheet, like the margins do; a +// densely packed résumé that shrinks its type to fit must not also shrink its +// decoration, or the page would quietly change character with its content. +function tupfen(doc, p, set) { + for (const d of set) { + const c = d.ton ? p.dekoHell : p.deko; + doc.setFillColor(c[0], c[1], c[2]); + doc.circle(d.x, d.y, d.r, 'F'); + } +} + +// Centred text with letter-spacing. jsPDF's own `align: 'center'` measures the +// string *without* the character spacing it then applies, so a tracked line comes +// out visibly off-centre. Measuring it here keeps it centred. +function tuCentered(doc, S, dry, cur, text, o) { + const label = o.upper ? String(text).toUpperCase() : String(text); + if (!label) return; + const cs = (o.charSpace || 0) * S; + doc.setFont(doc.__sans, o.style || 'normal'); + doc.setFontSize(o.pt * S); + const w = doc.getTextWidth(label) + cs * Math.max(0, label.length - 1); + if (!dry) { + if (cs) doc.setCharSpace(cs); + doc.setTextColor(o.color[0], o.color[1], o.color[2]); + doc.text(label, PAGE.w / 2 - w / 2, cur.y + o.pt * S * PT * 0.76); + if (cs) doc.setCharSpace(0); + } + cur.y += o.pt * S * PT * (o.factor == null ? 1.3 : o.factor); +} + +// Section label: the motif in miniature (a filled accent dot), a tracked capital +// label, and a hairline running out to the right margin. The dot is what makes it +// this layout's heading and not a generic one. +function tuHeading(doc, t, S, dry, cur, title) { + const pt = 8.6; + const cs = 1.1 * S; + cur.y += 7 * S; + const label = String(title).toUpperCase(); + doc.setFont(doc.__sans, 'bold'); + doc.setFontSize(pt * S); + const tw = doc.getTextWidth(label) + cs * Math.max(0, label.length - 1); + + const r = 1.35 * S; + const textX = TU.mx + 2 * r + 2.6 * S; + const midY = cur.y + pt * S * PT * 0.42; + if (!dry) { + doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]); + doc.circle(TU.mx + r, midY, r, 'F'); + doc.setCharSpace(cs); + doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]); + doc.text(label, textX, cur.y + pt * S * PT * 0.76); + doc.setCharSpace(0); + const lx = textX + tw + 4 * S; + if (lx < TU.r - 6 * S) rule(doc, S, dry, midY, lx, TU.r, t.rc.hair, 0.3); + } + cur.y += pt * S * PT * 1.25 + 2.4 * S; +} + +// Bullets led by a small accent dot — the same mark as the section heading's, a +// size down. +function tuBullets(doc, t, S, dry, cur, items, x, w, pt = 8.8) { + const ind = 3.6 * S; + const tw = w - ind; + for (const it of items) { + doc.setFont(doc.__sans, 'normal'); + doc.setFontSize(pt * S); + const lines = doc.splitTextToSize(String(it), tw); + lines.forEach((ln, i) => { + if (!dry) { + if (i === 0) { + const r = 0.7 * S; + doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]); + doc.circle(x + r + 0.3 * S, cur.y + pt * S * PT * 0.42, r, 'F'); + } + doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]); + doc.text(ln, x + ind, cur.y + pt * S * PT * 0.76); + } + cur.y += pt * S * PT * 1.36; + }); + cur.y += 1.1 * S; + } +} + +// Three dots, centred: the separator under the head. A rule would be the obvious +// move — and would say nothing about this layout. +function tuTrio(doc, p, S, dry, cur) { + const r = 1.1 * S; + const gap = 4.4 * S; + if (!dry) { + for (const i of [-1, 0, 1]) { + const c = i === 0 ? p.accent : p.deko; + doc.setFillColor(c[0], c[1], c[2]); + doc.circle(PAGE.w / 2 + i * gap, cur.y + r, r, 'F'); + } + } + cur.y += 2 * r; +} + +// A language level as five dots — the motif doing actual work. The fill comes from +// niveauLevel(), i.e. from the wording the CV itself uses; nothing is invented. +function tuLangRow(doc, t, S, dry, cur, x, w, s) { + const pt = 8.8; + const N = 5; + const r = 1.5 * S; + const gap = 2.4 * S; + const meterW = N * 2 * r + (N - 1) * gap; + const voll = Math.max(1, Math.round(niveauLevel(s.niveau) * N)); + const midY = cur.y + pt * S * PT * 0.42; + if (!dry) { + doc.setFont(doc.__sans, 'normal'); + doc.setFontSize(pt * S); + doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]); + doc.text(String(s.sprache || ''), x, cur.y + pt * S * PT * 0.76, { maxWidth: w - meterW - 4 * S }); + + const first = x + w - meterW + r; + for (let i = 0; i < N; i += 1) { + const c = i < voll ? t.rc.accent : t.rc.track; + doc.setFillColor(c[0], c[1], c[2]); + doc.circle(first + i * (2 * r + gap), midY, r, 'F'); + } + } + cur.y += pt * S * PT * 1.9; +} + +// One entry: the dates in a narrow column on the left, the substance on the right. +// A single, calm column down the page — the confetti is the only thing allowed to +// be playful here. +function tuEintrag(doc, t, S, dry, cur, { zeitraum, titel, unter, zusatz, punkte }) { + const dw = TU.datumW * S; + const bx = TU.mx + dw + 5 * S; + const bw = TU.r - bx; + const start = cur.y; + + const dc = { y: start + 0.8 * S }; + write(doc, S, dry, dc, zeitraum || '', { pt: 8, color: t.rc.sub, x: TU.mx, width: dw, factor: 1.28 }); + + const bc = { y: start }; + write(doc, S, dry, bc, titel || '', { pt: 10, style: 'bold', color: t.rc.ink, x: bx, width: bw, factor: 1.24 }); + if (unter) write(doc, S, dry, bc, unter, { pt: 8.8, color: t.rc.sub, x: bx, width: bw, factor: 1.3 }); + if (zusatz) write(doc, S, dry, bc, zusatz, { pt: 8.4, color: t.rc.sub, x: bx, width: bw, factor: 1.3 }); + if (punkte && punkte.length) { + bc.y += 1.6 * S; + tuBullets(doc, t, S, dry, bc, punkte, bx, bw); + } + + cur.y = Math.max(dc.y, bc.y) + 3.4 * S; +} + +// The head: portrait, name, role, contact — all centred, as in the reference. The +// round portrait is simply the largest dot on the page. +function tuKopf(doc, t, S, dry, cur, { header, titel, foto }) { + const cx = PAGE.w / 2; + const photo = fitPhoto(doc, foto, t.foto.rund); + if (photo.ok) { + const D = 30 * S; + const fx = cx - D / 2; + const fy = cur.y; + if (!dry) { + // Cover the frame and clip the overflow, so the face keeps its proportions. + const k = Math.max(D / photo.dw, D / photo.dh); + const dw = photo.dw * k; + const dh = photo.dh * k; + doc.saveGraphicsState(); + if (t.foto.rund) doc.circle(cx, fy + D / 2, D / 2, null); + else doc.roundedRect(fx, fy, D, D, 2.4 * S, 2.4 * S, null); + doc.clip(); + doc.discardPath(); + doc.addImage(foto.dataUrl, foto.format || 'PNG', fx + (D - dw) / 2, fy + (D - dh) / 2, dw, dh); + doc.restoreGraphicsState(); + } + cur.y += D + 5.5 * S; + } + + const name = String(header.name || ''); + let namePt = 26; + doc.setFont(doc.__sans, 'bold'); + for (; namePt > 15; namePt -= 0.5) { + doc.setFontSize(namePt * S); + if (doc.getTextWidth(name) <= TU.w * 0.92) break; + } + tuCentered(doc, S, dry, cur, name, { pt: namePt, style: 'bold', color: t.rc.ink, factor: 1.08 }); + + if (titel) { + cur.y += 1.6 * S; + tuCentered(doc, S, dry, cur, titel, { + pt: 8.8, style: 'bold', color: t.rc.accent, factor: 1.35, upper: true, charSpace: 1.3, + }); + } + + const kontakt = buildContactLines(header).map((k) => k.text).join(' · '); + if (kontakt) { + cur.y += 1.8 * S; + // Centred and wrapping: a full contact set can outrun one line, and jsPDF's + // own centring is exact here (no letter-spacing in play). + write(doc, S, dry, cur, kontakt, + { pt: 8.2, color: t.rc.sub, x: cx, width: TU.w, factor: 1.35, align: 'center' }); + } + + cur.y += 3.2 * S; + tuTrio(doc, t.rc, S, dry, cur); + cur.y += 1.5 * S; +} + +function composeCVTupfen(doc, t, S, dry, ctx) { + const { cv, header, titel, foto } = ctx; + if (!dry) tupfen(doc, t.rc, TUPFEN_CV); + + const cur = { y: TU.top }; + tuKopf(doc, t, S, dry, cur, { header, titel, foto }); + + if (cv.profil) { + write(doc, S, dry, cur, cv.profil, + { pt: 9, color: t.rc.ink, x: TU.mx, width: TU.w, factor: 1.5, justify: true }); + } + + if (cv.berufserfahrung.length) { + tuHeading(doc, t, S, dry, cur, 'Berufserfahrung'); + for (const e of cv.berufserfahrung) { + tuEintrag(doc, t, S, dry, cur, + { zeitraum: e.zeitraum, titel: e.titel, unter: e.firma, punkte: e.punkte }); + } + } + + // One "Ausbildung" heading for degrees, apprenticeship and courses alike — each + // entry names its own qualification, so three headings would only cost air. + const ausbildung = [ + ...(cv.studium || []), + ...(cv.berufsausbildung || []), + ...(cv.weiterbildungen || []), + ]; + if (ausbildung.length) { + tuHeading(doc, t, S, dry, cur, 'Ausbildung'); + for (const e of ausbildung) { + tuEintrag(doc, t, S, dry, cur, + { zeitraum: e.zeitraum, titel: e.abschluss, unter: e.institution, zusatz: e.zusatz }); + } + } + + if (cv.kenntnisse.length) { + tuHeading(doc, t, S, dry, cur, 'Kenntnisse'); + // Two columns of dotted bullets: the list is short items, and a single column + // of them would leave half the page empty. + const mitte = Math.ceil(cv.kenntnisse.length / 2); + const startY = cur.y; + const links = { y: startY }; + tuBullets(doc, t, S, dry, links, cv.kenntnisse.slice(0, mitte), TU.mx, TU.col); + const rechts = { y: startY }; + tuBullets(doc, t, S, dry, rechts, cv.kenntnisse.slice(mitte), TU.mx + TU.col + TU.gap, TU.col); + cur.y = Math.max(links.y, rechts.y); + } + + if (cv.sprachen.length) { + tuHeading(doc, t, S, dry, cur, 'Sprachen'); + // Two languages per row, so the meters pair up instead of running down one + // side of an otherwise empty band. + for (let i = 0; i < cv.sprachen.length; i += 2) { + const zeile = cv.sprachen.slice(i, i + 2); + let unten = cur.y; + zeile.forEach((s, j) => { + const c = { y: cur.y }; + tuLangRow(doc, t, S, dry, c, TU.mx + j * (TU.col + TU.gap), TU.col, s); + unten = Math.max(unten, c.y); + }); + cur.y = unten; + } + } + + if (cv.hobbys.length) { + tuHeading(doc, t, S, dry, cur, 'Interessen'); + write(doc, S, dry, cur, cv.hobbys.join(' · '), + { pt: 8.8, color: t.rc.sub, x: TU.mx, width: TU.w, factor: 1.35 }); + } + + return cur.y; +} + // =========================================================================== // Cover letter — clean, single-column DIN-5008 business letter (monochrome) // =========================================================================== @@ -2428,6 +2831,137 @@ function composeLetterSunny(doc, t, S, dry, cur, { letter, header, job, anlagen, } } +// =========================================================================== +// Cover letter, Tupfen variant — the layout's centrepiece +// +// The reference is a letter, and this is the sheet it was drawn for: the name +// centred and set large in plain ink, a two-column meta row under it, an airy +// justified body — and the confetti in the margins, some of it running off the +// page. No band, no card, no pill: the dots carry the entire design, so the text +// can be as plain as a letter should be. +// +// Where it departs from the reference: the recipient block stays. The original +// has none, and a German application without an Anschriftenfeld reads as sloppy +// no matter how well it is set. It slots into the reference's own two-column +// grid — recipient left, date and contact right — so the composition survives. +// =========================================================================== + +function composeLetterTupfen(doc, t, S, dry, cur, { letter, header, job, anlagen, signatur }) { + if (!dry) tupfen(doc, t.lc, TUPFEN_BRIEF); + + const x = TU.mx + 5 * S; // ≈25 mm — the DIN gutter + const right = TU.r - 5 * S; + const width = right - x; + const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + const betreff = letter.betreff || (job.stelle ? `Bewerbung als ${job.stelle}` : 'Bewerbung'); + const stadt = cityName(header); + + // --- The name, centred and large, in ink. Not in the accent: the dots are the + // page's only colour, and a coral name would make them redundant. + const name = String(header.name || ''); + let namePt = 26; + doc.setFont(doc.__sans, 'bold'); + for (; namePt > 15; namePt -= 0.5) { + doc.setFontSize(namePt * S); + if (doc.getTextWidth(name) <= width * 0.9) break; + } + tuCentered(doc, S, dry, cur, name, { pt: namePt, style: 'bold', color: t.lc.ink, factor: 1.1 }); + + // --- The meta row: recipient left, sender and date right, both starting on the + // same line — the reference's grid, with the DIN block folded into it. + cur.y += 13 * S; + const emp = letter.empfaenger || {}; + const empFirma = emp.firma || job.firma || ''; + const empOrt = emp.ort || job.ort || ''; + const spaltenW = width * 0.5 - 4 * S; + + const links = { y: cur.y }; + const zeile = { pt: 9.4, color: t.lc.ink, x, width: spaltenW, factor: 1.34 }; + if (empFirma) write(doc, S, dry, links, empFirma, { ...zeile, style: 'bold' }); + if (emp.ansprechpartner) write(doc, S, dry, links, `z. Hd. ${emp.ansprechpartner}`, zeile); + if (emp.adresse) write(doc, S, dry, links, emp.adresse, zeile); + if (empOrt) write(doc, S, dry, links, empOrt, zeile); + + // Right column, right-aligned: the sender's own details, closed by the date — + // which the reference sets bold, the one piece of emphasis in the whole row. + const rechts = { y: cur.y }; + const rZeile = { pt: 9.4, color: t.lc.muted, x, right, width: spaltenW, factor: 1.34, align: 'right' }; + if (header.adresse) { + const adr = String(header.adresse).split(/[\n,]/).map((s) => s.trim()).filter(Boolean).join(', '); + if (adr) write(doc, S, dry, rechts, adr, rZeile); + } + if (header.telefon) write(doc, S, dry, rechts, header.telefon, rZeile); + if (header.email) write(doc, S, dry, rechts, header.email, rZeile); + rechts.y += 1.6 * S; + write(doc, S, dry, rechts, stadt ? `${stadt}, ${today}` : today, + { ...rZeile, style: 'bold', color: t.lc.ink }); + + cur.y = Math.max(links.y, rechts.y); + + // --- Subject, led by a single accent dot: the same mark that opens every + // section of the résumé, and the only colour inside the text block. + cur.y += 11 * S; + const bpt = 12.5; + const br = 1.5 * S; + const bx = x + 2 * br + 3 * S; + if (!dry) { + doc.setFillColor(t.lc.accent[0], t.lc.accent[1], t.lc.accent[2]); + doc.circle(x + br, cur.y + bpt * S * PT * 0.42, br, 'F'); + } + write(doc, S, dry, cur, betreff, + { pt: bpt, style: 'bold', color: t.lc.ink, x: bx, width: right - bx, factor: 1.2 }); + + // --- Salutation and body: justified and generously leaded, as in the reference. + cur.y += 7 * S; + if (letter.anrede) { + write(doc, S, dry, cur, letter.anrede, { pt: 10, color: t.lc.ink, x, width, factor: 1.4 }); + cur.y += 3.4 * S; + } + letter.absaetze.forEach((p, i) => { + if (i > 0) cur.y += 3.6 * S; + write(doc, S, dry, cur, p, { pt: 10, color: t.lc.ink, x, width, factor: 1.55, justify: true }); + }); + + // --- Closing and signature --- + cur.y += 6 * S; + if (letter.gruss) write(doc, S, dry, cur, letter.gruss, { pt: 10, color: t.lc.ink, x, width, factor: 1.3 }); + + let sigOk = false, sigW = 0, sigH = 0; + if (signatur && signatur.dataUrl) { + try { + const props = doc.getImageProperties(signatur.dataUrl); + const maxW = 54 * S, maxH = 23 * S; + sigW = maxW; + sigH = sigW * props.height / props.width; + if (sigH > maxH) { sigH = maxH; sigW = sigH * props.width / props.height; } + sigOk = props.width > 0 && props.height > 0; + } catch (e) { sigOk = false; } + } + if (sigOk) { + cur.y += 2.5 * S; + if (!dry) doc.addImage(signatur.dataUrl, signatur.format || 'PNG', x, cur.y, sigW, sigH); + cur.y += sigH; + } else { + cur.y += 11 * S; + } + // The typed name closes the letter in either case — under the signature image + // where there is one, standing in for it where there is not. + write(doc, S, dry, cur, header.name, { pt: 10, color: t.lc.ink, x, width, factor: 1.3 }); + + // --- Enclosures: a tracked little label and one dot-separated line. Chips or + // pills here would be a second motif, and this page already has one. + if (anlagen && anlagen.length) { + cur.y += 8 * S; + write(doc, S, dry, cur, anlagen.length > 1 ? 'Anlagen' : 'Anlage', + { pt: 7.6, style: 'bold', color: t.lc.muted, x, width, factor: 1.5, charSpace: 0.9, upper: true }); + write(doc, S, dry, cur, anlagen.join(' · '), + { pt: 9, color: t.lc.ink, x, width, factor: 1.35 }); + } + + // No footer strip: the reference closes on white and confetti, and the sender's + // details are already in the meta row. A rule here would only fence them in. +} + // --------------------------------------------------------------------------- // Layout dispatch — the only place that knows which layout draws what. // Adding a layout means adding a row here (plus its palette in lib/design.js). @@ -2437,12 +2971,14 @@ const CV_LAYOUT = { sidebar: { compose: composeCV, box: RV }, social: { compose: composeCVSocial, box: SO }, sunny: { compose: composeCVSunny, box: SU }, + tupfen: { compose: composeCVTupfen, box: TU }, }; const LETTER_LAYOUT = { sidebar: { compose: composeLetter, box: LET }, social: { compose: composeLetterSocial, box: SO }, sunny: { compose: composeLetterSunny, box: SU }, + tupfen: { compose: composeLetterTupfen, box: TU_BRIEF }, }; function renderSingleColumn(compose, t) { diff --git a/views/vorlagen.ejs b/views/vorlagen.ejs index 8235cd0..21df55c 100644 --- a/views/vorlagen.ejs +++ b/views/vorlagen.ejs @@ -350,6 +350,11 @@

Klassisch mit Sidebar – zurückhaltend, monochrom, für klassische Bewerbungen (Standard).

Social Media – verspielt: getönte Seite, runde Karten, Herzchen, Schreibschrift. Bewusst auffällig – passend für kreative und Social-Media-Stellen, nicht für die Bank.

+

Sunny – plakativ: weiße Seite, sehr große Überschriften, pastellige Farbbänder, + ein Foto, das aus dem Kopfband ragt. Auffällig, aber aufgeräumt – für Marketing, Medien und Design.

+

Tupfen – dezent mit Augenzwinkern: weiße Seite, Name groß und mittig, luftiger + Blocksatz – und verstreute Punkte in den Rändern als einzige Farbe. Der Lebenslauf greift sie als + Abschnittsmarker und Sprach-Skala wieder auf. Passt überall, wirkt trotzdem nicht wie von der Stange.