fix(core): PDF-Anhänge korrekt einbetten via pdf-lib + Zeichenbereinigung verbessert

- pdf-lib installiert und importiert
- PDF-Anhänge werden nicht mehr als Platzhalter-Seite angezeigt, sondern
  alle Seiten des Anhang-PDFs direkt in das Export-PDF eingebettet (pdf-lib merge)
- Bild-Anhänge bleiben weiterhin per PDFKit eingebettet
- sanitizePdfText() erweitert: vollständige Windows-1252 U+0080-U+009F Mapping-Tabelle
  für mis-enkodierte Zeichen (€, Anführungszeichen, Gedankenstriche, TM usw.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-14 11:23:03 +01:00
parent d08bedecf7
commit 79ad5e4be3
3 changed files with 109 additions and 25 deletions

View file

@ -30,6 +30,7 @@
"otplib": "^12.0.1", "otplib": "^12.0.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@ -2748,6 +2749,24 @@
"@otplib/plugin-thirty-two": "^12.0.1" "@otplib/plugin-thirty-two": "^12.0.1"
} }
}, },
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -9210,6 +9229,24 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
}, },
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/pdfkit": { "node_modules/pdfkit": {
"version": "0.17.2", "version": "0.17.2",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz", "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",

View file

@ -47,6 +47,7 @@
"otplib": "^12.0.1", "otplib": "^12.0.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",

View file

@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { ExpertProfileService } from './expert-profile.service'; import { ExpertProfileService } from './expert-profile.service';
import { RedisService } from '../../redis/redis.service'; import { RedisService } from '../../redis/redis.service';
import PDFDocument from 'pdfkit'; import PDFDocument from 'pdfkit';
import { PDFDocument as PdfLib } from 'pdf-lib';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { PNG } from 'pngjs'; import { PNG } from 'pngjs';
@ -188,16 +189,34 @@ export class ProfileExportService {
} }
/** /**
* Ersetzt Sonderzeichen, die PDFKit's eingebauter Helvetica-Font (WinAnsiEncoding) * Bereinigt Text für PDFKit's eingebauten Helvetica-Font (WinAnsiEncoding).
* nicht darstellen kann (z.B. -Zeichen Zeichensalat). * PDFKit kann Zeichen außerhalb des Latin-1-Druckbereichs nicht darstellen.
* Behandelt auch Windows-1252-Zeichen die als U+0080U+009F mis-enkodiert sind
* (passiert wenn Text ursprünglich als Windows-1252 gespeichert wurde).
*/ */
private sanitizePdfText(text: string): string { private sanitizePdfText(text: string): string {
// Windows-1252 Bereich U+0080U+009F → lesbare ASCII-Äquivalente
const w1252: Record<string, string> = {
'\u0080': 'EUR', '\u0082': ',', '\u0083': 'f', '\u0084': '"',
'\u0085': '...', '\u0086': '+', '\u0087': '++', '\u0089': '%',
'\u008a': 'S', '\u008b': '<', '\u008c': 'OE', '\u008e': 'Z',
'\u0091': "'", '\u0092': "'", '\u0093': '"', '\u0094': '"',
'\u0095': '-', '\u0096': '-', '\u0097': '--', '\u0099': 'TM',
'\u009a': 's', '\u009b': '>', '\u009c': 'oe', '\u009e': 'z',
'\u009f': 'Y',
};
return text return text
.replace(/€/g, 'EUR') .replace(/€/g, 'EUR') // U+20AC korrektes Euro-Zeichen
.replace(/£/g, 'GBP') .replace(/\u00a3/g, 'GBP') // £
.replace(/¥/g, 'JPY') .replace(/\u00a5/g, 'JPY') // ¥
// Weiterer Fallback: alle Zeichen außerhalb Latin-1 (U+0100+) durch '?' ersetzen // Windows-1252 Sonderzeichen ersetzen
.replace(/[\u0100-\uFFFF]/g, '?'); .replace(/[\u0080-\u009f]/g, (c) => w1252[c] ?? '')
// Alles ab U+0100 ersetzen (Zeichen außerhalb Latin-1)
.replace(/[\u0100-\uffff]/g, (c) => {
// Versuch NFD-Normalisierung für Umlaute/Akzente
const nfd = c.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
return nfd.length > 0 && nfd.charCodeAt(0) < 0x100 ? nfd : '';
});
} }
// ============================================================ // ============================================================
@ -243,7 +262,13 @@ export class ProfileExportService {
const fullName = `${data.firstName} ${data.lastName}`; const fullName = `${data.firstName} ${data.lastName}`;
const { firstName, lastName } = data; const { firstName, lastName } = data;
return new Promise<{ buffer: Buffer; firstName: string; lastName: string }>((resolve, reject) => { // Speichere PDF-Anhänge für späteres Merging (pdf-lib)
const pdfAttachments = (profile?.attachments ?? []).filter(
(a) => a.mimetype === 'application/pdf',
);
// Schritt 1: CV-PDF mit PDFKit generieren
const cvResult = await new Promise<{ buffer: Buffer; firstName: string; lastName: string }>((resolve, reject) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
const doc = new PDFDocument({ const doc = new PDFDocument({
size: 'A4', size: 'A4',
@ -533,29 +558,22 @@ export class ProfileExportService {
} }
} }
// --- ANHÄNGE als zusätzliche Seiten --- // --- BILD-ANHÄNGE als zusätzliche Seiten (nur Bilder — PDFs werden per pdf-lib gemerged) ---
const attachments = profile?.attachments ?? []; const attachments = profile?.attachments ?? [];
for (const att of attachments) { for (const att of attachments) {
if (!att.mimetype.startsWith('image/')) continue;
doc.addPage(); doc.addPage();
let yAtt = 40; let yAtt = 40;
yAtt = this.pdfSectionTitle(doc, `ANLAGE: ${att.filename}`, 40, yAtt, pageWidth - 80, accentColor); yAtt = this.pdfSectionTitle(doc, `ANLAGE: ${att.filename}`, 40, yAtt, pageWidth - 80, accentColor);
if (att.mimetype.startsWith('image/')) { try {
try { const imgBuffer = this.base64ToBuffer(att.data);
const imgBuffer = this.base64ToBuffer(att.data); const maxW = pageWidth - 80;
const maxW = pageWidth - 80; const maxH = 680;
const maxH = 680; doc.image(imgBuffer, 40, yAtt, { fit: [maxW, maxH], align: 'center' });
doc.image(imgBuffer, 40, yAtt, { fit: [maxW, maxH], align: 'center' }); } catch (err) {
} catch (err) { this.logger.warn(`Anhang-Bild konnte nicht gerendert werden: ${att.filename}`, 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 });
}
} else {
// Nicht-Bild-Anhänge: Dateiname und Hinweis
doc.font('Helvetica').fontSize(9).fillColor('#777777'); doc.font('Helvetica').fontSize(9).fillColor('#777777');
doc.text(`Typ: ${att.mimetype}`, 40, yAtt, { width: pageWidth - 80 }); doc.text(`(Bild konnte nicht dargestellt werden: ${att.filename})`, 40, yAtt, { width: pageWidth - 80 });
doc.moveDown(0.4);
doc.text(`(Datei-Anhang nicht als Vorschau darstellbar)`, 40, doc.y, { width: pageWidth - 80 });
} }
} }
@ -580,6 +598,34 @@ export class ProfileExportService {
doc.end(); doc.end();
}); });
// Schritt 2: PDF-Anhänge mit pdf-lib einbetten
if (pdfAttachments.length === 0) {
return cvResult;
}
try {
const merged = await PdfLib.load(cvResult.buffer);
for (const att of pdfAttachments) {
try {
const attBuffer = this.base64ToBuffer(att.data);
const attPdf = await PdfLib.load(attBuffer);
const pageIndices = attPdf.getPageIndices();
const copiedPages = await merged.copyPages(attPdf, pageIndices);
for (const page of copiedPages) {
merged.addPage(page);
}
this.logger.log(`PDF-Anhang eingebettet: ${att.filename} (${pageIndices.length} Seite(n))`);
} catch (err) {
this.logger.warn(`PDF-Anhang konnte nicht eingebettet werden: ${att.filename}`, err);
}
}
const mergedBuffer = Buffer.from(await merged.save());
return { buffer: mergedBuffer, firstName: cvResult.firstName, lastName: cvResult.lastName };
} catch (err) {
this.logger.warn('PDF-Merge fehlgeschlagen, CV ohne Anhänge zurückgegeben', err);
return cvResult;
}
} }
// ============================================================ // ============================================================