diff --git a/lib/documents.js b/lib/documents.js index dd63046..a82fc1f 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -7,6 +7,24 @@ // The text stays grounded in the user's own base documents; nothing is invented. const { jsPDF } = require('jspdf'); +const fs = require('fs'); +const path = require('path'); + +// 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 +// once at module load and registered per jsPDF instance. +const LATO_REGULAR_B64 = fs.readFileSync(path.join(__dirname, 'fonts', 'Lato-Regular.ttf')).toString('base64'); +const LATO_BOLD_B64 = fs.readFileSync(path.join(__dirname, 'fonts', 'Lato-Bold.ttf')).toString('base64'); + +function makeDoc() { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }); + doc.addFileToVFS('Lato-Regular.ttf', LATO_REGULAR_B64); + doc.addFileToVFS('Lato-Bold.ttf', LATO_BOLD_B64); + doc.addFont('Lato-Regular.ttf', 'Lato', 'normal'); + doc.addFont('Lato-Bold.ttf', 'Lato', 'bold'); + doc.setFont('Lato'); + return doc; +} // Ollama Cloud API (https://ollama.com). Override host/model via env if needed. const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, ''); @@ -40,8 +58,9 @@ const OUTPUT_SCHEMA = { telefon: { type: 'string' }, ort: { type: 'string' }, webseite: { type: 'string' }, + geburtsdatum: { type: 'string' }, }, - required: ['email', 'telefon', 'ort', 'webseite'], + required: ['email', 'telefon', 'ort', 'webseite', 'geburtsdatum'], }, anschreiben: { type: 'object', @@ -147,7 +166,7 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen const skeleton = `{\n` + ` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)",\n` + - ` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "" },\n` + + ` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "" },\n` + ` "anschreiben": {\n` + ` "empfaenger": { "firma": "Unternehmen", "adresse": "Straße Hausnr. (falls bekannt, sonst leer)", "ort": "PLZ Ort", "ansprechpartner": "" },\n` + ` "betreff": "Bewerbung als …",\n` + @@ -156,15 +175,15 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen ` "gruss": "Mit freundlichen Grüßen"\n` + ` },\n` + ` "lebenslauf": {\n` + - ` "profil": "2–3 Sätze Kurzprofil, zugeschnitten auf die Zielstelle",\n` + + ` "profil": "2-3 Sätze Kurzprofil, zugeschnitten auf die Zielstelle",\n` + ` "berufserfahrung": [\n` + - ` { "zeitraum": "02/2025 – heute", "titel": "Jobtitel", "firma": "Arbeitgeber, Ort", "punkte": ["Aufgabe/Erfolg 1", "Aufgabe/Erfolg 2"] }\n` + + ` { "zeitraum": "02/2025 - heute", "titel": "Jobtitel", "firma": "Arbeitgeber, Ort", "punkte": ["Aufgabe/Erfolg 1", "Aufgabe/Erfolg 2"] }\n` + ` ],\n` + ` "studium": [\n` + - ` { "zeitraum": "10/2010 – 03/2017", "abschluss": "Studiengang", "institution": "Hochschule", "zusatz": "" }\n` + + ` { "zeitraum": "10/2010 - 03/2017", "abschluss": "Studiengang", "institution": "Hochschule", "zusatz": "" }\n` + ` ],\n` + ` "berufsausbildung": [\n` + - ` { "zeitraum": "08/2006 – 07/2009", "abschluss": "Ausbildungsberuf / Abschluss", "institution": "Betrieb/Berufskolleg", "zusatz": "" }\n` + + ` { "zeitraum": "08/2006 - 07/2009", "abschluss": "Ausbildungsberuf / Abschluss", "institution": "Betrieb/Berufskolleg", "zusatz": "" }\n` + ` ],\n` + ` "weiterbildungen": [],\n` + ` "kenntnisse": ["Fähigkeit 1", "Fähigkeit 2"],\n` + @@ -193,9 +212,10 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen `${skeleton}\n\n` + `Vorgaben (deutscher Lebenslauf nach aktueller Norm/Best Practice):\n` + `- headline: kurze Berufsbezeichnung des Bewerbers, ggf. auf die Zielstelle zugeschnitten.\n` + - `- kontakt: E-Mail, Telefon, Ort, Webseite NUR übernehmen, wenn in den Basis-Unterlagen ` + - `vorhanden; sonst leerer String. Nichts erfinden. Keine persönlichen Angaben (Geburtsdatum, ` + - `Führerschein, Familienstand, Nationalität o. Ä.) — diese Felder bleiben leer.\n` + + `- kontakt: E-Mail, Telefon, Ort, Webseite, Geburtsdatum NUR übernehmen, wenn in den ` + + `Basis-Unterlagen vorhanden; sonst leerer String. Nichts erfinden. Geburtsdatum als ` + + `DD.MM.YYYY (z. B. "03.04.1990"). Keine anderen persönlichen Angaben (Führerschein, ` + + `Familienstand, Nationalität o. Ä.) - diese Felder bleiben leer.\n` + `- anschreiben.empfaenger: Empfängerblock. firma = Unternehmen; adresse = Straße + Hausnr. ` + `(nur falls aus Stellenanzeige oder Notizen bekannt, sonst leer); ort = "PLZ Ort"; ` + `ansprechpartner = konkrete Kontaktperson (nur falls bekannt, sonst leer). Nutze eine in den ` + @@ -241,7 +261,10 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen `- Keine negativen/entwertenden Vermerke: NIEMALS "nicht beendet", "abgebrochen", "ohne Abschluss", ` + `"arbeitslos", "arbeitssuchend", "gekündigt" o. Ä. Lasse "zusatz" in solchen Fällen leer und nenne nur ` + `Zeitraum, Bezeichnung und Institution neutral.\n` + - `- Sachlich, positiv, konsistente Datumsformate (MM/YYYY), z. B. "02/2025 – heute".\n\n` + + `- Sachlich, positiv, konsistente Datumsformate (MM/YYYY), z. B. "02/2025 - heute".\n` + + `- Trennzeichen: in ALLEN Texten (Anschreiben UND Lebenslauf) ausschließlich den einfachen Bindestrich ` + + `"-" verwenden - niemals den Halbgeviertstrich (–) oder Geviertstrich (—). Gilt auch für Datumsangaben, ` + + `Wertebereiche (z. B. "2-3 Jahre") und Aufzählungen.\n\n` + `Leere Felder als leerer String bzw. leeres Array. Antworte AUSSCHLIESSLICH mit dem ` + `JSON-Objekt, ohne Markdown, ohne Code-Fences, ohne weiteren Text.`; @@ -301,7 +324,10 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen // ----- tolerant normalisation ---------------------------------------------- // Models honour the schema keys only loosely, so accept common German synonyms. -const str = (v) => (typeof v === 'string' ? v.trim() : (v == null ? '' : String(v).trim())); +// Normalize one content string: trim, and force a plain hyphen "-" instead of +// en/em dash ("–"/"—") everywhere — applies to every field of both documents +// so the requirement holds even if the model still emits dashes. +const str = (v) => (typeof v === 'string' ? v.trim() : (v == null ? '' : String(v).trim())).replace(/[–—]/g, '-'); function pick(obj, keys) { if (!obj) return ''; @@ -327,9 +353,10 @@ function toZeitraum(v) { else if (typeof v === 'object') { const von = str(pick(v, ['von', 'from', 'start', 'beginn'])); const bis = str(pick(v, ['bis', 'to', 'ende', 'end'])); - out = [von, bis].filter(Boolean).join(' – '); + out = [von, bis].filter(Boolean).join(' - '); } else out = str(v); - return normalizeDate(out); + // Use a plain hyphen "-" (never en/em dash "–"/"—") for the date range. + return normalizeDate(out).replace(/[–—]/g, '-'); } // After an Anrede ending in a comma, the body continues the sentence, so its @@ -473,7 +500,7 @@ function write(doc, S, dry, cur, text, o) { const pt = o.pt; const factor = o.factor == null ? 1.3 : o.factor; if (text == null || text === '') return; - doc.setFont('helvetica', o.style || 'normal'); + doc.setFont('Lato', o.style || 'normal'); doc.setFontSize(pt * S); if (o.charSpace) doc.setCharSpace(o.charSpace * S); const content = o.upper ? String(text).toUpperCase() : String(text); @@ -515,7 +542,7 @@ const RC = { function cvHeading(doc, S, dry, cur, title) { const pt = 11; cur.y += 3 * S; - doc.setFont('helvetica', 'bold'); + doc.setFont('Lato', 'bold'); doc.setFontSize(pt * S); if (!dry) { doc.setCharSpace(0.6 * S); @@ -537,7 +564,7 @@ function cvRow(doc, S, dry, cur, leftText, rightText, leftPt, leftStyle) { leftStyle = leftStyle || 'bold'; const rightPt = 9.5; const leftW = RV_W - RV_DATE_W; - doc.setFont('helvetica', leftStyle); + doc.setFont('Lato', leftStyle); doc.setFontSize(leftPt * S); const lines = doc.splitTextToSize(String(leftText || ''), leftW); const lineH = leftPt * S * PT * 1.18; @@ -545,7 +572,7 @@ function cvRow(doc, S, dry, cur, leftText, rightText, leftPt, leftStyle) { doc.setTextColor(RC.ink[0], RC.ink[1], RC.ink[2]); lines.forEach((ln, i) => doc.text(ln, RV.mx, cur.y + i * lineH + leftPt * S * PT * 0.76)); if (rightText) { - doc.setFont('helvetica', 'normal'); + doc.setFont('Lato', 'normal'); doc.setFontSize(rightPt * S); doc.setTextColor(RC.sub[0], RC.sub[1], RC.sub[2]); doc.text(String(rightText), RV_R, cur.y + rightPt * S * PT * 0.76, { align: 'right' }); @@ -558,7 +585,7 @@ function cvBullets(doc, S, dry, cur, items, x, w, pt = 9.3) { const tx = x + 3.6 * S; const tw = w - 3.6 * S; for (const it of items) { - doc.setFont('helvetica', 'normal'); + doc.setFont('Lato', 'normal'); doc.setFontSize(pt * S); const lines = doc.splitTextToSize(String(it), tw); lines.forEach((ln, i) => { @@ -606,6 +633,7 @@ function composeCvHeader(doc, S, dry, cur, header, titel) { const leftW = RV_W * 0.62; write(doc, S, dry, cur, header.name, { pt: 22, style: 'bold', color: RC.ink, x: RV.mx, width: leftW, factor: 1.05 }); if (titel) write(doc, S, dry, cur, titel, { pt: 12, color: RC.sub, x: RV.mx, width: leftW, factor: 1.15 }); + if (header.geburtsdatum) write(doc, S, dry, cur, `Geburtsdatum: ${header.geburtsdatum}`, { pt: 9, color: RC.sub, x: RV.mx, width: leftW, factor: 1.3 }); const leftBottom = cur.y; const contactLines = [header.email, header.telefon, cityName(header) || header.ort, header.webseite].filter(Boolean); @@ -647,7 +675,7 @@ function composeCV(doc, S, dry, cur, { cv, header, titel }) { } function renderCV(cv, header, titel) { - const doc = new jsPDF({ unit: 'mm', format: 'a4' }); + const doc = makeDoc(); const m = { y: RV.top }; composeCV(doc, 1, true, m, { cv, header, titel }); const need = m.y - RV.top; const avail = PAGE.h - RV.top - RV.bottom; @@ -767,7 +795,7 @@ function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen, signatu } function renderSingleColumn(compose) { - const doc = new jsPDF({ unit: 'mm', format: 'a4' }); + const doc = makeDoc(); const m = { y: LET.top }; compose(doc, 1, true, m); const need = m.y - LET.top; const avail = PAGE.h - LET.top - LET.bottom; @@ -824,7 +852,7 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus if (hasAnschreiben(data.anschreiben)) { documents.push({ - name: `Anschreiben – ${label}`.trim(), + name: `Anschreiben - ${label}`.trim(), filename: `Anschreiben_${suffix}.pdf`, mime: 'application/pdf', buffer: renderAnschreibenPdf(data.anschreiben, header, job, anlagen, signatur), @@ -833,7 +861,7 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus if (cvPresent) { documents.push({ - name: `Lebenslauf – ${label}`.trim(), + name: `Lebenslauf - ${label}`.trim(), filename: `Lebenslauf_${suffix}.pdf`, mime: 'application/pdf', buffer: renderLebenslaufPdf(data.lebenslauf, header), diff --git a/lib/fonts/Lato-Bold.ttf b/lib/fonts/Lato-Bold.ttf new file mode 100644 index 0000000..1d23c70 Binary files /dev/null and b/lib/fonts/Lato-Bold.ttf differ diff --git a/lib/fonts/Lato-Regular.ttf b/lib/fonts/Lato-Regular.ttf new file mode 100644 index 0000000..0f3d0f8 Binary files /dev/null and b/lib/fonts/Lato-Regular.ttf differ diff --git a/lib/fonts/OFL.txt b/lib/fonts/OFL.txt new file mode 100644 index 0000000..98383e3 --- /dev/null +++ b/lib/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE.