Neues Layout "Tupfen": dezent, mit verstreuten Punkten

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 22:15:20 +02:00
co-authored by Claude Opus 4.8
parent 75813a38a4
commit d20a265ebc
3 changed files with 609 additions and 5 deletions
+64 -1
View File
@@ -19,6 +19,13 @@
// level bars for the languages. Bold but tidy — right for marketing, // level bars for the languages. Bold but tidy — right for marketing,
// media and design roles that still want a readable, gridded page. // 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 // 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. // 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: 'stahlblau', label: 'Stahlblau', rgb: [51, 65, 85] },
{ key: 'kupfer', label: 'Kupfer', rgb: [124, 62, 24] }, { key: 'kupfer', label: 'Kupfer', rgb: [124, 62, 24] },
{ key: 'beere', label: 'Beere (Sunny)', rgb: [109, 24, 74] }, { 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: 'pink', label: 'Pink (verspielt)', rgb: [232, 54, 143] },
{ key: 'violett', label: 'Violett (verspielt)', rgb: [147, 51, 187] }, { key: 'violett', label: 'Violett (verspielt)', rgb: [147, 51, 187] },
]; ];
@@ -44,6 +55,7 @@ const LAYOUTS = [
{ key: 'sidebar', label: 'Klassisch mit Sidebar (Standard)' }, { key: 'sidebar', label: 'Klassisch mit Sidebar (Standard)' },
{ key: 'social', label: 'Social Media (verspielt)' }, { key: 'social', label: 'Social Media (verspielt)' },
{ key: 'sunny', label: 'Sunny (plakativ, Farbbänder)' }, { key: 'sunny', label: 'Sunny (plakativ, Farbbänder)' },
{ key: 'tupfen', label: 'Tupfen (dezent, verspielte Punkte)' },
]; ];
const SIDEBAR_VARIANTEN = [ const SIDEBAR_VARIANTEN = [
@@ -74,6 +86,9 @@ const LAYOUT_DEFAULTS = {
// Sunny is built around the berry/pastel pairing of its reference; the photo // 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. // sits in a square frame that breaks out of the hero band, so square it is.
sunny: { akzent: 'beere', foto_form: 'eckig' }, 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 = { const DEFAULTS = {
@@ -158,6 +173,7 @@ function resolve(overrides = {}) {
cardBg: [255, 255, 255], cardBg: [255, 255, 255],
cardBorder: [208, 210, 214], cardBorder: [208, 210, 214],
deko: [208, 210, 214], // the classic layout draws no sparkles; kept for shape parity deko: [208, 210, 214], // the classic layout draws no sparkles; kept for shape parity
dekoHell: [230, 232, 235],
band: tint(accent, 0.14), band: tint(accent, 0.14),
pillBg: tint(accent, 0.12), pillBg: tint(accent, 0.12),
pillInk: accent, pillInk: accent,
@@ -180,6 +196,7 @@ function resolve(overrides = {}) {
// Sparkles/blob sit on the tinted page, so they need more punch than the // Sparkles/blob sit on the tinted page, so they need more punch than the
// card border or they simply vanish. // card border or they simply vanish.
deko: tint(accent, 0.45), deko: tint(accent, 0.45),
dekoHell: tint(accent, 0.22),
band: tint(accent, 0.22), band: tint(accent, 0.22),
pillBg: tint(accent, 0.18), pillBg: tint(accent, 0.18),
pillInk: shade(accent, 0.6), pillInk: shade(accent, 0.6),
@@ -200,6 +217,7 @@ function resolve(overrides = {}) {
cardBg: [255, 255, 255], cardBg: [255, 255, 255],
cardBorder: tint(accent, 0.22), cardBorder: tint(accent, 0.22),
deko: tint(accent, 0.30), deko: tint(accent, 0.30),
dekoHell: tint(accent, 0.16),
band: tint(accent, 0.24), // the pastel hero band and section bars band: tint(accent, 0.24), // the pastel hero band and section bars
pillBg: accent, // solid badge… pillBg: accent, // solid badge…
pillInk: [255, 255, 255], // …with the text knocked out white pillInk: [255, 255, 255], // …with the text knocked out white
@@ -208,6 +226,29 @@ function resolve(overrides = {}) {
onSide: [40, 36, 42], onSide: [40, 36, 42],
onSideSub: [124, 118, 126], 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; const rc = RC[s.layout] || RC.sidebar;
@@ -223,6 +264,7 @@ function resolve(overrides = {}) {
cardBg: [255, 255, 255], cardBg: [255, 255, 255],
cardBorder: [206, 208, 212], cardBorder: [206, 208, 212],
deko: [206, 208, 212], deko: [206, 208, 212],
dekoHell: [230, 232, 235],
band: rc.band, band: rc.band,
pillBg: tint(accent, 0.12), pillBg: tint(accent, 0.12),
pillInk: accent, pillInk: accent,
@@ -236,6 +278,7 @@ function resolve(overrides = {}) {
cardBg: rc.cardBg, cardBg: rc.cardBg,
cardBorder: rc.cardBorder, cardBorder: rc.cardBorder,
deko: rc.deko, deko: rc.deko,
dekoHell: rc.dekoHell,
band: rc.band, band: rc.band,
pillBg: rc.pillBg, pillBg: rc.pillBg,
pillInk: rc.pillInk, pillInk: rc.pillInk,
@@ -249,6 +292,7 @@ function resolve(overrides = {}) {
cardBg: rc.cardBg, cardBg: rc.cardBg,
cardBorder: rc.cardBorder, cardBorder: rc.cardBorder,
deko: rc.deko, deko: rc.deko,
dekoHell: rc.dekoHell,
band: rc.band, band: rc.band,
// The letter's role badge sits on the pastel head band, so a solid accent // 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 // 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, pillBg: rc.cardBg,
pillInk: accent, 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; const lc = LC[s.layout] || LC.sidebar;
// Which typefaces the renderer registers and draws with. The playful layout // 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 // 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 // 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 = { const FONTS = {
sidebar: { sans: 'Lato', script: null }, sidebar: { sans: 'Lato', script: null },
social: { sans: 'Poppins', script: 'Pacifico' }, social: { sans: 'Poppins', script: 'Pacifico' },
sunny: { sans: 'Poppins', script: null }, sunny: { sans: 'Poppins', script: null },
tupfen: { sans: 'Poppins', script: null },
}; };
return { return {
+537 -1
View File
@@ -692,6 +692,10 @@ const PAGE = { w: 210, h: 297 };
const MIN_SCALE = 0.6; const MIN_SCALE = 0.6;
// Low-level text writer. `cur` is a {y} cursor; advances it. `dry` = measure only. // 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) { function write(doc, S, dry, cur, text, o) {
const pt = o.pt; const pt = o.pt;
const factor = o.factor == null ? 1.3 : o.factor; 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); if (o.charSpace) doc.setCharSpace(o.charSpace * S);
const content = o.upper ? String(text).toUpperCase() : String(text); const content = o.upper ? String(text).toUpperCase() : String(text);
const lines = doc.splitTextToSize(content, o.width); const lines = doc.splitTextToSize(content, o.width);
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]);
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) { for (const ln of lines) {
if (!dry) { if (!dry) {
doc.setTextColor(o.color[0], o.color[1], o.color[2]); doc.setTextColor(o.color[0], o.color[1], o.color[2]);
const drawX = o.align === 'right' ? o.right : o.x; 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(ln, drawX, cur.y + pt * S * PT * 0.76, { align: o.align || 'left' });
} }
cur.y += pt * S * PT * factor; cur.y += lead;
}
} }
if (o.charSpace) doc.setCharSpace(0); if (o.charSpace) doc.setCharSpace(0);
} }
@@ -1920,6 +1942,387 @@ function composeCVSunny(doc, t, S, dry, ctx) {
return cur.y; 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) // 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. // 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). // 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 }, sidebar: { compose: composeCV, box: RV },
social: { compose: composeCVSocial, box: SO }, social: { compose: composeCVSocial, box: SO },
sunny: { compose: composeCVSunny, box: SU }, sunny: { compose: composeCVSunny, box: SU },
tupfen: { compose: composeCVTupfen, box: TU },
}; };
const LETTER_LAYOUT = { const LETTER_LAYOUT = {
sidebar: { compose: composeLetter, box: LET }, sidebar: { compose: composeLetter, box: LET },
social: { compose: composeLetterSocial, box: SO }, social: { compose: composeLetterSocial, box: SO },
sunny: { compose: composeLetterSunny, box: SU }, sunny: { compose: composeLetterSunny, box: SU },
tupfen: { compose: composeLetterTupfen, box: TU_BRIEF },
}; };
function renderSingleColumn(compose, t) { function renderSingleColumn(compose, t) {
+5
View File
@@ -350,6 +350,11 @@
<p><strong>Klassisch mit Sidebar</strong> zurückhaltend, monochrom, für klassische Bewerbungen (Standard).</p> <p><strong>Klassisch mit Sidebar</strong> zurückhaltend, monochrom, für klassische Bewerbungen (Standard).</p>
<p class="mt-1"><strong>Social Media</strong> verspielt: getönte Seite, runde Karten, Herzchen, Schreibschrift. <p class="mt-1"><strong>Social Media</strong> verspielt: getönte Seite, runde Karten, Herzchen, Schreibschrift.
Bewusst auffällig passend für kreative und Social-Media-Stellen, nicht für die Bank.</p> Bewusst auffällig passend für kreative und Social-Media-Stellen, nicht für die Bank.</p>
<p class="mt-1"><strong>Sunny</strong> 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.</p>
<p class="mt-1"><strong>Tupfen</strong> 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.</p>
</div> </div>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8"> <section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">