Design der Unterlagen unter Vorlagen einstellbar
Akzentfarbe, Sidebar-Hintergrund, Foto (an/aus, eckig/rund) und Schriftgroesse lagen als Konstanten im Renderer. Sie kommen jetzt aus lib/design.js und sind unter Vorlagen waehlbar, inklusive Vorschau-PDF mit Musterinhalten - die zeigt das Ergebnis, ohne dass die KI laufen muss. Bewusst geschlossen gehalten: die Palette ist eine kurze Liste gedeckter Toene (kein freier Color-Picker), Grauwerte und Grundlayout bleiben fest. Die Schriftgroesse ist nur der Startwert der Seitenskalierung - der Lebenslauf passt weiterhin garantiert auf eine Seite. Das Theme wird durch die Render-Funktionen gereicht statt als Modulzustand gesetzt, damit spaeter mehrere Bewerber parallel generieren koennen, ohne sich gegenseitig das Design umzustellen. Rundes Foto: das Bild fuellt den Kreis formatfuellend und zentriert (Clip statt Skalieren), sonst wuerde ein Hochformat-Foto im Quadrat gestaucht. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+208
-127
@@ -10,6 +10,7 @@ const { jsPDF } = require('jspdf');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const promptStore = require('./prompts');
|
||||
const designStore = require('./design');
|
||||
|
||||
// Embedded professional typeface for both cover letter and résumé: Lato
|
||||
// (SIL Open Font License, see lib/fonts/OFL.txt). The base64 TTFs are read
|
||||
@@ -645,39 +646,44 @@ const BODY_X = MAIN_X + 8; // 88 body text left (hanging under labels
|
||||
const BODY_W = MAIN_R - BODY_X; // 107
|
||||
const MAIN_W = MAIN_R - MAIN_X; // 115 full main width (name / heading rules)
|
||||
|
||||
// Strictly monochrome — no colour anywhere. Hierarchy comes from weight, size,
|
||||
// tracking and a single neutral grey scale. The near-black "accent" carries the
|
||||
// name, section labels, company names, markers and the photo frame; body text
|
||||
// sits just below it; everything structural is grey.
|
||||
const RC = {
|
||||
ink: [33, 33, 33],
|
||||
sub: [92, 96, 100],
|
||||
hair: [208, 210, 214],
|
||||
accent: [23, 23, 23],
|
||||
// Neutral tint for the sidebar band and the experience timeline track.
|
||||
sidebarBg: [242, 243, 245],
|
||||
track: [199, 202, 208],
|
||||
onSide: [38, 40, 44],
|
||||
onSideSub: [96, 100, 106],
|
||||
};
|
||||
// The palette (`t.rc` here, `t.lc` for the letter) comes from lib/design.js and
|
||||
// is threaded through every draw call as `t`. The base stays a neutral grey
|
||||
// scale — hierarchy is carried by weight, size and tracking, exactly as in the
|
||||
// monochrome original. Only the accent (name, section labels, company names,
|
||||
// markers, photo frame) is the user's choice, so no setting can flatten the
|
||||
// typographic structure.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fit an applicant photo into a portrait frame, preserving aspect ratio. The
|
||||
// frame size is fixed (does not scale with the page-fit S) so the sidebar
|
||||
// keeps a stable, confident anchor regardless of how much text is below it.
|
||||
function fitPhoto(doc, foto) {
|
||||
// Fit an applicant photo into its frame, preserving aspect ratio. The frame size
|
||||
// is fixed (does not scale with the page-fit S) so the sidebar keeps a stable,
|
||||
// confident anchor regardless of how much text is below it.
|
||||
//
|
||||
// Returns the frame box (w/h — what the layout reserves) *and* the draw box
|
||||
// (dw/dh — what addImage gets). They differ for the round portrait: the frame is
|
||||
// a square, and the image is scaled to *cover* it and centred, with the overflow
|
||||
// clipped away by the circle. Scaling a non-square photo into a square frame
|
||||
// instead would squash the face.
|
||||
function fitPhoto(doc, foto, rund = false) {
|
||||
const MISS = { ok: false, w: 0, h: 0, dw: 0, dh: 0 };
|
||||
const MAXW = 42, MAXH = 52;
|
||||
if (!foto || !foto.dataUrl) return { ok: false, w: 0, h: 0 };
|
||||
if (!foto || !foto.dataUrl) return MISS;
|
||||
try {
|
||||
const p = doc.getImageProperties(foto.dataUrl);
|
||||
if (!(p.width > 0 && p.height > 0)) return { ok: false, w: 0, h: 0 };
|
||||
let w = MAXW, h = w * p.height / p.width;
|
||||
if (h > MAXH) { h = MAXH; w = h * p.width / p.height; }
|
||||
return { ok: true, w, h };
|
||||
} catch (e) { return { ok: false, w: 0, h: 0 }; }
|
||||
if (!(p.width > 0 && p.height > 0)) return MISS;
|
||||
const ar = p.width / p.height;
|
||||
if (rund) {
|
||||
const D = MAXW; // square frame = circle diameter
|
||||
const dw = ar >= 1 ? D * ar : D; // cover: the short side matches D
|
||||
const dh = ar >= 1 ? D : D / ar;
|
||||
return { ok: true, w: D, h: D, dw, dh };
|
||||
}
|
||||
let w = MAXW, h = w / ar;
|
||||
if (h > MAXH) { h = MAXH; w = h * ar; }
|
||||
return { ok: true, w, h, dw: w, dh: h };
|
||||
} catch (e) { return MISS; }
|
||||
}
|
||||
|
||||
function buildContactLines(header) {
|
||||
@@ -694,7 +700,7 @@ function buildContactLines(header) {
|
||||
|
||||
// Sidebar section label: a compact tracked uppercase accent word. No underline
|
||||
// rule — the sidebar stays clean; tracking and weight carry the structure.
|
||||
function sbHeading(doc, S, dry, cur, title) {
|
||||
function sbHeading(doc, t, S, dry, cur, title) {
|
||||
const pt = 9;
|
||||
const cs = 1.0 * S;
|
||||
cur.y += 5 * S;
|
||||
@@ -703,14 +709,14 @@ function sbHeading(doc, S, dry, cur, title) {
|
||||
const label = String(title).toUpperCase();
|
||||
if (!dry) {
|
||||
doc.setCharSpace(cs);
|
||||
doc.setTextColor(RC.accent[0], RC.accent[1], RC.accent[2]);
|
||||
doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||||
doc.text(label, SB_X, cur.y + pt * S * PT * 0.76);
|
||||
doc.setCharSpace(0);
|
||||
}
|
||||
cur.y += pt * S * PT + 4.6 * S;
|
||||
}
|
||||
|
||||
function sbBullets(doc, S, dry, cur, items, pt = 8.7) {
|
||||
function sbBullets(doc, t, S, dry, cur, items, pt = 8.7) {
|
||||
const tx = SB_X + 3.4;
|
||||
const tw = SB_CW - 3.4;
|
||||
for (const it of items) {
|
||||
@@ -721,10 +727,10 @@ function sbBullets(doc, S, dry, cur, items, pt = 8.7) {
|
||||
if (!dry) {
|
||||
if (i === 0) {
|
||||
const sz = 1.1 * S;
|
||||
doc.setFillColor(RC.accent[0], RC.accent[1], RC.accent[2]);
|
||||
doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||||
doc.rect(SB_X + 0.2, cur.y + pt * S * PT * 0.42 - sz / 2, sz, sz, 'F');
|
||||
}
|
||||
doc.setTextColor(RC.onSide[0], RC.onSide[1], RC.onSide[2]);
|
||||
doc.setTextColor(t.rc.onSide[0], t.rc.onSide[1], t.rc.onSide[2]);
|
||||
doc.text(ln, tx, cur.y + pt * S * PT * 0.76);
|
||||
}
|
||||
cur.y += pt * S * PT * 1.32;
|
||||
@@ -734,53 +740,69 @@ function sbBullets(doc, S, dry, cur, items, pt = 8.7) {
|
||||
}
|
||||
|
||||
// Language row: name (left) + level (right-aligned in the sidebar column).
|
||||
function sbLangRow(doc, S, dry, cur, s) {
|
||||
function sbLangRow(doc, t, S, dry, cur, s) {
|
||||
const pt = 8.7;
|
||||
doc.setFont('Lato', 'normal');
|
||||
doc.setFontSize(pt * S);
|
||||
if (!dry) {
|
||||
doc.setTextColor(RC.onSide[0], RC.onSide[1], RC.onSide[2]);
|
||||
doc.setTextColor(t.rc.onSide[0], t.rc.onSide[1], t.rc.onSide[2]);
|
||||
doc.text(String(s.sprache), SB_X, cur.y + pt * S * PT * 0.76);
|
||||
if (s.niveau) {
|
||||
doc.setTextColor(RC.onSideSub[0], RC.onSideSub[1], RC.onSideSub[2]);
|
||||
doc.setTextColor(t.rc.onSideSub[0], t.rc.onSideSub[1], t.rc.onSideSub[2]);
|
||||
doc.text(String(s.niveau), SB_R, cur.y + pt * S * PT * 0.76, { align: 'right' });
|
||||
}
|
||||
}
|
||||
cur.y += pt * S * PT * 1.62;
|
||||
}
|
||||
|
||||
function composeSidebar(doc, S, dry, { cv, header, foto }) {
|
||||
function composeSidebar(doc, t, S, dry, { cv, header, foto }) {
|
||||
const cur = { y: RV.top };
|
||||
|
||||
const photo = fitPhoto(doc, foto);
|
||||
const photo = fitPhoto(doc, foto, t.foto.rund);
|
||||
if (photo.ok) {
|
||||
const px = (SB_W - photo.w) / 2;
|
||||
const py = RV.top - 2;
|
||||
if (!dry) {
|
||||
doc.addImage(foto.dataUrl, foto.format || 'PNG', px, py, photo.w, photo.h);
|
||||
doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]);
|
||||
doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]);
|
||||
doc.setLineWidth(0.3 * S);
|
||||
doc.rect(px, py, photo.w, photo.h);
|
||||
if (t.foto.rund) {
|
||||
// Circular portrait: clip to the circle, draw the image centred and
|
||||
// over-sized (cover), then trace the frame on top of the clipped edge.
|
||||
const r = photo.w / 2;
|
||||
const cx = px + r;
|
||||
const cy = py + r;
|
||||
doc.saveGraphicsState();
|
||||
doc.circle(cx, cy, r, null);
|
||||
doc.clip();
|
||||
doc.discardPath();
|
||||
doc.addImage(foto.dataUrl, foto.format || 'PNG',
|
||||
cx - photo.dw / 2, cy - photo.dh / 2, photo.dw, photo.dh);
|
||||
doc.restoreGraphicsState();
|
||||
doc.circle(cx, cy, r, 'S');
|
||||
} else {
|
||||
doc.addImage(foto.dataUrl, foto.format || 'PNG', px, py, photo.w, photo.h);
|
||||
doc.rect(px, py, photo.w, photo.h);
|
||||
}
|
||||
}
|
||||
cur.y = py + photo.h + 7 * S;
|
||||
}
|
||||
|
||||
const contact = buildContactLines(header);
|
||||
if (contact.length) {
|
||||
sbHeading(doc, S, dry, cur, 'Kontakt');
|
||||
contact.forEach((line) => write(doc, S, dry, cur, line, { pt: 8.6, color: RC.onSideSub, x: SB_X, width: SB_CW, factor: 1.5 }));
|
||||
sbHeading(doc, t, S, dry, cur, 'Kontakt');
|
||||
contact.forEach((line) => write(doc, S, dry, cur, line, { pt: 8.6, color: t.rc.onSideSub, x: SB_X, width: SB_CW, factor: 1.5 }));
|
||||
}
|
||||
if (cv.kenntnisse.length) {
|
||||
sbHeading(doc, S, dry, cur, 'Kernkompetenzen');
|
||||
sbBullets(doc, S, dry, cur, cv.kenntnisse);
|
||||
sbHeading(doc, t, S, dry, cur, 'Kernkompetenzen');
|
||||
sbBullets(doc, t, S, dry, cur, cv.kenntnisse);
|
||||
}
|
||||
if (cv.sprachen.length) {
|
||||
sbHeading(doc, S, dry, cur, 'Sprachen');
|
||||
cv.sprachen.forEach((s) => sbLangRow(doc, S, dry, cur, s));
|
||||
sbHeading(doc, t, S, dry, cur, 'Sprachen');
|
||||
cv.sprachen.forEach((s) => sbLangRow(doc, t, S, dry, cur, s));
|
||||
}
|
||||
if (cv.hobbys.length) {
|
||||
sbHeading(doc, S, dry, cur, 'Interessen');
|
||||
write(doc, S, dry, cur, cv.hobbys.join(' · '), { pt: 8.6, color: RC.onSide, x: SB_X, width: SB_CW, factor: 1.55 });
|
||||
sbHeading(doc, t, S, dry, cur, 'Interessen');
|
||||
write(doc, S, dry, cur, cv.hobbys.join(' · '), { pt: 8.6, color: t.rc.onSide, x: SB_X, width: SB_CW, factor: 1.55 });
|
||||
}
|
||||
return cur.y;
|
||||
}
|
||||
@@ -792,7 +814,7 @@ function composeSidebar(doc, S, dry, { cv, header, foto }) {
|
||||
// Main section heading: tracked uppercase accent label, outdented over a
|
||||
// full-width hairline whose first stretch is a heavier accent segment as wide
|
||||
// as the label word — a precise, repeating anchor down the page.
|
||||
function mHeading(doc, S, dry, cur, title) {
|
||||
function mHeading(doc, t, S, dry, cur, title) {
|
||||
const pt = 11;
|
||||
const cs = 0.9 * S;
|
||||
cur.y += 5.5 * S;
|
||||
@@ -802,21 +824,21 @@ function mHeading(doc, S, dry, cur, title) {
|
||||
const lineY = cur.y + pt * S * PT * 1.1;
|
||||
if (!dry) {
|
||||
doc.setCharSpace(cs);
|
||||
doc.setTextColor(RC.accent[0], RC.accent[1], RC.accent[2]);
|
||||
doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||||
doc.text(label, MAIN_X, cur.y + pt * S * PT * 0.76);
|
||||
doc.setCharSpace(0);
|
||||
const wordW = doc.getTextWidth(label) + cs * label.length;
|
||||
doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]);
|
||||
doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]);
|
||||
doc.setLineWidth(0.3 * S);
|
||||
doc.line(MAIN_X, lineY, MAIN_R, lineY);
|
||||
doc.setDrawColor(RC.accent[0], RC.accent[1], RC.accent[2]);
|
||||
doc.setDrawColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||||
doc.setLineWidth(1.1 * S);
|
||||
doc.line(MAIN_X, lineY, Math.min(MAIN_X + wordW, MAIN_R), lineY);
|
||||
}
|
||||
cur.y += pt * S * PT + 4.6 * S;
|
||||
}
|
||||
|
||||
function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.2) {
|
||||
function cvBullets(doc, t, S, dry, cur, items, x, w, pt = 9.2) {
|
||||
const tx = x + 3.8;
|
||||
const tw = w - 3.8;
|
||||
for (const it of items) {
|
||||
@@ -827,10 +849,10 @@ function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.2) {
|
||||
if (!dry) {
|
||||
if (i === 0) {
|
||||
const sz = 1.15 * S;
|
||||
doc.setFillColor(RC.accent[0], RC.accent[1], RC.accent[2]);
|
||||
doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||||
doc.rect(x + 0.2, cur.y + pt * S * PT * 0.42 - sz / 2, sz, sz, 'F');
|
||||
}
|
||||
doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]);
|
||||
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
|
||||
doc.text(ln, tx, cur.y + pt * S * PT * 0.76);
|
||||
}
|
||||
cur.y += pt * S * PT * 1.32;
|
||||
@@ -841,7 +863,7 @@ function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.2) {
|
||||
|
||||
// One experience entry: an accent node on the timeline, bold role title with a
|
||||
// right-aligned period, the company in accent, then the achievement bullets.
|
||||
function cvExperience(doc, S, dry, cur, e) {
|
||||
function cvExperience(doc, t, S, dry, cur, e) {
|
||||
const cx = MAIN_X + 1.4; // node / track centre x
|
||||
const titlePt = 11, datePt = 9.3;
|
||||
const dateW = 30;
|
||||
@@ -850,83 +872,83 @@ function cvExperience(doc, S, dry, cur, e) {
|
||||
const titleLines = doc.splitTextToSize(String(e.titel || ''), BODY_W - dateW);
|
||||
const titleLineH = titlePt * S * PT * 1.16;
|
||||
if (!dry) {
|
||||
doc.setFillColor(RC.accent[0], RC.accent[1], RC.accent[2]);
|
||||
doc.setFillColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]);
|
||||
doc.circle(cx, cur.y + titlePt * S * PT * 0.44, 1.5 * S, 'F');
|
||||
doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]);
|
||||
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
|
||||
titleLines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * titleLineH + titlePt * S * PT * 0.76));
|
||||
if (e.zeitraum) {
|
||||
doc.setFont('Lato', 'normal');
|
||||
doc.setFontSize(datePt * S);
|
||||
doc.setTextColor(RC.sub[0], RC.sub[1], RC.sub[2]);
|
||||
doc.setTextColor(t.rc.sub[0], t.rc.sub[1], t.rc.sub[2]);
|
||||
doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' });
|
||||
}
|
||||
}
|
||||
cur.y += titleLineH * Math.max(1, titleLines.length);
|
||||
if (e.firma) write(doc, S, dry, cur, e.firma, { pt: 9.6, color: RC.accent, x: BODY_X, width: BODY_W, factor: 1.24 });
|
||||
if (e.punkte && e.punkte.length) { cur.y += 1.4 * S; cvBullets(doc, S, dry, cur, e.punkte, BODY_X, BODY_W); }
|
||||
if (e.firma) write(doc, S, dry, cur, e.firma, { pt: 9.6, color: t.rc.accent, x: BODY_X, width: BODY_W, factor: 1.24 });
|
||||
if (e.punkte && e.punkte.length) { cur.y += 1.4 * S; cvBullets(doc, t, S, dry, cur, e.punkte, BODY_X, BODY_W); }
|
||||
cur.y += 5 * S;
|
||||
}
|
||||
|
||||
// The full Berufserfahrung section, threaded on a subtle vertical timeline.
|
||||
// The track is measured with a dry pass so it can be drawn behind the nodes in
|
||||
// one clean stroke, then the entries render on top.
|
||||
function composeTimeline(doc, S, dry, cur, entries) {
|
||||
function composeTimeline(doc, t, S, dry, cur, entries) {
|
||||
const startY = cur.y;
|
||||
if (!dry) {
|
||||
const probe = { y: cur.y };
|
||||
entries.forEach((e) => cvExperience(doc, S, true, probe, e));
|
||||
doc.setDrawColor(RC.track[0], RC.track[1], RC.track[2]);
|
||||
entries.forEach((e) => cvExperience(doc, t, S, true, probe, e));
|
||||
doc.setDrawColor(t.rc.track[0], t.rc.track[1], t.rc.track[2]);
|
||||
doc.setLineWidth(0.5 * S);
|
||||
doc.line(MAIN_X + 1.4, startY + 2 * S, MAIN_X + 1.4, probe.y - 5 * S);
|
||||
}
|
||||
entries.forEach((e) => cvExperience(doc, S, dry, cur, e));
|
||||
entries.forEach((e) => cvExperience(doc, t, S, dry, cur, e));
|
||||
}
|
||||
|
||||
function cvEducation(doc, S, dry, cur, e) {
|
||||
function cvEducation(doc, t, S, dry, cur, e) {
|
||||
const pt = 10.5, datePt = 9.3, dateW = 30;
|
||||
doc.setFont('Lato', 'bold');
|
||||
doc.setFontSize(pt * S);
|
||||
const lines = doc.splitTextToSize(String(e.abschluss || ''), BODY_W - dateW);
|
||||
const lineH = pt * S * PT * 1.16;
|
||||
if (!dry) {
|
||||
doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]);
|
||||
doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]);
|
||||
lines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * lineH + pt * S * PT * 0.76));
|
||||
if (e.zeitraum) {
|
||||
doc.setFont('Lato', 'normal');
|
||||
doc.setFontSize(datePt * S);
|
||||
doc.setTextColor(RC.sub[0], RC.sub[1], RC.sub[2]);
|
||||
doc.setTextColor(t.rc.sub[0], t.rc.sub[1], t.rc.sub[2]);
|
||||
doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' });
|
||||
}
|
||||
}
|
||||
cur.y += lineH * Math.max(1, lines.length);
|
||||
if (e.institution) write(doc, S, dry, cur, e.institution, { pt: 9.5, color: RC.accent, x: BODY_X, width: BODY_W, factor: 1.2 });
|
||||
if (e.zusatz) write(doc, S, dry, cur, e.zusatz, { pt: 9.3, color: RC.sub, x: BODY_X, width: BODY_W, factor: 1.24 });
|
||||
if (e.institution) write(doc, S, dry, cur, e.institution, { pt: 9.5, color: t.rc.accent, x: BODY_X, width: BODY_W, factor: 1.2 });
|
||||
if (e.zusatz) write(doc, S, dry, cur, e.zusatz, { pt: 9.3, color: t.rc.sub, x: BODY_X, width: BODY_W, factor: 1.24 });
|
||||
cur.y += 4 * S;
|
||||
}
|
||||
|
||||
// Big name + target role — the confident head the recruiter's eye lands on
|
||||
// first. No divider rule here; the first section heading provides the structure.
|
||||
function composeMainHeader(doc, S, dry, cur, header, titel) {
|
||||
write(doc, S, dry, cur, header.name, { pt: 25, style: 'bold', color: RC.accent, x: MAIN_X, width: MAIN_W, factor: 1.06 });
|
||||
if (titel) write(doc, S, dry, cur, titel, { pt: 12, color: RC.sub, x: MAIN_X, width: MAIN_W, factor: 1.36, charSpace: 0.3 });
|
||||
function composeMainHeader(doc, t, S, dry, cur, header, titel) {
|
||||
write(doc, S, dry, cur, header.name, { pt: 25, style: 'bold', color: t.rc.accent, x: MAIN_X, width: MAIN_W, factor: 1.06 });
|
||||
if (titel) write(doc, S, dry, cur, titel, { pt: 12, color: t.rc.sub, x: MAIN_X, width: MAIN_W, factor: 1.36, charSpace: 0.3 });
|
||||
cur.y += 2 * S;
|
||||
}
|
||||
|
||||
function composeMain(doc, S, dry, { cv, header, titel }) {
|
||||
function composeMain(doc, t, S, dry, { cv, header, titel }) {
|
||||
const cur = { y: RV.top };
|
||||
composeMainHeader(doc, S, dry, cur, header, titel);
|
||||
composeMainHeader(doc, t, S, dry, cur, header, titel);
|
||||
if (cv.profil) {
|
||||
mHeading(doc, S, dry, cur, 'Profil');
|
||||
write(doc, S, dry, cur, cv.profil, { pt: 9.8, color: RC.ink, x: BODY_X, width: BODY_W, factor: 1.5 });
|
||||
mHeading(doc, t, S, dry, cur, 'Profil');
|
||||
write(doc, S, dry, cur, cv.profil, { pt: 9.8, color: t.rc.ink, x: BODY_X, width: BODY_W, factor: 1.5 });
|
||||
}
|
||||
if (cv.berufserfahrung.length) {
|
||||
mHeading(doc, S, dry, cur, 'Berufserfahrung');
|
||||
composeTimeline(doc, S, dry, cur, cv.berufserfahrung);
|
||||
mHeading(doc, t, S, dry, cur, 'Berufserfahrung');
|
||||
composeTimeline(doc, t, S, dry, cur, cv.berufserfahrung);
|
||||
}
|
||||
[['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Weiterbildungen', cv.weiterbildungen]].forEach(([t, entries]) => {
|
||||
[['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Weiterbildungen', cv.weiterbildungen]].forEach(([titel, entries]) => {
|
||||
if (!entries || !entries.length) return;
|
||||
mHeading(doc, S, dry, cur, t);
|
||||
entries.forEach((e) => cvEducation(doc, S, dry, cur, e));
|
||||
mHeading(doc, t, S, dry, cur, titel);
|
||||
entries.forEach((e) => cvEducation(doc, t, S, dry, cur, e));
|
||||
});
|
||||
return cur.y;
|
||||
}
|
||||
@@ -934,24 +956,29 @@ function composeMain(doc, S, dry, { cv, header, titel }) {
|
||||
// Draw the sidebar band first, then the main column, then the sidebar text —
|
||||
// so the tint sits underneath, and the PDF text stream leads with the name and
|
||||
// career story (better for résumé parsers) while positions stay absolute.
|
||||
function composeCV(doc, S, dry, ctx) {
|
||||
function composeCV(doc, t, S, dry, ctx) {
|
||||
if (!dry) {
|
||||
doc.setFillColor(RC.sidebarBg[0], RC.sidebarBg[1], RC.sidebarBg[2]);
|
||||
doc.setFillColor(t.rc.sidebarBg[0], t.rc.sidebarBg[1], t.rc.sidebarBg[2]);
|
||||
doc.rect(0, 0, SB_W, PAGE.h, 'F');
|
||||
}
|
||||
const mainBottom = composeMain(doc, S, dry, ctx);
|
||||
const sideBottom = composeSidebar(doc, S, dry, ctx);
|
||||
const mainBottom = composeMain(doc, t, S, dry, ctx);
|
||||
const sideBottom = composeSidebar(doc, t, S, dry, ctx);
|
||||
return Math.max(mainBottom, sideBottom);
|
||||
}
|
||||
|
||||
function renderCV(cv, header, titel, foto) {
|
||||
// The user's font-size choice (t.scale) is the *starting* scale, not the final
|
||||
// one: we measure at that size and shrink from there if the content would spill
|
||||
// onto a second page. So "110 %" enlarges a sparse CV but can never break the
|
||||
// one-page guarantee on a full one.
|
||||
function renderCV(cv, header, titel, foto, t) {
|
||||
const doc = makeDoc();
|
||||
const ctx = { cv, header, titel, foto };
|
||||
const need = composeCV(doc, 1, true, ctx) - RV.top;
|
||||
const S0 = t.scale;
|
||||
const need = composeCV(doc, t, S0, true, ctx) - RV.top;
|
||||
const avail = PAGE.h - RV.top - RV.bottom;
|
||||
let S = 1;
|
||||
if (need > avail) S = Math.max(MIN_SCALE, (avail / need) * 0.99);
|
||||
composeCV(doc, S, false, ctx);
|
||||
let S = S0;
|
||||
if (need > avail) S = Math.max(MIN_SCALE, S0 * (avail / need) * 0.99);
|
||||
composeCV(doc, t, S, false, ctx);
|
||||
return Buffer.from(doc.output('arraybuffer'));
|
||||
}
|
||||
|
||||
@@ -963,14 +990,8 @@ const LET = { mx: 25, mr: 20, top: 24, bottom: 24 }; // DIN-ish margins (bottom
|
||||
const LET_R = PAGE.w - LET.mr; // 190
|
||||
const LET_W = LET_R - LET.mx; // 165
|
||||
|
||||
// Strictly monochrome, matching the résumé, so the cover letter and CV read as
|
||||
// one deliberately designed black-and-white application set.
|
||||
const LC = {
|
||||
ink: [26, 26, 26],
|
||||
muted: [92, 96, 100],
|
||||
hair: [206, 208, 212],
|
||||
accent: [23, 23, 23],
|
||||
};
|
||||
// The letter shares the résumé's accent (see lib/design.js), so cover letter and
|
||||
// CV keep reading as one deliberately designed set whatever the user picks.
|
||||
|
||||
// Extract just the town from a possibly-full address ("Feldstraße 76, 45968
|
||||
// Gladbeck" / "45968 Gladbeck" / "Gladbeck" → "Gladbeck").
|
||||
@@ -981,7 +1002,7 @@ function cityName(header) {
|
||||
return s.replace(/\b\d{5}\b/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen, signatur }) {
|
||||
function composeLetter(doc, t, S, dry, cur, { letter, header, job, anlagen, signatur }) {
|
||||
const x = LET.mx;
|
||||
const right = LET_R;
|
||||
const width = LET_W;
|
||||
@@ -992,17 +1013,17 @@ function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen, signatu
|
||||
// --- Letterhead: a big, confident name + role in tracked caps, closed by
|
||||
// the shared hairline-with-accent motif. Full contact details live in the
|
||||
// footer strip, which keeps the head clean and echoes the résumé header. ---
|
||||
write(doc, S, dry, cur, header.name, { pt: 20, style: 'bold', color: LC.accent, x, width, factor: 1.12 });
|
||||
if (header.headline) write(doc, S, dry, cur, header.headline, { pt: 9.5, color: LC.muted, x, width, factor: 1.32, charSpace: 0.8, upper: true });
|
||||
write(doc, S, dry, cur, header.name, { pt: 20, style: 'bold', color: t.lc.accent, x, width, factor: 1.12 });
|
||||
if (header.headline) write(doc, S, dry, cur, header.headline, { pt: 9.5, color: t.lc.muted, x, width, factor: 1.32, charSpace: 0.8, upper: true });
|
||||
|
||||
// Hairline across, with a short heavier accent segment at the start — the
|
||||
// same motif that anchors every section of the résumé.
|
||||
cur.y += 3.4 * S;
|
||||
if (!dry) {
|
||||
doc.setDrawColor(LC.hair[0], LC.hair[1], LC.hair[2]);
|
||||
doc.setDrawColor(t.lc.hair[0], t.lc.hair[1], t.lc.hair[2]);
|
||||
doc.setLineWidth(0.3 * S);
|
||||
doc.line(x, cur.y, right, cur.y);
|
||||
doc.setDrawColor(LC.accent[0], LC.accent[1], LC.accent[2]);
|
||||
doc.setDrawColor(t.lc.accent[0], t.lc.accent[1], t.lc.accent[2]);
|
||||
doc.setLineWidth(1.1 * S);
|
||||
doc.line(x, cur.y, x + 16 * S, cur.y);
|
||||
}
|
||||
@@ -1012,31 +1033,31 @@ function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen, signatu
|
||||
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.5, color: LC.ink, x, width, factor: 1.32 });
|
||||
if (emp.ansprechpartner) write(doc, S, dry, cur, `z. Hd. ${emp.ansprechpartner}`, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 });
|
||||
if (emp.adresse) write(doc, S, dry, cur, emp.adresse, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 });
|
||||
if (empOrt) write(doc, S, dry, cur, empOrt, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 });
|
||||
if (empFirma) write(doc, S, dry, cur, empFirma, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.32 });
|
||||
if (emp.ansprechpartner) write(doc, S, dry, cur, `z. Hd. ${emp.ansprechpartner}`, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.32 });
|
||||
if (emp.adresse) write(doc, S, dry, cur, emp.adresse, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.32 });
|
||||
if (empOrt) write(doc, S, dry, cur, empOrt, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.32 });
|
||||
|
||||
// --- Date (right-aligned) ---
|
||||
cur.y += 7 * S;
|
||||
const dateLine = stadt ? `${stadt}, den ${today}` : today;
|
||||
write(doc, S, dry, cur, dateLine, { pt: 10, color: LC.ink, x, width, align: 'right', right, factor: 1.2 });
|
||||
write(doc, S, dry, cur, dateLine, { pt: 10, color: t.lc.ink, x, width, align: 'right', right, factor: 1.2 });
|
||||
|
||||
// --- Subject (bold, no "Betreff:" label) — the letter's headline ---
|
||||
cur.y += 7 * S;
|
||||
write(doc, S, dry, cur, betreff, { pt: 11.5, style: 'bold', color: LC.accent, x, width, factor: 1.3 });
|
||||
write(doc, S, dry, cur, betreff, { pt: 11.5, style: 'bold', color: t.lc.accent, x, width, factor: 1.3 });
|
||||
|
||||
// --- Salutation + body ---
|
||||
cur.y += 6 * S;
|
||||
if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.5, color: LC.ink, x, width, factor: 1.4 }); cur.y += 3 * S; }
|
||||
if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.4 }); cur.y += 3 * S; }
|
||||
letter.absaetze.forEach((p, i) => {
|
||||
if (i > 0) cur.y += 3.2 * S;
|
||||
write(doc, S, dry, cur, p, { pt: 10.5, color: LC.ink, x, width, factor: 1.52 });
|
||||
write(doc, S, dry, cur, p, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.52 });
|
||||
});
|
||||
|
||||
// --- Closing + signature ---
|
||||
cur.y += 6 * S;
|
||||
if (letter.gruss) write(doc, S, dry, cur, letter.gruss, { pt: 10.5, color: LC.ink, x, width, factor: 1.3 });
|
||||
if (letter.gruss) write(doc, S, dry, cur, letter.gruss, { pt: 10.5, color: t.lc.ink, x, width, factor: 1.3 });
|
||||
|
||||
// Signature image (if provided) directly under the closing, replacing the
|
||||
// typed name; otherwise leave room and print the name.
|
||||
@@ -1057,14 +1078,14 @@ function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen, signatu
|
||||
cur.y += sigH;
|
||||
} else {
|
||||
cur.y += 13 * S; // room for a handwritten signature
|
||||
write(doc, S, dry, cur, header.name, { pt: 10.5, style: 'bold', color: LC.ink, x, width, factor: 1.2 });
|
||||
write(doc, S, dry, cur, header.name, { pt: 10.5, style: 'bold', color: t.lc.ink, x, width, factor: 1.2 });
|
||||
}
|
||||
|
||||
// --- Enclosures ---
|
||||
if (anlagen && anlagen.length) {
|
||||
cur.y += 8 * S;
|
||||
write(doc, S, dry, cur, (anlagen.length > 1 ? 'Anlagen: ' : 'Anlage: ') + anlagen.join(', '),
|
||||
{ pt: 9, color: LC.muted, x, width, factor: 1.2 });
|
||||
{ pt: 9, color: t.lc.muted, x, width, factor: 1.2 });
|
||||
}
|
||||
|
||||
// --- Footer letterhead: a centred contact strip under a hairline, drawn in
|
||||
@@ -1081,25 +1102,26 @@ function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen, signatu
|
||||
if (header.email) parts.push(header.email);
|
||||
if (parts.length) {
|
||||
const fy = PAGE.h - 15;
|
||||
doc.setDrawColor(LC.hair[0], LC.hair[1], LC.hair[2]);
|
||||
doc.setDrawColor(t.lc.hair[0], t.lc.hair[1], t.lc.hair[2]);
|
||||
doc.setLineWidth(0.3 * S);
|
||||
doc.line(x, fy, right, fy);
|
||||
doc.setFont('Lato', 'normal');
|
||||
doc.setFontSize(8 * S);
|
||||
doc.setTextColor(LC.muted[0], LC.muted[1], LC.muted[2]);
|
||||
doc.setTextColor(t.lc.muted[0], t.lc.muted[1], t.lc.muted[2]);
|
||||
doc.text(parts.join(' · '), (x + right) / 2, fy + 4 * S, { align: 'center' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderSingleColumn(compose) {
|
||||
function renderSingleColumn(compose, t) {
|
||||
const doc = makeDoc();
|
||||
const m = { y: LET.top }; compose(doc, 1, true, m);
|
||||
const S0 = t.scale;
|
||||
const m = { y: LET.top }; compose(doc, t, S0, true, m);
|
||||
const need = m.y - LET.top;
|
||||
const avail = PAGE.h - LET.top - LET.bottom;
|
||||
let S = 1;
|
||||
if (need > avail) S = Math.max(MIN_SCALE, (avail / need) * 0.99);
|
||||
compose(doc, S, false, { y: LET.top });
|
||||
let S = S0;
|
||||
if (need > avail) S = Math.max(MIN_SCALE, S0 * (avail / need) * 0.99);
|
||||
compose(doc, t, S, false, { y: LET.top });
|
||||
return Buffer.from(doc.output('arraybuffer'));
|
||||
}
|
||||
|
||||
@@ -1112,13 +1134,19 @@ function buildHeader(settings, kontakt, headline) {
|
||||
};
|
||||
}
|
||||
|
||||
function renderLebenslaufPdf(cv, header, foto) {
|
||||
// `design` = the user's design overrides ({} / undefined -> shipped defaults).
|
||||
function renderLebenslaufPdf(cv, header, foto, design = null) {
|
||||
const t = designStore.resolve(design || {});
|
||||
// Title at the top = the position (headline), falling back to the name.
|
||||
return renderCV(cv, header, header.headline || '', foto);
|
||||
return renderCV(cv, header, header.headline || '', t.foto.anzeigen ? foto : null, t);
|
||||
}
|
||||
|
||||
function renderAnschreibenPdf(letter, header, job, anlagen, signatur) {
|
||||
return renderSingleColumn((doc, S, dry, cur) => composeLetter(doc, S, dry, cur, { letter, header, job, anlagen, signatur }));
|
||||
function renderAnschreibenPdf(letter, header, job, anlagen, signatur, design = null) {
|
||||
const t = designStore.resolve(design || {});
|
||||
return renderSingleColumn(
|
||||
(doc, th, S, dry, cur) => composeLetter(doc, th, S, dry, cur, { letter, header, job, anlagen, signatur }),
|
||||
t
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -1131,7 +1159,7 @@ function hasLebenslauf(l) {
|
||||
|| l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length);
|
||||
}
|
||||
|
||||
async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null, prompts = null }) {
|
||||
async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null, prompts = null, design = null }) {
|
||||
const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen, prompts });
|
||||
const headline = data.headline || job.stelle || '';
|
||||
const header = buildHeader(settings, data.kontakt, headline);
|
||||
@@ -1153,7 +1181,7 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus
|
||||
name: `Anschreiben - ${label}`.trim(),
|
||||
filename: `Anschreiben_${suffix}.pdf`,
|
||||
mime: 'application/pdf',
|
||||
buffer: renderAnschreibenPdf(data.anschreiben, header, job, anlagen, signatur),
|
||||
buffer: renderAnschreibenPdf(data.anschreiben, header, job, anlagen, signatur, design),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1162,7 +1190,7 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus
|
||||
name: `Lebenslauf - ${label}`.trim(),
|
||||
filename: `Lebenslauf_${suffix}.pdf`,
|
||||
mime: 'application/pdf',
|
||||
buffer: renderLebenslaufPdf(data.lebenslauf, header, bewerbungsfoto),
|
||||
buffer: renderLebenslaufPdf(data.lebenslauf, header, bewerbungsfoto, design),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1296,10 +1324,63 @@ async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Design preview
|
||||
// ===========================================================================
|
||||
|
||||
// Render the cover letter + CV from fixed sample content, so the Vorlagen page
|
||||
// can show what a design choice actually looks like without calling the LLM
|
||||
// (which costs time and money) and without needing a real application. The
|
||||
// user's own name, signature and photo are used when available — the point is
|
||||
// to preview *their* documents, not a stranger's.
|
||||
function renderDesignVorschau({ settings = {}, signatur = null, bewerbungsfoto = null, design = null }) {
|
||||
const header = buildHeader(settings, {
|
||||
email: 'name@example.de', telefon: '0123 4567890', ort: 'Musterstadt',
|
||||
}, 'Systemadministrator');
|
||||
|
||||
const letter = {
|
||||
empfaenger: { firma: 'Muster GmbH', adresse: 'Industriestraße 5', ort: '12345 Musterstadt', ansprechpartner: 'Frau Beispiel' },
|
||||
betreff: 'Bewerbung als Systemadministrator',
|
||||
anrede: 'Sehr geehrte Frau Beispiel,',
|
||||
absaetze: [
|
||||
'dies ist eine Vorschau Ihres Anschreiben-Layouts. Der Text ist ein Platzhalter und zeigt, '
|
||||
+ 'wie Absätze, Zeilenabstand und Schriftgröße im fertigen Dokument wirken.',
|
||||
'Die Farbe des Namens, der Überschriften und der Linien folgt der Akzentfarbe, die Sie unter '
|
||||
+ 'Design gewählt haben. Der eigentliche Bewerbungstext wird später von der KI aus Ihren '
|
||||
+ 'Basis-Unterlagen erzeugt.',
|
||||
'Unterschrift und Anlagenliste erscheinen genau so wie hier dargestellt.',
|
||||
],
|
||||
gruss: 'Mit freundlichen Grüßen',
|
||||
};
|
||||
|
||||
const cv = {
|
||||
profil: 'Kurzprofil als Platzhalter: zwei bis drei Sätze, die später von der KI auf die '
|
||||
+ 'jeweilige Stelle zugeschnitten werden.',
|
||||
berufserfahrung: [
|
||||
{ zeitraum: '02/2022 - heute', titel: 'Systemadministrator', firma: 'Beispiel AG, Musterstadt', punkte: ['Betrieb der Server- und Netzwerkinfrastruktur', 'Automatisierung wiederkehrender Aufgaben'] },
|
||||
{ zeitraum: '05/2018 - 01/2022', titel: 'IT-Supporter', firma: 'Muster GmbH, Musterstadt', punkte: ['First- und Second-Level-Support', 'Betreuung der Clients und Benutzerkonten'] },
|
||||
],
|
||||
studium: [],
|
||||
berufsausbildung: [{ zeitraum: '08/2015 - 06/2018', abschluss: 'Fachinformatiker Systemintegration', institution: 'Musterbetrieb / Berufskolleg', zusatz: '' }],
|
||||
schulbildung: [],
|
||||
weiterbildungen: [],
|
||||
kenntnisse: ['Linux / Windows Server', 'Netzwerke, Firewalls', 'Docker', 'Backup & Monitoring'],
|
||||
sprachen: [{ sprache: 'Deutsch', niveau: 'Muttersprache' }, { sprache: 'Englisch', niveau: 'gut' }],
|
||||
hobbys: ['Heimserver', 'Radfahren'],
|
||||
};
|
||||
|
||||
return {
|
||||
anschreiben: renderAnschreibenPdf(letter, header, { firma: 'Muster GmbH', stelle: 'Systemadministrator' },
|
||||
['Lebenslauf', 'Zeugnisse'], signatur, design),
|
||||
lebenslauf: renderLebenslaufPdf(cv, header, bewerbungsfoto, design),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateApplicationDocuments,
|
||||
generateEmailReply,
|
||||
generateTailoredTexts,
|
||||
renderLebenslaufPdf,
|
||||
renderAnschreibenPdf,
|
||||
renderDesignVorschau,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user