From a37942b37d7893cb89d12cc9defff38515f5e700 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Fri, 13 Mar 2026 21:20:43 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20PDF-Export=20=E2=80=94=20Dateiname,=20Fe?= =?UTF-8?q?ttschrift,=20Zeichen,=20Abstaende,=20Zertifizierungen=20rechts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 Korrekturen am Experten-Profil PDF-Export: 1. Dateiname: Vorname_Nachname_CV.pdf/.docx (RFC 5987 Umlaut-sicher) 2. Fettschrift: Zeitraum, Firmenname und Branche in Berufserfahrung 3. Zeichen-Darstellung: Markdown-Marker (**bold**/*italic*/__u__) werden aus Tasks-Text entfernt, doppelte Bullet-Praefix-Normalisierung 4. Abstaende: Sprachen 14->11px, Erfahrung 14->11px, Gap 8->4px 5. Zertifizierungen in rechte Spalte verschoben (nach Berufserfahrung) statt als Vollbreite-Sektion am Ende Co-Authored-By: Claude Sonnet 4.6 --- Summarize.md | 25 +++ .../expert-profile.controller.ts | 12 +- .../expert-profile/profile-export.service.ts | 173 ++++++++++-------- 3 files changed, 131 insertions(+), 79 deletions(-) diff --git a/Summarize.md b/Summarize.md index f80ef0e..04a63bf 100644 --- a/Summarize.md +++ b/Summarize.md @@ -6,6 +6,31 @@ --- +### Aenderungen 2026-03-13 (17): Experten-Profil PDF-Export — 5 Korrekturen + +#### Backend (Core Service) +- `expert-profile/profile-export.service.ts` — PDF-Export verbessert: + - Neue Helper-Methoden `stripMarkdown()` und `normalizeTaskLine()` + - Zeitraum fett (Helvetica-Bold, #555555) + - Firmenname + Branche fett (Helvetica-Bold, #333333) + - Tasks: Markdown-Marker (**bold**, *italic*, __u__) werden vor Ausgabe entfernt + - Sprachen/Erfahrung: Abstand 14px → 11px, Sektion-Gap 8px → 4px + - Zertifizierungen in rechte Spalte verschoben (nach Berufserfahrung) + - Return-Typ: `Promise<{ buffer, firstName, lastName }>` (war: `Promise`) +- `expert-profile/profile-export.service.ts` — DOCX analog angepasst: + - Zeitraum + Firma fett; normalizeTaskLine() fuer Tasks; Zertifizierungen in rightParagraphs +- `expert-profile/expert-profile.controller.ts`: + - PDF: `Vorname_Nachname_CV.pdf`, DOCX: `Vorname_Nachname_CV.docx` + - RFC-5987 `filename*=UTF-8''...` fuer Umlaut-Dateinamen + +#### TypeScript +- `npx tsc --noEmit` in packages/core-service: 0 Fehler + +#### Deployment-Hinweis (Schritt 17) +- Rebuild + Restart: nur core-service + +--- + ### Aenderungen 2026-03-13 (16): Global Admin — Login-Screen-Branding (Hintergrund + Logo) #### Backend (Core Service) diff --git a/packages/core-service/src/core/expert-profile/expert-profile.controller.ts b/packages/core-service/src/core/expert-profile/expert-profile.controller.ts index 5e6c5e3..77771e3 100644 --- a/packages/core-service/src/core/expert-profile/expert-profile.controller.ts +++ b/packages/core-service/src/core/expert-profile/expert-profile.controller.ts @@ -172,10 +172,12 @@ export class ExpertProfileController { @CurrentUser('sub') userId: string, @Res() res: Response, ) { - const buffer = await this.profileExportService.generatePdf(userId); + const { buffer, firstName, lastName } = await this.profileExportService.generatePdf(userId); + const baseName = `${firstName}_${lastName}_CV`.replace(/\s+/g, '_'); + const encodedName = encodeURIComponent(`${baseName}.pdf`); res.set({ 'Content-Type': 'application/pdf', - 'Content-Disposition': 'attachment; filename="Profil.pdf"', + 'Content-Disposition': `attachment; filename="${baseName}.pdf"; filename*=UTF-8''${encodedName}`, 'Content-Length': String(buffer.length), }); res.end(buffer); @@ -187,10 +189,12 @@ export class ExpertProfileController { @CurrentUser('sub') userId: string, @Res() res: Response, ) { - const buffer = await this.profileExportService.generateDocx(userId); + const { buffer, firstName, lastName } = await this.profileExportService.generateDocx(userId); + const baseName = `${firstName}_${lastName}_CV`.replace(/\s+/g, '_'); + const encodedName = encodeURIComponent(`${baseName}.docx`); res.set({ 'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'Content-Disposition': 'attachment; filename="Profil.docx"', + 'Content-Disposition': `attachment; filename="${baseName}.docx"; filename*=UTF-8''${encodedName}`, 'Content-Length': String(buffer.length), }); res.end(buffer); diff --git a/packages/core-service/src/core/expert-profile/profile-export.service.ts b/packages/core-service/src/core/expert-profile/profile-export.service.ts index 899d6f3..76b1cf8 100644 --- a/packages/core-service/src/core/expert-profile/profile-export.service.ts +++ b/packages/core-service/src/core/expert-profile/profile-export.service.ts @@ -152,15 +152,36 @@ export class ProfileExportService { .toBuffer(); } + // ============================================================ + // Hilfsfunktionen: Text-Bereinigung + // ============================================================ + + /** Entfernt Markdown-Marker (**bold**, *italic*, __underline__) aus Text */ + private stripMarkdown(text: string): string { + return text + .replace(/\*\*([^*\n]+)\*\*/g, '$1') // **fett** → Text + .replace(/\*([^*\n]+)\*/g, '$1') // *kursiv* → Text + .replace(/__([^_\n]+)__/g, '$1'); // __unterstrichen__ → Text + } + + /** Normalisiert eine Aufgaben-Zeile: entfernt bestehende Bullet/Nummer-Präfixe */ + private normalizeTaskLine(line: string): string { + return this.stripMarkdown(line.trim()) + .replace(/^[•\u2022]\s*/, '') // vorhandenes Bullet entfernen + .replace(/^\d+\.\s+/, '') // vorhandene Nummerierung entfernen + .trim(); + } + // ============================================================ // PDF Export // ============================================================ - async generatePdf(userId: string, accentColor = '#009688'): Promise { + async generatePdf(userId: string, accentColor = '#009688'): Promise<{ buffer: Buffer; firstName: string; lastName: string }> { const data = await this.expertProfileService.getExportData(userId) as ExportData; const profile = data.expertProfile; const fullName = `${data.firstName} ${data.lastName}`; + const { firstName, lastName } = data; - return new Promise((resolve, reject) => { + return new Promise<{ buffer: Buffer; firstName: string; lastName: string }>((resolve, reject) => { const chunks: Buffer[] = []; const doc = new PDFDocument({ size: 'A4', @@ -169,7 +190,7 @@ export class ProfileExportService { }); doc.on('data', (chunk: Buffer) => chunks.push(chunk)); - doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('end', () => resolve({ buffer: Buffer.concat(chunks), firstName, lastName })); doc.on('error', reject); // --- Konstanten --- @@ -265,9 +286,9 @@ export class ProfileExportService { doc.text(lang.language, leftColX, yLeft, { width: leftColWidth * 0.55, continued: false }); doc.font('Helvetica').fontSize(8).fillColor('#777777'); doc.text(lang.level, leftColX + leftColWidth * 0.6, yLeft, { width: leftColWidth * 0.4 }); - yLeft += 14; + yLeft += 11; } - yLeft += 8; + yLeft += 4; } // --- ERFAHRUNG (Expertise-Bereiche) --- @@ -279,7 +300,7 @@ export class ProfileExportService { doc.font('Helvetica').fontSize(8).fillColor('#777777'); const expDetail = `${exp.years}J.${exp.level ? ' · ' + exp.level : ''}`; doc.text(expDetail, leftColX + leftColWidth * 0.65, yLeft, { width: leftColWidth * 0.35 }); - yLeft += 14; + yLeft += 11; } } @@ -310,9 +331,9 @@ export class ProfileExportService { .strokeColor(accentColor).lineWidth(1).stroke(); } - // Zeitraum + // Zeitraum (fett) const dateRange = this.formatDateRange(proj.fromMonth, proj.fromYear, proj.toMonth, proj.toYear, proj.isCurrent); - doc.font('Helvetica').fontSize(8).fillColor('#888888'); + doc.font('Helvetica-Bold').fontSize(8).fillColor('#555555'); doc.text(dateRange, contentX, yRight, { width: contentWidth }); yRight += 12; @@ -321,15 +342,15 @@ export class ProfileExportService { doc.text(proj.role, contentX, yRight, { width: contentWidth }); yRight += doc.heightOfString(proj.role, { width: contentWidth }) + 2; - // Firma + Branche + // Firma + Branche (fett) if (proj.company) { const companyLine = [proj.company, proj.industry].filter(Boolean).join(' · '); - doc.font('Helvetica').fontSize(9).fillColor('#555555'); + doc.font('Helvetica-Bold').fontSize(9).fillColor('#333333'); doc.text(companyLine, contentX, yRight, { width: contentWidth }); yRight += doc.heightOfString(companyLine, { width: contentWidth }) + 2; } - // Aufgaben + // Aufgaben (Markdown-Marker entfernen) if (proj.tasks) { const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim()); doc.font('Helvetica').fontSize(8).fillColor('#444444'); @@ -338,7 +359,9 @@ export class ProfileExportService { doc.addPage(); yRight = 40; } - const bulletText = `\u2022 ${task.trim()}`; + const normalized = this.normalizeTaskLine(task); + if (!normalized) continue; + const bulletText = `\u2022 ${normalized}`; doc.text(bulletText, contentX + 4, yRight, { width: contentWidth - 8 }); yRight += doc.heightOfString(bulletText, { width: contentWidth - 8 }) + 1; } @@ -350,55 +373,53 @@ export class ProfileExportService { } } - // --- FOLGESEITEN: ZERTIFIZIERUNGEN --- + // --- ZERTIFIZIERUNGEN (rechte Spalte) --- if (profile && profile.certifications.length > 0) { - const certY = Math.max(yLeft, yRight); - let y = certY > pageBottom - 80 ? 40 : certY + 20; - if (certY > pageBottom - 80) { + if (yRight > pageBottom - 80) { doc.addPage(); + yRight = 40; + } else { + yRight += 15; } - y = this.pdfSectionTitle(doc, 'ZERTIFIZIERUNGEN', 40, y, pageWidth - 80, accentColor); + yRight = this.pdfSectionTitle(doc, 'ZERTIFIZIERUNGEN', rightColX, yRight, rightColWidth, accentColor); - const timelineX = 46; - const contentX = 58; - const contentWidth = pageWidth - 58 - 40; + const certTimelineX = rightColX + 6; + const certContentX = rightColX + 18; + const certContentWidth = rightColWidth - 18; for (let i = 0; i < profile.certifications.length; i++) { const cert = profile.certifications[i]; - if (y > pageBottom) { + if (yRight > pageBottom) { doc.addPage(); - y = 40; + yRight = 40; } // Timeline-Punkt - doc.circle(timelineX, y + 4, 3.5).fill(accentColor); + doc.circle(certTimelineX, yRight + 4, 3.5).fill(accentColor); if (i < profile.certifications.length - 1) { - doc.moveTo(timelineX, y + 8).lineTo(timelineX, y + 40) + doc.moveTo(certTimelineX, yRight + 8).lineTo(certTimelineX, yRight + 40) .strokeColor(accentColor).lineWidth(1).stroke(); } - // Jahr - doc.font('Helvetica').fontSize(8).fillColor('#888888'); - doc.text(String(cert.issueYear), contentX, y, { width: contentWidth }); - y += 12; + // Jahr (fett) + doc.font('Helvetica-Bold').fontSize(8).fillColor('#555555'); + doc.text(String(cert.issueYear), certContentX, yRight, { width: certContentWidth }); + yRight += 12; // Titel doc.font('Helvetica-Bold').fontSize(10).fillColor(accentColor); - doc.text(cert.title, contentX, y, { width: contentWidth }); - y += doc.heightOfString(cert.title, { width: contentWidth }) + 2; + doc.text(cert.title, certContentX, yRight, { width: certContentWidth }); + yRight += doc.heightOfString(cert.title, { width: certContentWidth }) + 2; // Zertifizierungsstelle doc.font('Helvetica').fontSize(9).fillColor('#555555'); - doc.text(cert.issuingBody, contentX, y, { width: contentWidth }); - y += 14; + doc.text(cert.issuingBody, certContentX, yRight, { width: certContentWidth }); + yRight += 14; - y += 8; + yRight += 6; } - - yRight = y; - yLeft = y; } // --- FÄHIGKEITEN (Skills als Chips) --- @@ -453,7 +474,7 @@ export class ProfileExportService { // ============================================================ // DOCX Export // ============================================================ - async generateDocx(userId: string, accentColor = '#009688'): Promise { + async generateDocx(userId: string, accentColor = '#009688'): Promise<{ buffer: Buffer; firstName: string; lastName: string }> { const data = await this.expertProfileService.getExportData(userId) as ExportData; const profile = data.expertProfile; const fullName = `${data.firstName} ${data.lastName}`; @@ -618,7 +639,7 @@ export class ProfileExportService { rightParagraphs.push( new Paragraph({ children: [ - new TextRun({ text: dateRange, size: 16, color: '888888', italics: true }), + new TextRun({ text: dateRange, size: 16, color: '555555', bold: true }), ], spacing: { before: 160, after: 40 }, }), @@ -638,7 +659,7 @@ export class ProfileExportService { rightParagraphs.push( new Paragraph({ children: [ - new TextRun({ text: companyLine, size: 18, color: '555555' }), + new TextRun({ text: companyLine, size: 18, color: '333333', bold: true }), ], spacing: { after: 40 }, }), @@ -648,10 +669,12 @@ export class ProfileExportService { if (proj.tasks) { const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim()); for (const task of taskLines) { + const normalized = this.normalizeTaskLine(task); + if (!normalized) continue; rightParagraphs.push( new Paragraph({ children: [ - new TextRun({ text: `\u2022 ${task.trim()}`, size: 16, color: '444444' }), + new TextRun({ text: `\u2022 ${normalized}`, size: 16, color: '444444' }), ], spacing: { after: 20 }, }), @@ -661,6 +684,40 @@ export class ProfileExportService { } } + // --- Zertifizierungen (rechte Spalte) --- + if (profile && profile.certifications.length > 0) { + rightParagraphs.push(this.docxSectionHeading('ZERTIFIZIERUNGEN', accentHex)); + + for (const cert of profile.certifications) { + rightParagraphs.push( + new Paragraph({ + children: [ + new TextRun({ text: String(cert.issueYear), size: 16, color: '555555', bold: true }), + ], + spacing: { before: 120, after: 30 }, + }), + ); + + rightParagraphs.push( + new Paragraph({ + children: [ + new TextRun({ text: cert.title, bold: true, size: 20, color: accentHex }), + ], + spacing: { after: 20 }, + }), + ); + + rightParagraphs.push( + new Paragraph({ + children: [ + new TextRun({ text: cert.issuingBody, size: 18, color: '555555' }), + ], + spacing: { after: 60 }, + }), + ); + } + } + // --- Tabelle (Zwei-Spalten-Layout) --- const noBorders = { top: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' }, @@ -689,40 +746,6 @@ export class ProfileExportService { width: { size: 100, type: WidthType.PERCENTAGE }, }); - // --- Volle Breite: Zertifizierungen --- - if (profile && profile.certifications.length > 0) { - sections.push(this.docxSectionHeading('ZERTIFIZIERUNGEN', accentHex)); - - for (const cert of profile.certifications) { - sections.push( - new Paragraph({ - children: [ - new TextRun({ text: String(cert.issueYear), size: 16, color: '888888', italics: true }), - ], - spacing: { before: 120, after: 30 }, - }), - ); - - sections.push( - new Paragraph({ - children: [ - new TextRun({ text: cert.title, bold: true, size: 20, color: accentHex }), - ], - spacing: { after: 20 }, - }), - ); - - sections.push( - new Paragraph({ - children: [ - new TextRun({ text: cert.issuingBody, size: 18, color: '555555' }), - ], - spacing: { after: 60 }, - }), - ); - } - } - // --- Volle Breite: Fähigkeiten (Skills als Chips) --- if (profile && profile.skills.length > 0) { sections.push(this.docxSectionHeading('FÄHIGKEITEN', accentHex)); @@ -776,7 +799,7 @@ export class ProfileExportService { const buffer = await Packer.toBuffer(document); this.logger.log(`DOCX generiert für User ${userId}: ${buffer.length} Bytes`); - return buffer; + return { buffer, firstName: data.firstName, lastName: data.lastName }; } // ============================================================