feat: recolor PNG contact icons to match accent color at runtime

Uses pngjs to replace all visible pixels in PNG icons with the
configured accent color while preserving alpha transparency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-09 20:22:37 +01:00
parent 9a9800e17e
commit b948027dab
3 changed files with 57 additions and 11 deletions

View file

@ -30,6 +30,7 @@
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfkit": "^0.17.2",
"pngjs": "^7.0.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@ -46,6 +47,7 @@
"@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.5",
"@types/pngjs": "^6.0.5",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
@ -2712,6 +2714,16 @@
"@types/node": "*"
}
},
"node_modules/@types/pngjs": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz",
"integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
@ -8836,12 +8848,12 @@
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
"node": ">=14.19.0"
}
},
"node_modules/prelude-ls": {
@ -9079,6 +9091,15 @@
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",

View file

@ -47,6 +47,7 @@
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfkit": "^0.17.2",
"pngjs": "^7.0.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@ -63,6 +64,7 @@
"@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.5",
"@types/pngjs": "^6.0.5",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",

View file

@ -3,6 +3,7 @@ import { ExpertProfileService } from './expert-profile.service';
import PDFDocument from 'pdfkit';
import * as fs from 'fs';
import * as path from 'path';
import { PNG } from 'pngjs';
import {
Document,
Packer,
@ -102,11 +103,15 @@ export class ProfileExportService {
constructor(private readonly expertProfileService: ExpertProfileService) {}
private loadIcon(name: string): Buffer | null {
private loadIcon(name: string, color?: string): Buffer | null {
try {
const iconPath = path.join(this.iconsDir, name);
if (fs.existsSync(iconPath)) {
return fs.readFileSync(iconPath);
const raw = fs.readFileSync(iconPath);
if (color) {
return this.recolorPng(raw, color);
}
return raw;
}
this.logger.warn(`Icon nicht gefunden: ${iconPath}`);
} catch (err) {
@ -115,6 +120,24 @@ export class ProfileExportService {
return null;
}
/** Ersetzt alle sichtbaren Pixel eines PNG durch die angegebene Farbe (Alpha bleibt erhalten) */
private recolorPng(pngBuffer: Buffer, hexColor: string): Buffer {
const { r, g, b } = hexToRgb(hexColor);
const png = PNG.sync.read(pngBuffer);
for (let i = 0; i < png.data.length; i += 4) {
const alpha = png.data[i + 3];
if (alpha > 0) {
png.data[i] = r;
png.data[i + 1] = g;
png.data[i + 2] = b;
// Alpha bleibt unverändert
}
}
return PNG.sync.write(png);
}
// ============================================================
// PDF Export
// ============================================================
@ -191,11 +214,11 @@ export class ProfileExportService {
// --- KONTAKT ---
yLeft = this.pdfSectionTitle(doc, 'KONTAKT', leftColX, yLeft, leftColWidth, accentColor);
// 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');
// Icons laden (eingefärbt in Akzentfarbe)
const phoneIcon = this.loadIcon('Phone.png', accentColor);
const mobileIcon = this.loadIcon('Mobile.png', accentColor);
const mailIcon = this.loadIcon('Mail.png', accentColor);
const addressIcon = this.loadIcon('Address.png', accentColor);
const iconSize = 12;
const iconTextOffset = 20; // Abstand Icon → Text