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:
+64
-1
@@ -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 {
|
||||||
|
|||||||
+540
-4
@@ -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);
|
||||||
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) {
|
if (!dry) {
|
||||||
|
const vorher = doc.getLineHeightFactor();
|
||||||
|
doc.setLineHeightFactor(factor);
|
||||||
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;
|
doc.text(lines, o.x, cur.y + pt * S * PT * 0.76, { align: 'justify', maxWidth: o.width });
|
||||||
doc.text(ln, drawX, cur.y + pt * S * PT * 0.76, { align: o.align || 'left' });
|
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);
|
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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user