feat: use PNG contact icons in PDF export instead of vector drawing
- Add Phone.png, Mobile.png, Mail.png, Address.png icon assets - Replace hand-drawn vector icons with professional PNG icons - Icons stored in packages/core-service/assets/icons/ (included in Docker build) - Also stored in templates/cv/default/ and Icons/ for reference Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
BIN
Icons/Address.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
Icons/Mail.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
Icons/Mobile.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
Icons/Phone.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
packages/core-service/assets/icons/Address.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
packages/core-service/assets/icons/Mail.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
packages/core-service/assets/icons/Mobile.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
packages/core-service/assets/icons/Phone.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -1,6 +1,8 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ExpertProfileService } from './expert-profile.service';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
Document,
|
||||
Packer,
|
||||
|
|
@ -95,8 +97,24 @@ function lightenColor(hex: string, factor: number): string {
|
|||
export class ProfileExportService {
|
||||
private readonly logger = new Logger(ProfileExportService.name);
|
||||
|
||||
// Pfad zu den Icon-Assets (relativ zum Working Directory /app im Container)
|
||||
private readonly iconsDir = path.resolve(process.cwd(), 'assets', 'icons');
|
||||
|
||||
constructor(private readonly expertProfileService: ExpertProfileService) {}
|
||||
|
||||
private loadIcon(name: string): Buffer | null {
|
||||
try {
|
||||
const iconPath = path.join(this.iconsDir, name);
|
||||
if (fs.existsSync(iconPath)) {
|
||||
return fs.readFileSync(iconPath);
|
||||
}
|
||||
this.logger.warn(`Icon nicht gefunden: ${iconPath}`);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Icon konnte nicht geladen werden: ${name}`, err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PDF Export
|
||||
// ============================================================
|
||||
|
|
@ -173,21 +191,28 @@ export class ProfileExportService {
|
|||
// --- KONTAKT ---
|
||||
yLeft = this.pdfSectionTitle(doc, 'KONTAKT', leftColX, yLeft, leftColWidth, accentColor);
|
||||
|
||||
const iconTextOffset = 18; // Abstand Icon → Text
|
||||
// Icons laden
|
||||
const phoneIcon = this.loadIcon('Phone.png');
|
||||
const mobileIcon = this.loadIcon('Mobile.png');
|
||||
const mailIcon = this.loadIcon('Mail.png');
|
||||
const addressIcon = this.loadIcon('Address.png');
|
||||
const iconSize = 12;
|
||||
const iconTextOffset = 20; // Abstand Icon → Text
|
||||
|
||||
if (data.phone) {
|
||||
this.drawHandsetIcon(doc, leftColX + 1, yLeft, accentColor);
|
||||
if (phoneIcon) doc.image(phoneIcon, leftColX, yLeft - 1, { width: iconSize, height: iconSize });
|
||||
yLeft = this.pdfContactText(doc, data.phone, leftColX + iconTextOffset, yLeft, leftColWidth - iconTextOffset);
|
||||
}
|
||||
if (data.mobile) {
|
||||
this.drawMobileIcon(doc, leftColX + 1, yLeft, accentColor);
|
||||
if (mobileIcon) doc.image(mobileIcon, leftColX + 1, yLeft - 1, { width: iconSize, height: iconSize });
|
||||
yLeft = this.pdfContactText(doc, data.mobile, leftColX + iconTextOffset, yLeft, leftColWidth - iconTextOffset);
|
||||
}
|
||||
if (data.email) {
|
||||
this.drawEmailIcon(doc, leftColX, yLeft + 1, accentColor);
|
||||
if (mailIcon) doc.image(mailIcon, leftColX, yLeft - 1, { width: iconSize, height: iconSize });
|
||||
yLeft = this.pdfContactText(doc, data.email, leftColX + iconTextOffset, yLeft, leftColWidth - iconTextOffset);
|
||||
}
|
||||
if (data.street || data.city) {
|
||||
this.drawLocationIcon(doc, leftColX, yLeft, accentColor);
|
||||
if (addressIcon) doc.image(addressIcon, leftColX + 1, yLeft - 2, { width: iconSize, height: iconSize + 2 });
|
||||
const line1 = data.street || '';
|
||||
const line2 = [data.postalCode, data.city].filter(Boolean).join(' ');
|
||||
const addressText = [line1, line2].filter(Boolean).join('\n');
|
||||
|
|
@ -729,76 +754,6 @@ export class ProfileExportService {
|
|||
return y + Math.max(textHeight, 10) + 3;
|
||||
}
|
||||
|
||||
// --- Vektor-Icons für Kontakt ---
|
||||
|
||||
private drawHandsetIcon(doc: PDFKit.PDFDocument, x: number, y: number, color: string): void {
|
||||
doc.save();
|
||||
// Klassischer Telefonhörer als zusammenhängende Silhouette
|
||||
// Ohrmuschel + Sprechmuschel als Blöcke, Griff als gefüllter Bogen
|
||||
|
||||
// Ohrmuschel (oben)
|
||||
doc.roundedRect(x, y, 10, 3.5, 1.5).fill(color);
|
||||
|
||||
// Sprechmuschel (unten)
|
||||
doc.roundedRect(x, y + 7.5, 10, 3.5, 1.5).fill(color);
|
||||
|
||||
// Griff: gefüllte Bogenform (Crescent) — verbindet nahtlos
|
||||
// Äußerer Bogen (weit nach links)
|
||||
doc.moveTo(x + 4, y + 3)
|
||||
.bezierCurveTo(x - 2, y + 4, x - 2, y + 7, x + 4, y + 8)
|
||||
// Innerer Bogen (zurück, näher zur Mitte)
|
||||
.bezierCurveTo(x + 1.5, y + 6.5, x + 1.5, y + 4.5, x + 4, y + 3)
|
||||
.fill(color);
|
||||
|
||||
doc.restore();
|
||||
}
|
||||
|
||||
private drawMobileIcon(doc: PDFKit.PDFDocument, x: number, y: number, color: string): void {
|
||||
doc.save();
|
||||
// Smartphone-Icon: abgerundetes Rechteck mit Display und Home-Button
|
||||
const w = 7;
|
||||
const h = 10;
|
||||
doc.roundedRect(x, y - 1, w, h, 1.5).strokeColor(color).lineWidth(1).stroke();
|
||||
// Display-Bereich (inneres Rechteck)
|
||||
doc.rect(x + 1.2, y + 1, w - 2.4, h - 4.5).fill(color);
|
||||
// Home-Button (kleiner Kreis unten)
|
||||
doc.circle(x + w / 2, y + h - 2, 0.8).fill(color);
|
||||
doc.restore();
|
||||
}
|
||||
|
||||
private drawEmailIcon(doc: PDFKit.PDFDocument, x: number, y: number, color: string): void {
|
||||
doc.save();
|
||||
const w = 11;
|
||||
const h = 8;
|
||||
// Briefumschlag-Körper
|
||||
doc.rect(x, y, w, h).strokeColor(color).lineWidth(0.8).stroke();
|
||||
// Klappen-Linien (V-Form von oben + Ecken unten)
|
||||
doc.moveTo(x, y).lineTo(x + w / 2, y + h * 0.55).lineTo(x + w, y)
|
||||
.strokeColor(color).lineWidth(0.8).stroke();
|
||||
doc.restore();
|
||||
}
|
||||
|
||||
private drawLocationIcon(doc: PDFKit.PDFDocument, x: number, y: number, color: string): void {
|
||||
doc.save();
|
||||
// Map-Pin als Tropfenform mit Bezier-Kurven
|
||||
const cx = x + 5; // Mittelpunkt X
|
||||
const top = y - 1; // Oberkante
|
||||
const r = 4; // Radius des Kopfes
|
||||
const tip = y + 11; // Spitze des Pins
|
||||
|
||||
// Tropfenform: Start oben, links herum, Spitze unten, rechts hoch
|
||||
doc.moveTo(cx, top)
|
||||
.bezierCurveTo(cx - r * 1.1, top, cx - r, top + r * 0.6, cx - r, top + r)
|
||||
.bezierCurveTo(cx - r, top + r * 1.8, cx - r * 0.3, tip - 2, cx, tip)
|
||||
.bezierCurveTo(cx + r * 0.3, tip - 2, cx + r, top + r * 1.8, cx + r, top + r)
|
||||
.bezierCurveTo(cx + r, top + r * 0.6, cx + r * 1.1, top, cx, top)
|
||||
.fill(color);
|
||||
|
||||
// Innerer Kreis (weiß) für Pin-Look
|
||||
doc.circle(cx, top + r, 1.5).fill('#ffffff');
|
||||
doc.restore();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DOCX-Hilfsfunktionen
|
||||
// ============================================================
|
||||
|
|
|
|||
BIN
templates/cv/default/Address.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
templates/cv/default/Mail.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
templates/cv/default/Mobile.png
Normal file
|
After Width: | Height: | Size: 750 B |
BIN
templates/cv/default/Phone.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |