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:
2026-07-13 00:51:36 +02:00
co-authored by Claude Opus 4.8
parent 65a5943993
commit 9f7349af4e
4 changed files with 546 additions and 128 deletions
+161
View File
@@ -0,0 +1,161 @@
// Editable design settings for the generated application documents.
//
// Same contract as lib/prompts.js: the defaults live here, an override is a row
// in the `design` table, and deleting the row restores the default. What stays
// in code is the *layout* — the sidebar grid, the DIN-5008 letter, the one-page
// auto-scaling. What the user picks is a narrow, curated set of knobs.
//
// The palette is deliberately closed. A free colour picker invites a neon CV,
// which makes an application worse, not better; every option here is a muted
// tone that survives being printed in greyscale by an HR department.
// Accent colours. `ink`/`sub`/`hair` stay neutral in every theme — the accent
// only carries the name, section labels, company names and markers, exactly as
// the monochrome original did.
const AKZENTE = [
{ key: 'anthrazit', label: 'Anthrazit (Standard)', rgb: [23, 23, 23] },
{ key: 'tiefblau', label: 'Tiefblau', rgb: [30, 58, 95] },
{ key: 'bordeaux', label: 'Bordeaux', rgb: [122, 31, 47] },
{ key: 'waldgruen', label: 'Waldgrün', rgb: [27, 67, 50] },
{ key: 'stahlblau', label: 'Stahlblau', rgb: [51, 65, 85] },
{ key: 'kupfer', label: 'Kupfer', rgb: [124, 62, 24] },
];
const SIDEBAR_VARIANTEN = [
{ key: 'neutral', label: 'Neutral grau (Standard)' },
{ key: 'getoent', label: 'Im Akzent getönt' },
{ key: 'weiss', label: 'Weiß (ohne Band)' },
];
const FOTO_FORMEN = [
{ key: 'eckig', label: 'Eckig (Standard)' },
{ key: 'rund', label: 'Rund' },
];
const SCHRIFT_STUFEN = [
{ key: '90', label: '90 % (kompakt)' },
{ key: '95', label: '95 %' },
{ key: '100', label: '100 % (Standard)' },
{ key: '105', label: '105 %' },
{ key: '110', label: '110 % (groß)' },
];
// Layout is fixed for now; the field exists so a second variant can be added
// later without another migration.
const LAYOUTS = [
{ key: 'sidebar', label: 'Sidebar (Standard)' },
];
const DEFAULTS = {
layout: 'sidebar',
akzent: 'anthrazit',
sidebar: 'neutral',
foto_anzeigen: '1',
foto_form: 'eckig',
schrift: '100',
};
const FELDER = [
{ key: 'layout', label: 'Layout', optionen: LAYOUTS },
{ key: 'akzent', label: 'Akzentfarbe', optionen: AKZENTE },
{ key: 'sidebar', label: 'Sidebar-Hintergrund', optionen: SIDEBAR_VARIANTEN },
{ key: 'foto_form', label: 'Form des Bewerberfotos', optionen: FOTO_FORMEN },
{ key: 'schrift', label: 'Schriftgröße', optionen: SCHRIFT_STUFEN },
];
const NEUTRAL_SIDEBAR = [242, 243, 245];
// Mix a colour towards white. amount = 0 -> white, 1 -> the colour itself.
function tint(rgb, amount) {
return rgb.map((c) => Math.round(c * amount + 255 * (1 - amount)));
}
function findOption(list, key, fallbackKey) {
return list.find((o) => o.key === key) || list.find((o) => o.key === fallbackKey);
}
// A checkbox paired with a hidden fallback submits *both* values, which Express
// hands over as an array — the last entry is the effective one.
function one(v) {
return Array.isArray(v) ? v[v.length - 1] : v;
}
// Fold the stored overrides onto the defaults, dropping unknown keys/values so a
// stale row, a hand-crafted query string or a checkbox pair can never produce an
// invalid theme.
function settings(overrides = {}) {
const out = { ...DEFAULTS };
for (const f of FELDER) {
const v = one(overrides[f.key]);
if (v && f.optionen.some((o) => o.key === String(v))) out[f.key] = String(v);
}
const foto = one(overrides.foto_anzeigen);
if (foto === '0' || foto === '1') out.foto_anzeigen = foto;
return out;
}
// Build the concrete theme the renderer draws with. Everything the PDF code
// needs to know about the user's choices is in here — the renderer itself has
// no idea these are configurable.
function resolve(overrides = {}) {
const s = settings(overrides);
const accent = findOption(AKZENTE, s.akzent, 'anthrazit').rgb;
let sidebarBg;
if (s.sidebar === 'weiss') sidebarBg = [255, 255, 255];
else if (s.sidebar === 'getoent') sidebarBg = tint(accent, 0.08);
else sidebarBg = NEUTRAL_SIDEBAR;
return {
layout: s.layout,
// Résumé palette — neutral base, configurable accent.
rc: {
ink: [33, 33, 33],
sub: [92, 96, 100],
hair: [208, 210, 214],
accent,
sidebarBg,
track: [199, 202, 208],
onSide: [38, 40, 44],
onSideSub: [96, 100, 106],
},
// Cover-letter palette — same accent, so both documents read as one set.
lc: {
ink: [26, 26, 26],
muted: [92, 96, 100],
hair: [206, 208, 212],
accent,
},
foto: {
anzeigen: s.foto_anzeigen !== '0',
rund: s.foto_form === 'rund',
},
// Starting scale for the one-page fit. The auto-scaler may still shrink
// below this when there is a lot of content — it never grows past it.
scale: (Number(s.schrift) || 100) / 100,
};
}
// Field list for the Vorlagen UI, with the current selection marked.
function list(overrides = {}) {
const s = settings(overrides);
return FELDER.map((f) => ({
key: f.key,
label: f.label,
aktuell: s[f.key],
optionen: f.optionen.map((o) => ({ key: o.key, label: o.label, gewaehlt: o.key === s[f.key] })),
}));
}
// True when the user deviates from the shipped defaults.
function isAngepasst(overrides = {}) {
const s = settings(overrides);
return Object.keys(DEFAULTS).some((k) => s[k] !== DEFAULTS[k]);
}
module.exports = {
DEFAULTS, AKZENTE, FELDER,
settings, resolve, list, isAngepasst,
// The default theme, for callers that pass no overrides at all.
defaultTheme: () => resolve({}),
};
+208 -127
View File
@@ -10,6 +10,7 @@ const { jsPDF } = require('jspdf');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const promptStore = require('./prompts'); const promptStore = require('./prompts');
const designStore = require('./design');
// Embedded professional typeface for both cover letter and résumé: Lato // 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 // (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 BODY_W = MAIN_R - BODY_X; // 107
const MAIN_W = MAIN_R - MAIN_X; // 115 full main width (name / heading rules) const MAIN_W = MAIN_R - MAIN_X; // 115 full main width (name / heading rules)
// Strictly monochrome — no colour anywhere. Hierarchy comes from weight, size, // The palette (`t.rc` here, `t.lc` for the letter) comes from lib/design.js and
// tracking and a single neutral grey scale. The near-black "accent" carries the // is threaded through every draw call as `t`. The base stays a neutral grey
// name, section labels, company names, markers and the photo frame; body text // scale — hierarchy is carried by weight, size and tracking, exactly as in the
// sits just below it; everything structural is grey. // monochrome original. Only the accent (name, section labels, company names,
const RC = { // markers, photo frame) is the user's choice, so no setting can flatten the
ink: [33, 33, 33], // typographic structure.
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],
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sidebar // Sidebar
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fit an applicant photo into a portrait frame, preserving aspect ratio. The // Fit an applicant photo into its frame, preserving aspect ratio. The frame size
// frame size is fixed (does not scale with the page-fit S) so the sidebar // is fixed (does not scale with the page-fit S) so the sidebar keeps a stable,
// keeps a stable, confident anchor regardless of how much text is below it. // confident anchor regardless of how much text is below it.
function fitPhoto(doc, foto) { //
// 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; const MAXW = 42, MAXH = 52;
if (!foto || !foto.dataUrl) return { ok: false, w: 0, h: 0 }; if (!foto || !foto.dataUrl) return MISS;
try { try {
const p = doc.getImageProperties(foto.dataUrl); const p = doc.getImageProperties(foto.dataUrl);
if (!(p.width > 0 && p.height > 0)) return { ok: false, w: 0, h: 0 }; if (!(p.width > 0 && p.height > 0)) return MISS;
let w = MAXW, h = w * p.height / p.width; const ar = p.width / p.height;
if (h > MAXH) { h = MAXH; w = h * p.width / p.height; } if (rund) {
return { ok: true, w, h }; const D = MAXW; // square frame = circle diameter
} catch (e) { return { ok: false, w: 0, h: 0 }; } 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) { function buildContactLines(header) {
@@ -694,7 +700,7 @@ function buildContactLines(header) {
// Sidebar section label: a compact tracked uppercase accent word. No underline // Sidebar section label: a compact tracked uppercase accent word. No underline
// rule — the sidebar stays clean; tracking and weight carry the structure. // 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 pt = 9;
const cs = 1.0 * S; const cs = 1.0 * S;
cur.y += 5 * S; cur.y += 5 * S;
@@ -703,14 +709,14 @@ function sbHeading(doc, S, dry, cur, title) {
const label = String(title).toUpperCase(); const label = String(title).toUpperCase();
if (!dry) { if (!dry) {
doc.setCharSpace(cs); 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.text(label, SB_X, cur.y + pt * S * PT * 0.76);
doc.setCharSpace(0); doc.setCharSpace(0);
} }
cur.y += pt * S * PT + 4.6 * S; 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 tx = SB_X + 3.4;
const tw = SB_CW - 3.4; const tw = SB_CW - 3.4;
for (const it of items) { for (const it of items) {
@@ -721,10 +727,10 @@ function sbBullets(doc, S, dry, cur, items, pt = 8.7) {
if (!dry) { if (!dry) {
if (i === 0) { if (i === 0) {
const sz = 1.1 * S; 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.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); doc.text(ln, tx, cur.y + pt * S * PT * 0.76);
} }
cur.y += pt * S * PT * 1.32; 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). // 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; const pt = 8.7;
doc.setFont('Lato', 'normal'); doc.setFont('Lato', 'normal');
doc.setFontSize(pt * S); doc.setFontSize(pt * S);
if (!dry) { 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); doc.text(String(s.sprache), SB_X, cur.y + pt * S * PT * 0.76);
if (s.niveau) { 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' }); doc.text(String(s.niveau), SB_R, cur.y + pt * S * PT * 0.76, { align: 'right' });
} }
} }
cur.y += pt * S * PT * 1.62; 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 cur = { y: RV.top };
const photo = fitPhoto(doc, foto); const photo = fitPhoto(doc, foto, t.foto.rund);
if (photo.ok) { if (photo.ok) {
const px = (SB_W - photo.w) / 2; const px = (SB_W - photo.w) / 2;
const py = RV.top - 2; const py = RV.top - 2;
if (!dry) { if (!dry) {
doc.addImage(foto.dataUrl, foto.format || 'PNG', px, py, photo.w, photo.h); doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]);
doc.setDrawColor(RC.hair[0], RC.hair[1], RC.hair[2]);
doc.setLineWidth(0.3 * S); 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; cur.y = py + photo.h + 7 * S;
} }
const contact = buildContactLines(header); const contact = buildContactLines(header);
if (contact.length) { if (contact.length) {
sbHeading(doc, S, dry, cur, 'Kontakt'); sbHeading(doc, t, 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 })); 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) { if (cv.kenntnisse.length) {
sbHeading(doc, S, dry, cur, 'Kernkompetenzen'); sbHeading(doc, t, S, dry, cur, 'Kernkompetenzen');
sbBullets(doc, S, dry, cur, cv.kenntnisse); sbBullets(doc, t, S, dry, cur, cv.kenntnisse);
} }
if (cv.sprachen.length) { if (cv.sprachen.length) {
sbHeading(doc, S, dry, cur, 'Sprachen'); sbHeading(doc, t, S, dry, cur, 'Sprachen');
cv.sprachen.forEach((s) => sbLangRow(doc, S, dry, cur, s)); cv.sprachen.forEach((s) => sbLangRow(doc, t, S, dry, cur, s));
} }
if (cv.hobbys.length) { if (cv.hobbys.length) {
sbHeading(doc, S, dry, cur, 'Interessen'); sbHeading(doc, t, 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 }); 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; 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 // Main section heading: tracked uppercase accent label, outdented over a
// full-width hairline whose first stretch is a heavier accent segment as wide // full-width hairline whose first stretch is a heavier accent segment as wide
// as the label word — a precise, repeating anchor down the page. // 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 pt = 11;
const cs = 0.9 * S; const cs = 0.9 * S;
cur.y += 5.5 * 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; const lineY = cur.y + pt * S * PT * 1.1;
if (!dry) { if (!dry) {
doc.setCharSpace(cs); 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.text(label, MAIN_X, cur.y + pt * S * PT * 0.76);
doc.setCharSpace(0); doc.setCharSpace(0);
const wordW = doc.getTextWidth(label) + cs * label.length; 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.setLineWidth(0.3 * S);
doc.line(MAIN_X, lineY, MAIN_R, lineY); 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.setLineWidth(1.1 * S);
doc.line(MAIN_X, lineY, Math.min(MAIN_X + wordW, MAIN_R), lineY); doc.line(MAIN_X, lineY, Math.min(MAIN_X + wordW, MAIN_R), lineY);
} }
cur.y += pt * S * PT + 4.6 * S; 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 tx = x + 3.8;
const tw = w - 3.8; const tw = w - 3.8;
for (const it of items) { for (const it of items) {
@@ -827,10 +849,10 @@ function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.2) {
if (!dry) { if (!dry) {
if (i === 0) { if (i === 0) {
const sz = 1.15 * S; 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.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); doc.text(ln, tx, cur.y + pt * S * PT * 0.76);
} }
cur.y += pt * S * PT * 1.32; 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 // 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. // 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 cx = MAIN_X + 1.4; // node / track centre x
const titlePt = 11, datePt = 9.3; const titlePt = 11, datePt = 9.3;
const dateW = 30; 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 titleLines = doc.splitTextToSize(String(e.titel || ''), BODY_W - dateW);
const titleLineH = titlePt * S * PT * 1.16; const titleLineH = titlePt * S * PT * 1.16;
if (!dry) { 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.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)); titleLines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * titleLineH + titlePt * S * PT * 0.76));
if (e.zeitraum) { if (e.zeitraum) {
doc.setFont('Lato', 'normal'); doc.setFont('Lato', 'normal');
doc.setFontSize(datePt * S); 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' }); doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' });
} }
} }
cur.y += titleLineH * Math.max(1, titleLines.length); 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.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, S, dry, cur, e.punkte, BODY_X, BODY_W); } 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; cur.y += 5 * S;
} }
// The full Berufserfahrung section, threaded on a subtle vertical timeline. // 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 // 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. // 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; const startY = cur.y;
if (!dry) { if (!dry) {
const probe = { y: cur.y }; const probe = { y: cur.y };
entries.forEach((e) => cvExperience(doc, S, true, probe, e)); entries.forEach((e) => cvExperience(doc, t, S, true, probe, e));
doc.setDrawColor(RC.track[0], RC.track[1], RC.track[2]); doc.setDrawColor(t.rc.track[0], t.rc.track[1], t.rc.track[2]);
doc.setLineWidth(0.5 * S); doc.setLineWidth(0.5 * S);
doc.line(MAIN_X + 1.4, startY + 2 * S, MAIN_X + 1.4, probe.y - 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; const pt = 10.5, datePt = 9.3, dateW = 30;
doc.setFont('Lato', 'bold'); doc.setFont('Lato', 'bold');
doc.setFontSize(pt * S); doc.setFontSize(pt * S);
const lines = doc.splitTextToSize(String(e.abschluss || ''), BODY_W - dateW); const lines = doc.splitTextToSize(String(e.abschluss || ''), BODY_W - dateW);
const lineH = pt * S * PT * 1.16; const lineH = pt * S * PT * 1.16;
if (!dry) { 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)); lines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * lineH + pt * S * PT * 0.76));
if (e.zeitraum) { if (e.zeitraum) {
doc.setFont('Lato', 'normal'); doc.setFont('Lato', 'normal');
doc.setFontSize(datePt * S); 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' }); doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' });
} }
} }
cur.y += lineH * Math.max(1, lines.length); 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.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: RC.sub, x: BODY_X, width: BODY_W, factor: 1.24 }); 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; cur.y += 4 * S;
} }
// Big name + target role — the confident head the recruiter's eye lands on // 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. // first. No divider rule here; the first section heading provides the structure.
function composeMainHeader(doc, S, dry, cur, header, titel) { function composeMainHeader(doc, t, 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 }); 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: RC.sub, x: MAIN_X, width: MAIN_W, factor: 1.36, charSpace: 0.3 }); 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; 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 }; const cur = { y: RV.top };
composeMainHeader(doc, S, dry, cur, header, titel); composeMainHeader(doc, t, S, dry, cur, header, titel);
if (cv.profil) { if (cv.profil) {
mHeading(doc, S, dry, cur, 'Profil'); mHeading(doc, t, 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 }); 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) { if (cv.berufserfahrung.length) {
mHeading(doc, S, dry, cur, 'Berufserfahrung'); mHeading(doc, t, S, dry, cur, 'Berufserfahrung');
composeTimeline(doc, S, dry, cur, cv.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; if (!entries || !entries.length) return;
mHeading(doc, S, dry, cur, t); mHeading(doc, t, S, dry, cur, titel);
entries.forEach((e) => cvEducation(doc, S, dry, cur, e)); entries.forEach((e) => cvEducation(doc, t, S, dry, cur, e));
}); });
return cur.y; 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 — // 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 // 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. // 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) { 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'); doc.rect(0, 0, SB_W, PAGE.h, 'F');
} }
const mainBottom = composeMain(doc, S, dry, ctx); const mainBottom = composeMain(doc, t, S, dry, ctx);
const sideBottom = composeSidebar(doc, S, dry, ctx); const sideBottom = composeSidebar(doc, t, S, dry, ctx);
return Math.max(mainBottom, sideBottom); 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 doc = makeDoc();
const ctx = { cv, header, titel, foto }; 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; const avail = PAGE.h - RV.top - RV.bottom;
let S = 1; let S = S0;
if (need > avail) S = Math.max(MIN_SCALE, (avail / need) * 0.99); if (need > avail) S = Math.max(MIN_SCALE, S0 * (avail / need) * 0.99);
composeCV(doc, S, false, ctx); composeCV(doc, t, S, false, ctx);
return Buffer.from(doc.output('arraybuffer')); 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_R = PAGE.w - LET.mr; // 190
const LET_W = LET_R - LET.mx; // 165 const LET_W = LET_R - LET.mx; // 165
// Strictly monochrome, matching the résumé, so the cover letter and CV read as // The letter shares the résumé's accent (see lib/design.js), so cover letter and
// one deliberately designed black-and-white application set. // CV keep reading as one deliberately designed set whatever the user picks.
const LC = {
ink: [26, 26, 26],
muted: [92, 96, 100],
hair: [206, 208, 212],
accent: [23, 23, 23],
};
// Extract just the town from a possibly-full address ("Feldstraße 76, 45968 // Extract just the town from a possibly-full address ("Feldstraße 76, 45968
// Gladbeck" / "45968 Gladbeck" / "Gladbeck" → "Gladbeck"). // Gladbeck" / "45968 Gladbeck" / "Gladbeck" → "Gladbeck").
@@ -981,7 +1002,7 @@ function cityName(header) {
return s.replace(/\b\d{5}\b/g, '').replace(/\s+/g, ' ').trim(); 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 x = LET.mx;
const right = LET_R; const right = LET_R;
const width = LET_W; 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 // --- Letterhead: a big, confident name + role in tracked caps, closed by
// the shared hairline-with-accent motif. Full contact details live in the // 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. --- // 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 }); 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: LC.muted, x, width, factor: 1.32, charSpace: 0.8, upper: true }); 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 // Hairline across, with a short heavier accent segment at the start — the
// same motif that anchors every section of the résumé. // same motif that anchors every section of the résumé.
cur.y += 3.4 * S; cur.y += 3.4 * S;
if (!dry) { 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.setLineWidth(0.3 * S);
doc.line(x, cur.y, right, cur.y); 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.setLineWidth(1.1 * S);
doc.line(x, cur.y, x + 16 * S, cur.y); 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 emp = letter.empfaenger || {};
const empFirma = emp.firma || job.firma || ''; const empFirma = emp.firma || job.firma || '';
const empOrt = emp.ort || job.ort || ''; 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 (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: 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: 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: 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) --- // --- Date (right-aligned) ---
cur.y += 7 * S; cur.y += 7 * S;
const dateLine = stadt ? `${stadt}, den ${today}` : today; 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 --- // --- Subject (bold, no "Betreff:" label) — the letter's headline ---
cur.y += 7 * S; 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 --- // --- Salutation + body ---
cur.y += 6 * S; 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) => { letter.absaetze.forEach((p, i) => {
if (i > 0) cur.y += 3.2 * S; 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 --- // --- Closing + signature ---
cur.y += 6 * S; 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 // Signature image (if provided) directly under the closing, replacing the
// typed name; otherwise leave room and print the name. // 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; cur.y += sigH;
} else { } else {
cur.y += 13 * S; // room for a handwritten signature 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 --- // --- Enclosures ---
if (anlagen && anlagen.length) { if (anlagen && anlagen.length) {
cur.y += 8 * S; cur.y += 8 * S;
write(doc, S, dry, cur, (anlagen.length > 1 ? 'Anlagen: ' : 'Anlage: ') + anlagen.join(', '), 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 // --- 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 (header.email) parts.push(header.email);
if (parts.length) { if (parts.length) {
const fy = PAGE.h - 15; 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.setLineWidth(0.3 * S);
doc.line(x, fy, right, fy); doc.line(x, fy, right, fy);
doc.setFont('Lato', 'normal'); doc.setFont('Lato', 'normal');
doc.setFontSize(8 * S); 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' }); doc.text(parts.join(' · '), (x + right) / 2, fy + 4 * S, { align: 'center' });
} }
} }
} }
function renderSingleColumn(compose) { function renderSingleColumn(compose, t) {
const doc = makeDoc(); 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 need = m.y - LET.top;
const avail = PAGE.h - LET.top - LET.bottom; const avail = PAGE.h - LET.top - LET.bottom;
let S = 1; let S = S0;
if (need > avail) S = Math.max(MIN_SCALE, (avail / need) * 0.99); if (need > avail) S = Math.max(MIN_SCALE, S0 * (avail / need) * 0.99);
compose(doc, S, false, { y: LET.top }); compose(doc, t, S, false, { y: LET.top });
return Buffer.from(doc.output('arraybuffer')); 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. // 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) { function renderAnschreibenPdf(letter, header, job, anlagen, signatur, design = null) {
return renderSingleColumn((doc, S, dry, cur) => composeLetter(doc, S, dry, cur, { letter, header, job, anlagen, signatur })); 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); || 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 data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen, prompts });
const headline = data.headline || job.stelle || ''; const headline = data.headline || job.stelle || '';
const header = buildHeader(settings, data.kontakt, headline); const header = buildHeader(settings, data.kontakt, headline);
@@ -1153,7 +1181,7 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus
name: `Anschreiben - ${label}`.trim(), name: `Anschreiben - ${label}`.trim(),
filename: `Anschreiben_${suffix}.pdf`, filename: `Anschreiben_${suffix}.pdf`,
mime: 'application/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(), name: `Lebenslauf - ${label}`.trim(),
filename: `Lebenslauf_${suffix}.pdf`, filename: `Lebenslauf_${suffix}.pdf`,
mime: 'application/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 = { module.exports = {
generateApplicationDocuments, generateApplicationDocuments,
generateEmailReply, generateEmailReply,
generateTailoredTexts, generateTailoredTexts,
renderLebenslaufPdf, renderLebenslaufPdf,
renderAnschreibenPdf, renderAnschreibenPdf,
renderDesignVorschau,
}; };
+95 -1
View File
@@ -27,9 +27,10 @@ const multer = require('multer');
} }
})(); })();
const { generateApplicationDocuments, generateEmailReply } = require('./lib/documents'); const { generateApplicationDocuments, generateEmailReply, renderDesignVorschau } = require('./lib/documents');
const chat = require('./lib/chat'); const chat = require('./lib/chat');
const promptStore = require('./lib/prompts'); const promptStore = require('./lib/prompts');
const designStore = require('./lib/design');
const mailer = require('./lib/mailer'); const mailer = require('./lib/mailer');
const { createExternalApi } = require('./lib/api'); const { createExternalApi } = require('./lib/api');
const { buildOpenApiSpec } = require('./lib/openapi'); const { buildOpenApiSpec } = require('./lib/openapi');
@@ -439,6 +440,18 @@ async function loadPrompts() {
} }
} }
// The user's design overrides as { key: value }. Unset keys keep the defaults
// from lib/design.js. Read fresh per generation, like the prompts.
async function loadDesign() {
try {
const rows = await dbAll('SELECT key, value FROM design');
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
} catch (e) {
console.error('Konnte Design nicht laden, nutze Standardwerte:', e.message);
return {};
}
}
// Match an incoming message to an application: first via In-Reply-To/References // Match an incoming message to an application: first via In-Reply-To/References
// pointing at one of our sent messages, then by sender = a previous recipient. // pointing at one of our sent messages, then by sender = a previous recipient.
async function matchBewerbung(msg) { async function matchBewerbung(msg) {
@@ -598,6 +611,7 @@ async function runGeneration(bewerbungId, options = {}) {
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC'); const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC');
const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
const prompts = await loadPrompts(); const prompts = await loadPrompts();
const design = await loadDesign();
// Only the explicitly selected extra attachments are enclosed (default: none). // Only the explicitly selected extra attachments are enclosed (default: none).
const anlagenIds = Array.isArray(options.anlagenIds) ? options.anlagenIds.map(Number) : []; const anlagenIds = Array.isArray(options.anlagenIds) ? options.anlagenIds.map(Number) : [];
@@ -614,6 +628,7 @@ async function runGeneration(bewerbungId, options = {}) {
basisDokumente, basisDokumente,
settings, settings,
prompts, prompts,
design,
// Names of the selected attachments so the cover letter (and the LLM) lists // Names of the selected attachments so the cover letter (and the LLM) lists
// exactly these under "Anlagen". // exactly these under "Anlagen".
zusatzAnlagen: selectedAnhaenge.map((a) => a.name || a.dateiname), zusatzAnlagen: selectedAnhaenge.map((a) => a.name || a.dateiname),
@@ -967,6 +982,18 @@ function initializeDatabase() {
) )
`, () => {}); `, () => {});
// Design choices for the generated PDFs (accent colour, sidebar tint,
// photo shape, font size). Same contract as `prompts`: only deviations
// are stored, and lib/design.js validates every value on read, so a
// stale row can never produce a broken document.
db.run(`
CREATE TABLE IF NOT EXISTS design (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, () => {});
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -1908,10 +1935,14 @@ initializeDatabase().then(() => {
try { try {
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC'); const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC'); const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC');
const design = await loadDesign();
res.render('vorlagen', { res.render('vorlagen', {
basisDokumente, basisDokumente,
basisAnhaenge, basisAnhaenge,
prompts: promptStore.list(await loadPrompts()), prompts: promptStore.list(await loadPrompts()),
designFelder: designStore.list(design),
designAngepasst: designStore.isAngepasst(design),
designFotoAn: designStore.settings(design).foto_anzeigen === '1',
hasSignatur: Boolean(currentSignaturFile()), hasSignatur: Boolean(currentSignaturFile()),
hasFoto: Boolean(currentFotoFile()), hasFoto: Boolean(currentFotoFile()),
basisTypOptions: BASIS_TYP_OPTIONS, basisTypOptions: BASIS_TYP_OPTIONS,
@@ -1962,6 +1993,69 @@ initializeDatabase().then(() => {
} }
}); });
// ----- Design of the generated PDFs -----
// Save the design choices. Values are validated by lib/design.js on read, and
// a choice equal to the default is stored as a deletion, so the DB only ever
// holds real deviations.
app.post('/vorlagen/design', async (req, res) => {
try {
const gewaehlt = designStore.settings(req.body);
for (const [key, value] of Object.entries(gewaehlt)) {
if (value === designStore.DEFAULTS[key]) {
await dbRun('DELETE FROM design WHERE key = ?', [key]);
} else {
await dbRun(
`INSERT INTO design (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
[key, value]
);
}
}
res.redirect('/vorlagen#design');
} catch (error) {
console.error('Error saving design:', error);
res.status(500).send('Serverfehler');
}
});
// Back to the shipped design.
app.post('/vorlagen/design/reset', async (req, res) => {
try {
await dbRun('DELETE FROM design');
res.redirect('/vorlagen#design');
} catch (error) {
console.error('Error resetting design:', error);
res.status(500).send('Serverfehler');
}
});
// Preview PDF (Anschreiben or Lebenslauf) with sample content — lets the user
// see a design choice without spending an LLM run. Query params override the
// saved design, so the form can preview a selection before it is saved.
app.get('/vorlagen/design/vorschau/:doc.pdf', async (req, res) => {
try {
const welches = req.params.doc === 'lebenslauf' ? 'lebenslauf' : 'anschreiben';
const gespeichert = await loadDesign();
// Only known design keys from the query are honoured; design.settings()
// drops anything invalid.
const design = { ...gespeichert, ...req.query };
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
const pdfs = renderDesignVorschau({
settings,
signatur: loadSignatur(),
bewerbungsfoto: loadFoto(),
design,
});
res.type('application/pdf');
res.setHeader('Content-Disposition', `inline; filename="Vorschau_${welches}.pdf"`);
res.send(pdfs[welches]);
} catch (error) {
console.error('Error rendering design preview:', error);
res.status(500).send('Vorschau konnte nicht erzeugt werden');
}
});
// Add a base document // Add a base document
app.post('/vorlagen', async (req, res) => { app.post('/vorlagen', async (req, res) => {
try { try {
+82
View File
@@ -252,6 +252,72 @@
</form> </form>
</section> </section>
<!-- Design of the generated PDFs -->
<h2 id="design" class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2 scroll-mt-4">Design der Unterlagen</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Aussehen von Anschreiben und Lebenslauf. Das Grundlayout (Aufbau, Seitenränder, DIN-gerechter Brief)
bleibt fest der Lebenslauf passt immer auf eine Seite. Die Vorschau zeigt Musterinhalte mit deinem
Namen, Foto und deiner Unterschrift, ohne dass die KI läuft.
</p>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<form action="/vorlagen/design" method="POST" class="space-y-4" id="designForm">
<div class="grid gap-4 sm:grid-cols-2">
<% designFelder.forEach(f => { %>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"><%= f.label %></label>
<select name="<%= f.key %>" <%= f.optionen.length < 2 ? 'disabled' : '' %>
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-60">
<% f.optionen.forEach(o => { %>
<option value="<%= o.key %>" <%= o.gewaehlt ? 'selected' : '' %>><%= o.label %></option>
<% }) %>
</select>
<% if (f.optionen.length < 2) { %>
<input type="hidden" name="<%= f.key %>" value="<%= f.aktuell %>">
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Weitere Layouts folgen.</p>
<% } %>
</div>
<% }) %>
</div>
<!-- The hidden field must come first: when the box is checked both
values are submitted and the *last* one wins. -->
<input type="hidden" name="foto_anzeigen" value="0">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="foto_anzeigen" value="1" <%= designFotoAn ? 'checked' : '' %>
class="rounded border-gray-300 dark:border-gray-600">
Bewerberfoto im Lebenslauf anzeigen
<% if (!hasFoto) { %>
<span class="text-xs text-gray-400 dark:text-gray-500">(noch kein Foto hinterlegt)</span>
<% } %>
</label>
<div class="flex flex-wrap items-center gap-3 border-t border-gray-200 dark:border-gray-700 pt-4">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
Speichern
</button>
<button type="button" id="vorschauAnschreiben"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Vorschau Anschreiben
</button>
<button type="button" id="vorschauLebenslauf"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Vorschau Lebenslauf
</button>
<% if (designAngepasst) { %>
<button type="submit" formaction="/vorlagen/design/reset" formnovalidate
onclick="return confirm('Design auf die Standardwerte zurücksetzen?');"
class="text-sm text-gray-600 dark:text-gray-400 hover:underline">
Auf Standard zurücksetzen
</button>
<% } %>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500">
Die Vorschau zeigt die aktuell <em>ausgewählten</em> Werte auch ungespeicherte.
</p>
</form>
</section>
<!-- Editable KI prompts --> <!-- Editable KI prompts -->
<h2 id="prompts" class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2 scroll-mt-4">KI-Prompts</h2> <h2 id="prompts" class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2 scroll-mt-4">KI-Prompts</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6"> <p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
@@ -336,6 +402,22 @@
const cy = document.getElementById('currentYear'); const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear(); if (cy) cy.textContent = new Date().getFullYear();
})(); })();
// Design preview: open the sample PDF with the values currently selected
// in the form (not just the saved ones), so a choice can be judged before
// it is committed.
(function () {
const form = document.getElementById('designForm');
if (!form) return;
function oeffne(welches) {
const q = new URLSearchParams(new FormData(form)).toString();
window.open('/vorlagen/design/vorschau/' + welches + '.pdf?' + q, '_blank');
}
const a = document.getElementById('vorschauAnschreiben');
const l = document.getElementById('vorschauLebenslauf');
if (a) a.addEventListener('click', () => oeffne('anschreiben'));
if (l) l.addEventListener('click', () => oeffne('lebenslauf'));
})();
</script> </script>
</body> </body>
</html> </html>