From 78128db42297517f5cb3aa1ea8327c6e4c74c58f Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Fri, 3 Jul 2026 11:37:03 +0200 Subject: [PATCH] Signature image in cover letter + fix per-paragraph capitalization - Only the first body paragraph continues the salutation (lowercase safe opener); every following paragraph is a new sentence and is capitalized. Handled deterministically so it no longer depends on the model. - Signature: upload a signature image on the Vorlagen page (preview / replace / remove). It is embedded in the cover letter directly under the closing salutation, replacing the typed name; falls back to the name when absent. Co-Authored-By: Claude Opus 4.8 --- lib/documents.js | 73 +++++++++++++++++++++++++++++------------ server.js | 81 ++++++++++++++++++++++++++++++++++++++++++++++ views/vorlagen.ejs | 34 +++++++++++++++++++ 3 files changed, 168 insertions(+), 20 deletions(-) diff --git a/lib/documents.js b/lib/documents.js index b09f715..862b06c 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -207,9 +207,9 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen `keine Grußformel hier). Sachlich und positiv, in Aktiv-Sätzen, ohne Floskeln und ` + `Weichspüler ("Ich würde gerne …"). KEINE Gehaltsvorstellungen, KEINE Kündigungsgründe, ` + `KEINE Erwähnung von Lücken/Arbeitslosigkeit, keine Selbstzweifel. Keine Platzhalter — echte Angaben nutzen. ` + - `WICHTIG: Der erste Absatz beginnt mit einem KLEINbuchstaben (die Anrede endet mit Komma, der Satz wird ` + + `Nur der ERSTE Absatz beginnt mit einem KLEINbuchstaben (die Anrede endet mit Komma, der Satz wird ` + `fortgesetzt), z. B. "mit …" oder "als …" — außer das erste Wort ist ein Substantiv, ein Eigenname oder die ` + - `Höflichkeitsform "Ihre/Ihr/Ihnen".\n` + + `Höflichkeitsform "Ihre/Ihr/Ihnen". Alle WEITEREN Absätze sind neue Sätze und beginnen normal mit GROSSbuchstaben.\n` + `- berufserfahrung: ALLE Stationen aus den Unterlagen, neueste zuerst. "firma" = "Arbeitgeber, Ort". ` + `Nur die 2–3 jüngsten/relevantesten erhalten EINEN kurzen "beschreibung"-Satz, ältere lässt du leer.\n` + `- Bildung korrekt einordnen: Hochschulstudium → studium; Berufsausbildung/Ausbildungsberuf → ` + @@ -322,19 +322,32 @@ const LOWER_OPENERS = new Set([ 'meinen', 'meiner', 'meinem', 'meines', ]); -function fixAnredeContinuation(anrede, absaetze) { - if (!absaetze.length || !anrede.trim().endsWith(',')) return absaetze; - const m = absaetze[0].match(/^(\s*)([A-Za-zÄÖÜäöüß]+)([\s\S]*)$/); - if (!m) return absaetze; - const [, lead, word, rest] = m; +// Capitalize the first letter of a paragraph (every paragraph is a new sentence). +function capitalizeFirstLetter(p) { + return String(p).replace(/^(\s*)([a-zäöüß])/, (_, lead, ch) => lead + ch.toUpperCase()); +} + +// Lowercase the first word only if it's a clearly-safe sentence opener. +function lowercaseFirstIfOpener(p) { + const m = String(p).match(/^(\s*)([A-Za-zÄÖÜäöüß]+)/); + if (!m) return p; + const word = m[2]; const first = word[0]; const isUpper = (first >= 'A' && first <= 'Z') || 'ÄÖÜ'.includes(first); if (isUpper && LOWER_OPENERS.has(word.toLowerCase())) { - const out = absaetze.slice(); - out[0] = lead + first.toLowerCase() + word.slice(1) + rest; - return out; + return m[1] + first.toLowerCase() + p.slice(m[1].length + 1); } - return absaetze; + return p; +} + +// Fix paragraph casing: all paragraphs start capitalized (new sentences); the +// first paragraph continues the sentence after the Anrede comma, so a safe +// opener there is lowercased. +function fixParagraphCasing(anrede, absaetze) { + if (!absaetze.length) return absaetze; + const out = absaetze.map(capitalizeFirstLetter); + if (anrede.trim().endsWith(',')) out[0] = lowercaseFirstIfOpener(out[0]); + return out; } // Defensive: never let an HR red-flag phrase reach the PDF, even if the model @@ -390,7 +403,7 @@ function normalizeResult(parsed) { }, betreff: str(pick(a, ['betreff', 'subject', 'titel'])), anrede, - absaetze: fixAnredeContinuation(anrede, absaetze), + absaetze: fixParagraphCasing(anrede, absaetze), gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])), }; })(), @@ -656,7 +669,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 }) { +function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen, signatur }) { const x = LET.mx; const right = LET_R; const width = LET_W; @@ -707,11 +720,31 @@ function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen }) { write(doc, S, dry, cur, p, { pt: 10.5, color: LC.ink, x, width, factor: 1.52 }); }); - // --- Closing + signature space + name --- + // --- 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 }); - cur.y += 13 * S; // room for a signature - write(doc, S, dry, cur, header.name, { pt: 10.5, style: 'bold', color: LC.ink, x, width, factor: 1.2 }); + + // Signature image (if provided) directly under the closing, replacing the + // typed name; otherwise leave room and print the name. + let sigOk = false, sigW = 0, sigH = 0; + if (signatur && signatur.dataUrl) { + try { + const props = doc.getImageProperties(signatur.dataUrl); + const maxW = 48 * S, maxH = 20 * S; + sigW = maxW; + sigH = sigW * props.height / props.width; + if (sigH > maxH) { sigH = maxH; sigW = sigH * props.width / props.height; } + sigOk = props.width > 0 && props.height > 0; + } catch (e) { sigOk = false; } + } + if (sigOk) { + cur.y += 3 * S; + if (!dry) doc.addImage(signatur.dataUrl, signatur.format || 'PNG', x, cur.y, sigW, sigH); + 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 }); + } // --- Enclosures --- if (anlagen && anlagen.length) { @@ -748,8 +781,8 @@ function renderLebenslaufPdf(cv, header) { ); } -function renderAnschreibenPdf(letter, header, job, anlagen) { - return renderSingleColumn((doc, S, dry, cur) => composeLetter(doc, S, dry, cur, { letter, header, job, anlagen })); +function renderAnschreibenPdf(letter, header, job, anlagen, signatur) { + return renderSingleColumn((doc, S, dry, cur) => composeLetter(doc, S, dry, cur, { letter, header, job, anlagen, signatur })); } // =========================================================================== @@ -762,7 +795,7 @@ function hasLebenslauf(l) { || l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length); } -async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '' }) { +async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null }) { const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen }); const headline = data.headline || job.stelle || ''; const header = buildHeader(settings, data.kontakt, headline); @@ -784,7 +817,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), + buffer: renderAnschreibenPdf(data.anschreiben, header, job, anlagen, signatur), }); } diff --git a/server.js b/server.js index d8fb7c1..db413e4 100644 --- a/server.js +++ b/server.js @@ -95,6 +95,48 @@ const uploadBasisAnhang = multer({ limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB }).single('datei'); +// Directory + uploader for the applicant's signature image (used in the letter) +const signaturDir = path.join(dataDir, 'signatur'); +if (!fs.existsSync(signaturDir)) { + fs.mkdirSync(signaturDir, { recursive: true }); +} +const uploadSignatur = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => cb(null, signaturDir), + filename: (req, file, cb) => { + const ext = (path.extname(file.originalname) || '.png').toLowerCase(); + cb(null, `signatur_${Date.now()}${ext}`); + }, + }), + limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB + fileFilter: (req, file, cb) => cb(null, /^image\/(png|jpe?g)$/.test(file.mimetype)), +}).single('signatur'); + +// The single stored signature file, if any. +function currentSignaturFile() { + try { + const files = fs.readdirSync(signaturDir).filter((f) => !f.startsWith('.')); + return files.length ? path.join(signaturDir, files[0]) : null; + } catch (e) { + return null; + } +} + +// Read the signature as a data URL + jsPDF format, for embedding in the letter. +function loadSignatur() { + const file = currentSignaturFile(); + if (!file) return null; + try { + const ext = path.extname(file).toLowerCase(); + const format = (ext === '.jpg' || ext === '.jpeg') ? 'JPEG' : 'PNG'; + const mime = format === 'JPEG' ? 'image/jpeg' : 'image/png'; + const b64 = fs.readFileSync(file).toString('base64'); + return { dataUrl: `data:${mime};base64,${b64}`, format }; + } catch (e) { + return null; + } +} + // Database setup const dbPath = path.join(dataDir, 'bewerbungen.db'); const db = new sqlite3.Database(dbPath); @@ -184,6 +226,8 @@ async function runGeneration(bewerbungId) { zusatzAnlagen: basisAnhaenge.map((a) => a.name || a.dateiname), // Free-text notes (company address, contact person, extra context) for the LLM. llmNotizen: bewerbung.llm_notizen || '', + // Signature image placed under the closing salutation (instead of the typed name). + signatur: loadSignatur(), }); let seq = 0; @@ -749,6 +793,7 @@ initializeDatabase().then(() => { res.render('vorlagen', { basisDokumente, basisAnhaenge, + hasSignatur: Boolean(currentSignaturFile()), basisTypOptions: BASIS_TYP_OPTIONS, hasApiKey: Boolean(process.env.OLLAMA_API_KEY), hideSettings: true, @@ -860,6 +905,42 @@ initializeDatabase().then(() => { } }); + // ----- Signature (Unterschrift) ----- + + // Serve the current signature image (for the preview on the Vorlagen page) + app.get('/unterschrift', (req, res) => { + const file = currentSignaturFile(); + if (!file) return res.status(404).send('Keine Unterschrift'); + res.sendFile(file); + }); + + // Upload / replace the signature + app.post('/unterschrift', (req, res) => { + uploadSignatur(req, res, (err) => { + try { + if (err) console.error('Signature upload error:', err.message); + if (req.file) { + // keep only the newly uploaded file + fs.readdirSync(signaturDir).forEach((f) => { + if (f !== req.file.filename) fs.promises.unlink(path.join(signaturDir, f)).catch(() => {}); + }); + } + res.redirect('/vorlagen'); + } catch (error) { + console.error('Error saving signature:', error); + res.status(500).send('Serverfehler'); + } + }); + }); + + // Delete the signature + app.post('/unterschrift/delete', (req, res) => { + try { + fs.readdirSync(signaturDir).forEach((f) => fs.promises.unlink(path.join(signaturDir, f)).catch(() => {})); + } catch (e) { /* ignore */ } + res.redirect('/vorlagen'); + }); + // Download a generated attachment app.get('/anhaenge/:id/download', async (req, res) => { try { diff --git a/views/vorlagen.ejs b/views/vorlagen.ejs index 65c0540..54c6180 100644 --- a/views/vorlagen.ejs +++ b/views/vorlagen.ejs @@ -183,6 +183,40 @@ + + +

Unterschrift

+

+ Lade dein Unterschriftsbild (PNG/JPG, am besten mit transparentem Hintergrund) hoch. Es wird im + Anschreiben direkt unter „Mit freundlichen Grüßen" anstelle des getippten Namens eingefügt. +

+ +
+ <% if (hasSignatur) { %> +
+
+ Unterschrift +
+
+ +
+
+ <% } else { %> +

Noch keine Unterschrift hinterlegt.

+ <% } %> + +
+
+ + +
+ +
+
<%- include('partials/footer') %>