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:
+53
-20
@@ -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 2–3 jüngsten/relevantesten erhalten EINEN kurzen "beschreibung"-Satz, ältere lässt du leer.\n` +
|
`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 → ` +
|
`- 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
|
|
||||||
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 ---
|
// --- 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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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') %>
|
||||||
|
|||||||
Reference in New Issue
Block a user