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:
+161
@@ -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({}),
|
||||||
|
};
|
||||||
+207
-126
@@ -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);
|
||||||
|
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);
|
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user