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 ` +
|
||||
`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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user