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 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 11:37:03 +02:00
co-authored by Claude Opus 4.8
parent cbdbbc07e3
commit 78128db422
3 changed files with 168 additions and 20 deletions
+52 -19
View File
@@ -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 ` + `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, ` + `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. ` + `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 ` + `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". ` + `- berufserfahrung: ALLE Stationen aus den Unterlagen, neueste zuerst. "firma" = "Arbeitgeber, Ort". ` +
`Nur die 23 jüngsten/relevantesten erhalten EINEN kurzen "beschreibung"-Satz, ältere lässt du leer.\n` + `Nur die 23 jüngsten/relevantesten erhalten EINEN kurzen "beschreibung"-Satz, ältere lässt du leer.\n` +
`- Bildung korrekt einordnen: Hochschulstudium → studium; Berufsausbildung/Ausbildungsberuf → ` + `- Bildung korrekt einordnen: Hochschulstudium → studium; Berufsausbildung/Ausbildungsberuf → ` +
@@ -322,19 +322,32 @@ const LOWER_OPENERS = new Set([
'meinen', 'meiner', 'meinem', 'meines', 'meinen', 'meiner', 'meinem', 'meines',
]); ]);
function fixAnredeContinuation(anrede, absaetze) { // Capitalize the first letter of a paragraph (every paragraph is a new sentence).
if (!absaetze.length || !anrede.trim().endsWith(',')) return absaetze; function capitalizeFirstLetter(p) {
const m = absaetze[0].match(/^(\s*)([A-Za-zÄÖÜäöüß]+)([\s\S]*)$/); return String(p).replace(/^(\s*)([a-zäöüß])/, (_, lead, ch) => lead + ch.toUpperCase());
if (!m) return absaetze; }
const [, lead, word, rest] = m;
// 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 first = word[0];
const isUpper = (first >= 'A' && first <= 'Z') || 'ÄÖÜ'.includes(first); const isUpper = (first >= 'A' && first <= 'Z') || 'ÄÖÜ'.includes(first);
if (isUpper && LOWER_OPENERS.has(word.toLowerCase())) { if (isUpper && LOWER_OPENERS.has(word.toLowerCase())) {
const out = absaetze.slice(); return m[1] + first.toLowerCase() + p.slice(m[1].length + 1);
out[0] = lead + first.toLowerCase() + word.slice(1) + rest;
return out;
} }
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 // 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'])), betreff: str(pick(a, ['betreff', 'subject', 'titel'])),
anrede, anrede,
absaetze: fixAnredeContinuation(anrede, absaetze), absaetze: fixParagraphCasing(anrede, absaetze),
gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])), 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(); 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 x = LET.mx;
const right = LET_R; const right = LET_R;
const width = LET_W; 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 }); 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; 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: LC.ink, x, width, factor: 1.3 });
cur.y += 13 * S; // room for a signature
// 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 }); write(doc, S, dry, cur, header.name, { pt: 10.5, style: 'bold', color: LC.ink, x, width, factor: 1.2 });
}
// --- Enclosures --- // --- Enclosures ---
if (anlagen && anlagen.length) { if (anlagen && anlagen.length) {
@@ -748,8 +781,8 @@ function renderLebenslaufPdf(cv, header) {
); );
} }
function renderAnschreibenPdf(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 })); 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); || 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 data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen });
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);
@@ -784,7 +817,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), buffer: renderAnschreibenPdf(data.anschreiben, header, job, anlagen, signatur),
}); });
} }
+81
View File
@@ -95,6 +95,48 @@ const uploadBasisAnhang = multer({
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
}).single('datei'); }).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 // Database setup
const dbPath = path.join(dataDir, 'bewerbungen.db'); const dbPath = path.join(dataDir, 'bewerbungen.db');
const db = new sqlite3.Database(dbPath); const db = new sqlite3.Database(dbPath);
@@ -184,6 +226,8 @@ async function runGeneration(bewerbungId) {
zusatzAnlagen: basisAnhaenge.map((a) => a.name || a.dateiname), zusatzAnlagen: basisAnhaenge.map((a) => a.name || a.dateiname),
// Free-text notes (company address, contact person, extra context) for the LLM. // Free-text notes (company address, contact person, extra context) for the LLM.
llmNotizen: bewerbung.llm_notizen || '', llmNotizen: bewerbung.llm_notizen || '',
// Signature image placed under the closing salutation (instead of the typed name).
signatur: loadSignatur(),
}); });
let seq = 0; let seq = 0;
@@ -749,6 +793,7 @@ initializeDatabase().then(() => {
res.render('vorlagen', { res.render('vorlagen', {
basisDokumente, basisDokumente,
basisAnhaenge, basisAnhaenge,
hasSignatur: Boolean(currentSignaturFile()),
basisTypOptions: BASIS_TYP_OPTIONS, basisTypOptions: BASIS_TYP_OPTIONS,
hasApiKey: Boolean(process.env.OLLAMA_API_KEY), hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
hideSettings: true, 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 // Download a generated attachment
app.get('/anhaenge/:id/download', async (req, res) => { app.get('/anhaenge/:id/download', async (req, res) => {
try { try {
+34
View File
@@ -183,6 +183,40 @@
</div> </div>
</form> </form>
</section> </section>
<!-- Signature -->
<h2 class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2">Unterschrift</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
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.
</p>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<% if (hasSignatur) { %>
<div class="flex items-center justify-between gap-4 mb-4">
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 p-3">
<img src="/unterschrift?t=<%= Date.now() %>" alt="Unterschrift" class="max-h-24 max-w-[240px] object-contain">
</div>
<form action="/unterschrift/delete" method="POST" onsubmit="return confirm('Unterschrift entfernen?');">
<button type="submit" class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 hover:underline">Entfernen</button>
</form>
</div>
<% } else { %>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Noch keine Unterschrift hinterlegt.</p>
<% } %>
<form action="/unterschrift" method="POST" enctype="multipart/form-data"
class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-2 flex flex-wrap items-end gap-3">
<div class="flex-1 min-w-[220px]">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Unterschrift-Bild (PNG/JPG)</label>
<input type="file" name="signatur" accept="image/png,image/jpeg" required
class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
</div>
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
<%= hasSignatur ? 'Ersetzen' : 'Hochladen' %>
</button>
</form>
</section>
</main> </main>
<%- include('partials/footer') %> <%- include('partials/footer') %>