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

Design der Unterlagen

+

+ 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. +

+ +
+
+
+ <% designFelder.forEach(f => { %> +
+ + + <% if (f.optionen.length < 2) { %> + +

Weitere Layouts folgen.

+ <% } %> +
+ <% }) %> +
+ + + + + +
+ + + + <% if (designAngepasst) { %> + + <% } %> +
+

+ Die Vorschau zeigt die aktuell ausgewählten Werte – auch ungespeicherte. +

+
+
+

KI-Prompts

@@ -336,6 +402,22 @@ const cy = document.getElementById('currentYear'); 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')); + })();