mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:36:38 +02:00
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:
parent
c333cbfa4b
commit
a37942b37d
3 changed files with 131 additions and 79 deletions
25
Summarize.md
25
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<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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue