From 4d5aa84ac922707438730829874b6f88de2125c0 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Mon, 9 Mar 2026 12:06:37 +0100 Subject: [PATCH] fix: improve PDF export - vector icons, wider section lines, address line break, simplified language levels Co-Authored-By: Claude Opus 4.6 --- .../expert-profile/profile-export.service.ts | 101 +++++++++++++----- .../src/profile/sections/LanguagesSection.tsx | 2 +- 2 files changed, 78 insertions(+), 25 deletions(-) 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 e488c82..f5c88aa 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 @@ -174,17 +174,23 @@ export class ProfileExportService { yLeft = this.pdfSectionTitle(doc, 'KONTAKT', leftColX, yLeft, leftColWidth, accentColor); if (data.phone) { - yLeft = this.pdfContactLine(doc, '\u260E', data.phone, leftColX, yLeft, leftColWidth); + this.drawPhoneIcon(doc, leftColX, yLeft + 1, accentColor); + yLeft = this.pdfContactText(doc, data.phone, leftColX + 16, yLeft, leftColWidth - 16); } if (data.mobile) { - yLeft = this.pdfContactLine(doc, '\u260E', data.mobile, leftColX, yLeft, leftColWidth); + this.drawPhoneIcon(doc, leftColX, yLeft + 1, accentColor); + yLeft = this.pdfContactText(doc, data.mobile, leftColX + 16, yLeft, leftColWidth - 16); } if (data.email) { - yLeft = this.pdfContactLine(doc, '\u2709', data.email, leftColX, yLeft, leftColWidth); + this.drawEmailIcon(doc, leftColX, yLeft + 1, accentColor); + yLeft = this.pdfContactText(doc, data.email, leftColX + 16, yLeft, leftColWidth - 16); } if (data.street || data.city) { - const address = [data.street, [data.postalCode, data.city].filter(Boolean).join(' ')].filter(Boolean).join(', '); - yLeft = this.pdfContactLine(doc, '\u2302', address, leftColX, yLeft, leftColWidth); + this.drawLocationIcon(doc, leftColX + 1, yLeft, accentColor); + const line1 = data.street || ''; + const line2 = [data.postalCode, data.city].filter(Boolean).join(' '); + const addressText = [line1, line2].filter(Boolean).join('\n'); + yLeft = this.pdfContactText(doc, addressText, leftColX + 16, yLeft, leftColWidth - 16); } yLeft += 10; @@ -457,20 +463,15 @@ export class ProfileExportService { // Kontakt-Sektion leftParagraphs.push(this.docxSectionHeading('KONTAKT', accentHex)); - const contactItems: Array<{ icon: string; text: string }> = []; - if (data.phone) contactItems.push({ icon: '\u260E', text: data.phone }); - if (data.mobile) contactItems.push({ icon: '\u260E', text: data.mobile }); - if (data.email) contactItems.push({ icon: '\u2709', text: data.email }); - if (data.street || data.city) { - const address = [data.street, [data.postalCode, data.city].filter(Boolean).join(' ')].filter(Boolean).join(', '); - contactItems.push({ icon: '\u2302', text: address }); - } - + const contactItems: Array<{ label: string; text: string }> = []; + if (data.phone) contactItems.push({ label: 'Tel.', text: data.phone }); + if (data.mobile) contactItems.push({ label: 'Mobil', text: data.mobile }); + if (data.email) contactItems.push({ label: 'Mail', text: data.email }); for (const item of contactItems) { leftParagraphs.push( new Paragraph({ children: [ - new TextRun({ text: `${item.icon} `, size: 18, color: accentHex }), + new TextRun({ text: `${item.label} `, bold: true, size: 14, color: accentHex }), new TextRun({ text: item.text, size: 16, color: '555555' }), ], spacing: { after: 60 }, @@ -478,6 +479,23 @@ export class ProfileExportService { ); } + // Adresse separat (mit Zeilenumbruch) + if (data.street || data.city) { + const addrRuns: TextRun[] = [ + new TextRun({ text: 'Adr. ', bold: true, size: 14, color: accentHex }), + ]; + if (data.street) { + addrRuns.push(new TextRun({ text: data.street, size: 16, color: '555555' })); + } + const cityLine = [data.postalCode, data.city].filter(Boolean).join(' '); + if (cityLine) { + addrRuns.push(new TextRun({ text: cityLine, size: 16, color: '555555', break: 1 })); + } + leftParagraphs.push( + new Paragraph({ children: addrRuns, spacing: { after: 60 } }), + ); + } + // Sprachen if (profile && profile.languages.length > 0) { leftParagraphs.push(this.docxSectionHeading('SPRACHEN', accentHex)); @@ -684,29 +702,64 @@ export class ProfileExportService { title: string, x: number, y: number, - width: number, + _width: number, accentColor: string, ): number { doc.font('Helvetica-Bold').fontSize(10).fillColor('#333333'); - doc.text(title, x, y, { width }); + doc.text(title, x, y); + const titleWidth = doc.widthOfString(title); y += 14; - doc.moveTo(x, y).lineTo(x + Math.min(width, 60), y) - .strokeColor(accentColor).lineWidth(1.5).stroke(); + doc.moveTo(x, y).lineTo(x + titleWidth + 4, y) + .strokeColor(accentColor).lineWidth(2).stroke(); y += 8; return y; } - private pdfContactLine( + private pdfContactText( doc: PDFKit.PDFDocument, - icon: string, text: string, x: number, y: number, width: number, ): number { - doc.font('Helvetica').fontSize(8).fillColor('#777777'); - doc.text(`${icon} ${text}`, x, y, { width }); - return y + doc.heightOfString(`${icon} ${text}`, { width }) + 3; + doc.font('Helvetica').fontSize(8).fillColor('#555555'); + doc.text(text, x, y, { width }); + const textHeight = doc.heightOfString(text, { width }); + return y + Math.max(textHeight, 10) + 3; + } + + // --- Vektor-Icons für Kontakt --- + + private drawPhoneIcon(doc: PDFKit.PDFDocument, x: number, y: number, color: string): void { + doc.save(); + // Telefon-Hörer: abgerundetes Rechteck + doc.roundedRect(x + 1, y, 4, 8, 1.5).fillAndStroke(color, color); + // Hörer-Bogen oben + doc.moveTo(x, y + 1).quadraticCurveTo(x - 1, y + 4, x + 1, y + 7) + .strokeColor(color).lineWidth(1.5).stroke(); + doc.restore(); + } + + private drawEmailIcon(doc: PDFKit.PDFDocument, x: number, y: number, color: string): void { + doc.save(); + // Briefumschlag + doc.rect(x, y + 1, 10, 7).strokeColor(color).lineWidth(1).stroke(); + // V-Linie oben + doc.moveTo(x, y + 1).lineTo(x + 5, y + 5).lineTo(x + 10, y + 1) + .strokeColor(color).lineWidth(0.8).stroke(); + doc.restore(); + } + + private drawLocationIcon(doc: PDFKit.PDFDocument, x: number, y: number, color: string): void { + doc.save(); + // Pin-Kopf (Kreis) + doc.circle(x + 4, y + 4, 3).fillAndStroke(color, color); + // Pin-Spitze (Dreieck) + doc.moveTo(x + 1.5, y + 5.5).lineTo(x + 4, y + 11).lineTo(x + 6.5, y + 5.5) + .fill(color); + // Innerer Kreis (weiß) + doc.circle(x + 4, y + 4, 1.2).fill('#ffffff'); + doc.restore(); } // ============================================================ diff --git a/packages/frontend/src/profile/sections/LanguagesSection.tsx b/packages/frontend/src/profile/sections/LanguagesSection.tsx index c15c663..0538ca6 100644 --- a/packages/frontend/src/profile/sections/LanguagesSection.tsx +++ b/packages/frontend/src/profile/sections/LanguagesSection.tsx @@ -8,7 +8,7 @@ interface LanguagesSectionProps { onUpdate: () => Promise; } -const LANGUAGE_LEVELS = ['Muttersprache', 'C2', 'C1', 'B2', 'B1', 'A2', 'A1']; +const LANGUAGE_LEVELS = ['Muttersprache', 'Fließend', 'Gut']; export function LanguagesSection({ languages, onUpdate }: LanguagesSectionProps) { const [language, setLanguage] = useState('');