diff --git a/lib/documents.js b/lib/documents.js index 458d3b4..dd233c4 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -1562,20 +1562,43 @@ 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) { +// opposite corner stays open and the page keeps breathing. `p` is the palette +// (t.rc for the résumé, t.lc for the letter), so both documents draw the same +// motif from their own colours. +function suBar(doc, p, 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.setFillColor(p.band[0], p.band[1], p.band[2]); doc.rect(x, y, w, 2.4 * S, 'F'); } +// A filled dot. The confetti scattered through the Sunny documents is built from +// these — a designer's flourish, kept to the margins where no text can collide. +function suDot(doc, cx, cy, r, color) { + doc.setFillColor(color[0], color[1], color[2]); + doc.circle(cx, cy, r, 'F'); +} + +// Confetti in the band's gutters — the strips between its edges and the text +// column, empty by construction, so the decoration can never run into a word. +// +// It all lives on the *right*: dots down the left gutter sat directly beside the +// name and read as bullet points in front of it, not as decoration. The only +// thing on the left is a sparkle low in the band, well clear of the name. +function suKonfetti(doc, p, S, bandY, bandH) { + const gx = SU.r - 3 * S; + suDot(doc, gx, bandY + bandH * 0.26, 1.4 * S, p.accent); + suDot(doc, gx, bandY + bandH * 0.48, 0.9 * S, [255, 255, 255]); + suDot(doc, gx, bandY + bandH * 0.66, 1.9 * S, [255, 255, 255]); + sparkle(doc, SU.mx + 3.4 * S, bandY + bandH - 5.5 * S, 4.4 * S, [255, 255, 255]); +} + // 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); + suBar(doc, t.rc, S, dry, cur.y, align); cur.y += 2.4 * S + 4.5 * S; const label = String(title); @@ -1909,7 +1932,7 @@ function composeCVSunny(doc, t, S, dry, ctx) { if (cv.hobbys.length) { cur.y += 5 * S; - suBar(doc, t, S, dry, cur.y, 'left', 0.28); + suBar(doc, t.rc, 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 }); @@ -2226,57 +2249,101 @@ function composeLetterSunny(doc, t, S, dry, cur, { letter, header, job, anlagen, const betreff = letter.betreff || (job.stelle ? `Bewerbung als ${job.stelle}` : 'Bewerbung'); const stadt = cityName(header); - // --- Head: name and role badge on the pastel band --- + // --- Head: the CV's hero, minus the portrait. Name set as a poster on the + // pastel band, the role as a solid badge, the contact details as white + // chips stacked down the right — the letter is the résumé's twin, so a + // recruiter sees one designed set, not two documents that share a colour. const bandY = cur.y; + const chips = buildContactLines(header) + .filter((k) => k.typ === 'email' || k.typ === 'telefon' || k.typ === 'ort' || k.typ === 'web') + .map((k) => ({ text: k.text, icon: k.typ })); + + // Size the chips first: a long e-mail address must not push the name column + // into a one-word-per-line sliver. Shrink the chips until they fit their half. + let chipPt = 8.4; + let chipW = 0; + if (chips.length) { + const maxW = width * 0.46; + for (; chipPt > 6; chipPt -= 0.2) { + chipW = Math.max(...chips.map((c) => pill(doc, t, S, true, 0, 0, c, { style: 'normal', pt: chipPt }).w)); + if (chipW <= maxW) break; + } + chipW = Math.min(chipW, maxW); + } + const nameW = width - (chips.length ? chipW + 5 * S : 0); + const inner = (c, isDry) => { - c.y += SU.pad * S; + const top = c.y + SU.pad * S; + + const cl = { y: top }; const name = String(header.name || ''); - let namePt = 26; + let namePt = 27; doc.setFont(doc.__sans, 'bold'); for (; namePt > 14; namePt -= 0.5) { doc.setFontSize(namePt * S); - if (doc.getTextWidth(name) <= width) break; + // Fit the longest word: a two-part name may break, a single word may not. + const longest = name.split(/\s+/).reduce((a, b) => (doc.getTextWidth(a) > doc.getTextWidth(b) ? a : b), ''); + if (doc.getTextWidth(longest) <= nameW) break; } - write(doc, S, isDry, c, name, { pt: namePt, style: 'bold', color: t.lc.accent, x, width, factor: 1.04 }); + write(doc, S, isDry, cl, name, { pt: namePt, style: 'bold', color: t.lc.accent, x, width: nameW, factor: 1.02 }); if (header.headline) { - c.y += 2.4 * S; - suBadge(doc, t.lc, S, isDry, c, header.headline, x, width); + cl.y += 2.6 * S; + suBadge(doc, t.lc, S, isDry, cl, header.headline, x, nameW); } - c.y += SU.pad * S; + + const cr = { y: top + 1 * S }; + for (const ch of chips) { + const probe = pill(doc, t, S, true, 0, 0, ch, { style: 'normal', pt: chipPt }); + pill(doc, t, S, isDry, right - probe.w, cr.y, ch, + { style: 'normal', pt: chipPt, bg: t.lc.cardBg, ink: t.lc.accent }); + cr.y += probe.h + 1.8 * S; + } + + c.y = Math.max(cl.y, cr.y - 1.8 * S) + 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'); + suKonfetti(doc, t.lc, S, bandY, bandH); inner({ y: bandY }, false); } - cur.y = bandY + bandH + 10 * S; + cur.y = bandY + bandH + 9 * S; - // --- Recipient (Anschriftfeld) --- + // --- Recipient (Anschriftfeld) with the date as a pastel chip on the same + // line — DIN keeps its address block, the date just stops being a lonely + // right-aligned string and becomes part of the layout's chip language. 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 }); + const datum = stadt ? `${stadt}, ${today}` : today; + if (!dry) { + const p = pill(doc, t, S, true, 0, 0, datum, { pt: 8.6, style: 'normal' }); + pill(doc, t, S, false, right - p.w, cur.y - 0.5 * S, datum, + { pt: 8.6, style: 'normal', bg: t.lc.band, ink: t.lc.accent }); + } + const empW = width * 0.6; // keep the address clear of the date chip + if (empFirma) write(doc, S, dry, cur, empFirma, { pt: 10.2, style: 'bold', color: t.lc.ink, x, width: empW, factor: 1.32 }); + if (emp.ansprechpartner) write(doc, S, dry, cur, `z. Hd. ${emp.ansprechpartner}`, { pt: 10.2, color: t.lc.ink, x, width: empW, factor: 1.32 }); + if (emp.adresse) write(doc, S, dry, cur, emp.adresse, { pt: 10.2, color: t.lc.ink, x, width: empW, factor: 1.32 }); + if (empOrt) write(doc, S, dry, cur, empOrt, { pt: 10.2, color: t.lc.ink, x, width: empW, 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 as a display heading under the bar: the same motif that opens + // every section of the résumé, so the letter's one headline lands with the + // same weight as "Berufserfahrung" does over there. + cur.y += 9 * S; + suBar(doc, t.lc, S, dry, cur.y, 'left', 0.34); + cur.y += 2.4 * S + 4.2 * S; + write(doc, S, dry, cur, betreff, { pt: 15, style: 'bold', color: t.lc.accent, x, width, factor: 1.18 }); - // --- 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 --- + // --- Salutation + body. The salutation carries the accent: it is the moment + // the letter starts speaking, and it echoes the coloured entry titles of + // the résumé. 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; } + if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.2, style: 'bold', color: t.lc.accent, 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 }); @@ -2302,15 +2369,36 @@ function composeLetterSunny(doc, t, S, dry, cur, { letter, header, job, anlagen, if (!dry) doc.addImage(signatur.dataUrl, signatur.format || 'PNG', x, cur.y, sigW, sigH); cur.y += sigH; } else { + // No signature image: the typed name gets a pastel highlight swiped behind + // it, like a marker stroke — the flourish that stands in for the missing + // handwriting instead of leaving an empty gap. 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 }); + const pt = 10.6; + if (!dry) { + doc.setFont(doc.__sans, 'bold'); + doc.setFontSize(pt * S); + const w = doc.getTextWidth(String(header.name || '')); + // Centre the swipe on the glyphs: `write` puts the baseline at + // cur.y + pt*PT*0.76, so a box hung off cur.y alone sits too low and the + // capitals poke out over its top edge. + const h = pt * S * PT * 1.2; + const y = cur.y + pt * S * PT * 0.76 - h * 0.72; + doc.setFillColor(t.lc.band[0], t.lc.band[1], t.lc.band[2]); + doc.roundedRect(x - 1.8 * S, y, w + 3.6 * S, h, 1.4 * S, 1.4 * S, 'F'); + } + write(doc, S, dry, cur, header.name, { pt, style: 'bold', color: t.lc.accent, x, width, factor: 1.25 }); } - // --- Enclosures --- + // --- Enclosures as chips, in the same pill language as the contact details --- 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 }); + write(doc, S, dry, cur, anlagen.length > 1 ? 'Anlagen' : 'Anlage', + { pt: 8.2, style: 'bold', color: t.lc.muted, x, width, factor: 1.4, charSpace: 0.5, upper: true }); + cur.y += 1.4 * S; + const c = { y: cur.y }; + pillRow(doc, t, S, dry, c, anlagen, x, width, + { style: 'normal', pt: 8.4, bg: t.lc.band, ink: t.lc.accent }); + cur.y = c.y; } // --- Footer: the contact strip on a pastel bar, in the reserved bottom margin --- @@ -2327,10 +2415,35 @@ function composeLetterSunny(doc, t, S, dry, cur, { letter, header, job, anlagen, 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'); + // Dots closing the strip at both ends — the head's confetti, answered. + suDot(doc, SU.mx + 4 * S, fy + fh / 2, 1.1 * S, t.lc.accent); + suDot(doc, SU.r - 4 * S, fy + fh / 2, 1.1 * S, t.lc.accent); + + // Fit the strip to the band. A long address plus a long e-mail address + // overruns it at a fixed size — the line then slides out over the dots and + // off the band's edge. Shrink to fit; if even the floor is too wide, drop + // the postal address and keep the two ways to actually reach the person. + const raum = SU.w - 16 * S; // band minus the dots at both ends doc.setFont(doc.__sans, 'normal'); - doc.setFontSize(8 * S); + const passt = (text, pt) => { + doc.setFontSize(pt * S); + return doc.getTextWidth(text) <= raum; + }; + const fit = (text) => { + for (let pt = 8; pt > 5.4; pt -= 0.2) if (passt(text, pt)) return pt; + return null; + }; + let strip = parts.join(' · '); + let fpt = fit(strip); + if (fpt === null && parts.length > 1) { + strip = parts.slice(1).join(' · '); // ohne Anschrift + fpt = fit(strip); + } + if (fpt === null) fpt = 5.4; // Notnagel: lieber klein als über den Rand + + doc.setFontSize(fpt * 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' }); + doc.text(strip, PAGE.w / 2, fy + fh / 2 + fpt * S * PT * 0.35, { align: 'center' }); } } }