fix(core): PDF-Export — Bullets, Zertifizierungen, Bild-Anhaenge

- Tasks: Bullet-Praefix nur fuer Zeilen mit echtem Aufzaehlungszeichen (kein spurious Bullet bei Plaintext)
- Zertifizierungen: Schriftgroesse reduziert (Titel 10->9pt, Aussteller 9->8pt) und Timeline-Linie gekuerzt
- Anhaenge: Bild-Anhaenge werden als zusaetzliche Seiten ans PDF angehaengt
- ExportData-Interface + getExportData() um attachments[] erweitert
- Gleiche Bullet-Fix-Logik im DOCX-Export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 21:33:01 +01:00
parent a37942b37d
commit 3d486e0541
3 changed files with 70 additions and 15 deletions

View file

@ -6,6 +6,27 @@
--- ---
### Aenderungen 2026-03-13 (18): Experten-Profil PDF-Export — 3 weitere Korrekturen
#### Backend (Core Service)
- `expert-profile/expert-profile.service.ts``getExportData()`: Anhänge jetzt inkludiert (`attachments: { orderBy: { createdAt: 'asc' } }`)
- `expert-profile/profile-export.service.ts` — PDF-Export:
- Neues `ExportAttachment` Interface + `attachments[]` in `ExportData`
- Tasks: Bullet `•` wird nur noch hinzugefügt, wenn Original-Zeile bereits ein Aufzählungszeichen (`•`, `\u2022`) oder Nummerierung (`1.`) enthält — kein spurious Bullet für Plaintext-Zeilen
- Zertifizierungen: Schriftgröße reduziert (Titel 10pt→9pt, Aussteller 9pt→8pt, Abstände angepasst) — passt jetzt zur linken Spalte
- Zertifizierungen: Timeline-Linienlänge 40→32px
- Anhänge: Bild-Anhänge (image/jpeg, image/png, etc.) werden als zusätzliche Seiten ans PDF angehängt (Überschrift "ANLAGE: dateiname" + Bild; PDFs + andere Formate werden übersprungen)
- `expert-profile/profile-export.service.ts` — DOCX-Export:
- Tasks: Gleiche Bullet-Fix-Logik (nur Bullet wenn Original-Zeile Bullet/Nummer hat)
#### TypeScript
- `npx tsc --noEmit` in packages/core-service: 0 Fehler
#### Deployment-Hinweis (Schritt 18)
- Rebuild + Restart: nur core-service
---
### Aenderungen 2026-03-13 (17): Experten-Profil PDF-Export — 5 Korrekturen ### Aenderungen 2026-03-13 (17): Experten-Profil PDF-Export — 5 Korrekturen
#### Backend (Core Service) #### Backend (Core Service)

View file

@ -316,6 +316,7 @@ export class ExpertProfileService {
languages: { orderBy: { language: 'asc' } }, languages: { orderBy: { language: 'asc' } },
projects: { orderBy: [{ fromYear: 'desc' }, { fromMonth: 'desc' }] }, projects: { orderBy: [{ fromYear: 'desc' }, { fromMonth: 'desc' }] },
certifications: { orderBy: { issueYear: 'desc' } }, certifications: { orderBy: { issueYear: 'desc' } },
attachments: { orderBy: { createdAt: 'asc' } },
}, },
}, },
}, },

View file

@ -55,6 +55,14 @@ interface ExportLanguage {
level: string; level: string;
} }
interface ExportAttachment {
id: string;
filename: string;
mimetype: string;
size: number;
data: string;
}
interface ExportData { interface ExportData {
firstName: string; firstName: string;
lastName: string; lastName: string;
@ -71,6 +79,7 @@ interface ExportData {
languages: ExportLanguage[]; languages: ExportLanguage[];
projects: ExportProject[]; projects: ExportProject[];
certifications: ExportCertification[]; certifications: ExportCertification[];
attachments: ExportAttachment[];
} | null; } | null;
} }
@ -350,7 +359,7 @@ export class ProfileExportService {
yRight += doc.heightOfString(companyLine, { width: contentWidth }) + 2; yRight += doc.heightOfString(companyLine, { width: contentWidth }) + 2;
} }
// Aufgaben (Markdown-Marker entfernen) // Aufgaben (Bullet nur wenn Original-Zeile ein Aufzählungszeichen hat)
if (proj.tasks) { if (proj.tasks) {
const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim()); const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim());
doc.font('Helvetica').fontSize(8).fillColor('#444444'); doc.font('Helvetica').fontSize(8).fillColor('#444444');
@ -359,11 +368,13 @@ export class ProfileExportService {
doc.addPage(); doc.addPage();
yRight = 40; yRight = 40;
} }
const normalized = this.normalizeTaskLine(task); const raw = task.trim();
if (!normalized) continue; const hasBullet = /^[•\u2022]\s/.test(raw) || /^\d+\.\s/.test(raw);
const bulletText = `\u2022 ${normalized}`; const cleaned = this.normalizeTaskLine(raw);
doc.text(bulletText, contentX + 4, yRight, { width: contentWidth - 8 }); if (!cleaned) continue;
yRight += doc.heightOfString(bulletText, { width: contentWidth - 8 }) + 1; const displayText = hasBullet ? `\u2022 ${cleaned}` : cleaned;
doc.text(displayText, contentX + 4, yRight, { width: contentWidth - 8 });
yRight += doc.heightOfString(displayText, { width: contentWidth - 8 }) + 1;
} }
} }
@ -399,26 +410,26 @@ export class ProfileExportService {
// Timeline-Punkt // Timeline-Punkt
doc.circle(certTimelineX, yRight + 4, 3.5).fill(accentColor); doc.circle(certTimelineX, yRight + 4, 3.5).fill(accentColor);
if (i < profile.certifications.length - 1) { if (i < profile.certifications.length - 1) {
doc.moveTo(certTimelineX, yRight + 8).lineTo(certTimelineX, yRight + 40) doc.moveTo(certTimelineX, yRight + 8).lineTo(certTimelineX, yRight + 32)
.strokeColor(accentColor).lineWidth(1).stroke(); .strokeColor(accentColor).lineWidth(1).stroke();
} }
// Jahr (fett) // Jahr (fett)
doc.font('Helvetica-Bold').fontSize(8).fillColor('#555555'); doc.font('Helvetica-Bold').fontSize(8).fillColor('#555555');
doc.text(String(cert.issueYear), certContentX, yRight, { width: certContentWidth }); doc.text(String(cert.issueYear), certContentX, yRight, { width: certContentWidth });
yRight += 12; yRight += 11;
// Titel // Titel
doc.font('Helvetica-Bold').fontSize(10).fillColor(accentColor); doc.font('Helvetica-Bold').fontSize(9).fillColor(accentColor);
doc.text(cert.title, certContentX, yRight, { width: certContentWidth }); doc.text(cert.title, certContentX, yRight, { width: certContentWidth });
yRight += doc.heightOfString(cert.title, { width: certContentWidth }) + 2; yRight += doc.heightOfString(cert.title, { width: certContentWidth }) + 2;
// Zertifizierungsstelle // Zertifizierungsstelle
doc.font('Helvetica').fontSize(9).fillColor('#555555'); doc.font('Helvetica').fontSize(8).fillColor('#555555');
doc.text(cert.issuingBody, certContentX, yRight, { width: certContentWidth }); doc.text(cert.issuingBody, certContentX, yRight, { width: certContentWidth });
yRight += 14; yRight += 11;
yRight += 6; yRight += 4;
} }
} }
@ -467,6 +478,25 @@ export class ProfileExportService {
} }
} }
// --- ANHÄNGE (Bild-Anhänge als zusätzliche Seiten) ---
const attachments = profile?.attachments ?? [];
for (const att of attachments) {
if (!att.mimetype.startsWith('image/')) continue;
doc.addPage();
let yAtt = 40;
yAtt = this.pdfSectionTitle(doc, `ANLAGE: ${att.filename}`, 40, yAtt, pageWidth - 80, accentColor);
try {
const imgBuffer = this.base64ToBuffer(att.data);
const maxW = pageWidth - 80;
const maxH = 680;
doc.image(imgBuffer, 40, yAtt, { fit: [maxW, maxH], align: 'center' });
} catch (err) {
this.logger.warn(`Anhang-Bild konnte nicht gerendert werden: ${att.filename}`, err);
doc.font('Helvetica').fontSize(9).fillColor('#777777');
doc.text(`(Bild konnte nicht dargestellt werden: ${att.filename})`, 40, yAtt, { width: pageWidth - 80 });
}
}
doc.end(); doc.end();
}); });
} }
@ -669,12 +699,15 @@ export class ProfileExportService {
if (proj.tasks) { if (proj.tasks) {
const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim()); const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim());
for (const task of taskLines) { for (const task of taskLines) {
const normalized = this.normalizeTaskLine(task); const raw = task.trim();
if (!normalized) continue; const hasBullet = /^[•\u2022]\s/.test(raw) || /^\d+\.\s/.test(raw);
const cleaned = this.normalizeTaskLine(raw);
if (!cleaned) continue;
const displayText = hasBullet ? `\u2022 ${cleaned}` : cleaned;
rightParagraphs.push( rightParagraphs.push(
new Paragraph({ new Paragraph({
children: [ children: [
new TextRun({ text: `\u2022 ${normalized}`, size: 16, color: '444444' }), new TextRun({ text: displayText, size: 16, color: '444444' }),
], ],
spacing: { after: 20 }, spacing: { after: 20 },
}), }),