fix: PDF-Export — Dateiname, Fettschrift, Zeichen, Abstaende, Zertifizierungen rechts

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 21:20:43 +01:00
parent c333cbfa4b
commit a37942b37d
3 changed files with 131 additions and 79 deletions

View file

@ -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<Buffer>`)
- `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)

View file

@ -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);

View file

@ -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<Buffer> {
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<Buffer>((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<Buffer> {
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 };
}
// ============================================================