fix(core): Word-Export — jobTitle, Logo, Akzentfarbe und Firmenfußzeile

- Jobtitel aus Profil statt experiences[0].area unter dem Namen
- Platform-Logo aus Redis über Avatar einfügen
- Dominante Akzentfarbe dynamisch aus Logo extrahieren
- Firmen-Fußzeile aus Redis-Settings als DOCX-Footer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-14 09:43:29 +01:00
parent 196515daa8
commit 8fc894c74c

View file

@ -20,6 +20,7 @@ import {
ImageRun, ImageRun,
HeadingLevel, HeadingLevel,
ShadingType, ShadingType,
Footer,
} from 'docx'; } from 'docx';
// ============================================================ // ============================================================
@ -555,6 +556,48 @@ export class ProfileExportService {
const data = await this.expertProfileService.getExportData(userId) as ExportData; const data = await this.expertProfileService.getExportData(userId) as ExportData;
const profile = data.expertProfile; const profile = data.expertProfile;
const fullName = `${data.firstName} ${data.lastName}`; const fullName = `${data.firstName} ${data.lastName}`;
// Branding + Firmendaten aus Redis laden
const [brandingRaw, companyRaw] = await Promise.all([
this.redis.get('platform_branding_logo'),
this.redis.get('platform_company_settings'),
]);
let platformLogo: Buffer | null = null;
if (brandingRaw) {
try {
const branding = JSON.parse(brandingRaw) as { logo?: string };
if (branding.logo) {
const base64 = branding.logo.replace(/^data:image\/\w+;base64,/, '');
platformLogo = Buffer.from(base64, 'base64');
}
} catch { /* ignore */ }
}
// Dominante Farbe aus Logo extrahieren
if (platformLogo) {
const extracted = await this.extractDominantColor(platformLogo);
if (extracted) accentColor = extracted;
}
let companyFooterText: string | null = null;
if (companyRaw) {
try {
const c = JSON.parse(companyRaw) as {
name?: string | null; street?: string | null; postalCode?: string | null;
city?: string | null; phone?: string | null; email?: string | null; website?: string | null;
};
const parts: string[] = [];
if (c.name) parts.push(c.name);
const addr = [c.street, [c.postalCode, c.city].filter(Boolean).join(' ')].filter(Boolean).join(', ');
if (addr) parts.push(addr);
if (c.phone) parts.push(`Tel: ${c.phone}`);
if (c.email) parts.push(c.email);
if (c.website) parts.push(c.website);
if (parts.length) companyFooterText = parts.join(' | ');
} catch { /* ignore */ }
}
const accentHex = accentColor.replace('#', ''); const accentHex = accentColor.replace('#', '');
const lightAccent = lightenColor(accentColor, 0.85).replace('#', ''); const lightAccent = lightenColor(accentColor, 0.85).replace('#', '');
@ -563,6 +606,31 @@ export class ProfileExportService {
// --- Kontakt-Infos für linke Spalte --- // --- Kontakt-Infos für linke Spalte ---
const leftParagraphs: Paragraph[] = []; const leftParagraphs: Paragraph[] = [];
// Platform-Logo
if (platformLogo) {
try {
const logoBuffer = await sharp(platformLogo)
.resize(120, 40, { fit: 'inside' })
.png()
.toBuffer();
leftParagraphs.push(
new Paragraph({
children: [
new ImageRun({
data: logoBuffer,
transformation: { width: 120, height: 40 },
type: 'png',
}),
],
alignment: AlignmentType.CENTER,
spacing: { after: 120 },
}),
);
} catch (err) {
this.logger.warn('Logo für DOCX konnte nicht geladen werden', err);
}
}
// Avatar (rund zugeschnitten) // Avatar (rund zugeschnitten)
let avatarImageRun: ImageRun | null = null; let avatarImageRun: ImageRun | null = null;
if (data.avatar) { if (data.avatar) {
@ -605,13 +673,13 @@ export class ProfileExportService {
}), }),
); );
// Rolle // Rolle / Jobtitel
if (profile && profile.experiences.length > 0) { if (data.jobTitle) {
leftParagraphs.push( leftParagraphs.push(
new Paragraph({ new Paragraph({
children: [ children: [
new TextRun({ new TextRun({
text: profile.experiences[0].area, text: data.jobTitle,
size: 20, size: 20,
color: accentHex, color: accentHex,
}), }),
@ -854,6 +922,27 @@ export class ProfileExportService {
); );
} }
// --- Fußzeile ---
const footerChildren: Paragraph[] = [];
if (companyFooterText) {
footerChildren.push(
new Paragraph({
children: [
new TextRun({
text: companyFooterText,
size: 14,
color: '999999',
}),
],
alignment: AlignmentType.CENTER,
border: {
top: { style: BorderStyle.SINGLE, size: 3, color: 'cccccc', space: 4 },
},
spacing: { before: 80 },
}),
);
}
// --- Dokument zusammenstellen --- // --- Dokument zusammenstellen ---
const document = new Document({ const document = new Document({
styles: { styles: {
@ -869,9 +958,16 @@ export class ProfileExportService {
{ {
properties: { properties: {
page: { page: {
margin: { top: 720, bottom: 720, left: 720, right: 720 }, margin: { top: 720, bottom: companyFooterText ? 800 : 720, left: 720, right: 720 },
}, },
}, },
footers: companyFooterText
? {
default: new Footer({
children: footerChildren,
}),
}
: undefined,
children: [layoutTable, ...sections], children: [layoutTable, ...sections],
}, },
], ],