From 79ad5e4be34589bca697b0b929b14ad4b2e5409b Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Sat, 14 Mar 2026 11:23:03 +0100 Subject: [PATCH] =?UTF-8?q?fix(core):=20PDF-Anh=C3=A4nge=20korrekt=20einbe?= =?UTF-8?q?tten=20via=20pdf-lib=20+=20Zeichenbereinigung=20verbessert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/core-service/package-lock.json | 37 +++++++ packages/core-service/package.json | 1 + .../expert-profile/profile-export.service.ts | 96 ++++++++++++++----- 3 files changed, 109 insertions(+), 25 deletions(-) diff --git a/packages/core-service/package-lock.json b/packages/core-service/package-lock.json index caab552..2347a06 100644 --- a/packages/core-service/package-lock.json +++ b/packages/core-service/package-lock.json @@ -30,6 +30,7 @@ "otplib": "^12.0.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pdf-lib": "^1.17.1", "pdfkit": "^0.17.2", "pngjs": "^7.0.0", "qrcode": "^1.5.4", @@ -2748,6 +2749,24 @@ "@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": { "version": "0.11.0", "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", "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": { "version": "0.17.2", "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz", diff --git a/packages/core-service/package.json b/packages/core-service/package.json index deafca3..00d7543 100644 --- a/packages/core-service/package.json +++ b/packages/core-service/package.json @@ -47,6 +47,7 @@ "otplib": "^12.0.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pdf-lib": "^1.17.1", "pdfkit": "^0.17.2", "pngjs": "^7.0.0", "qrcode": "^1.5.4", 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 39b7c70..6ce362c 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 @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ExpertProfileService } from './expert-profile.service'; import { RedisService } from '../../redis/redis.service'; import PDFDocument from 'pdfkit'; +import { PDFDocument as PdfLib } from 'pdf-lib'; import * as fs from 'fs'; import * as path from 'path'; import { PNG } from 'pngjs'; @@ -188,16 +189,34 @@ export class ProfileExportService { } /** - * Ersetzt Sonderzeichen, die PDFKit's eingebauter Helvetica-Font (WinAnsiEncoding) - * nicht darstellen kann (z.B. €-Zeichen → Zeichensalat). + * Bereinigt Text für PDFKit's eingebauten Helvetica-Font (WinAnsiEncoding). + * PDFKit kann Zeichen außerhalb des Latin-1-Druckbereichs nicht darstellen. + * Behandelt auch Windows-1252-Zeichen die als U+0080–U+009F mis-enkodiert sind + * (passiert wenn Text ursprünglich als Windows-1252 gespeichert wurde). */ private sanitizePdfText(text: string): string { + // Windows-1252 Bereich U+0080–U+009F → lesbare ASCII-Äquivalente + const w1252: Record = { + '\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 - .replace(/€/g, 'EUR') - .replace(/£/g, 'GBP') - .replace(/¥/g, 'JPY') - // Weiterer Fallback: alle Zeichen außerhalb Latin-1 (U+0100+) durch '?' ersetzen - .replace(/[\u0100-\uFFFF]/g, '?'); + .replace(/€/g, 'EUR') // U+20AC korrektes Euro-Zeichen + .replace(/\u00a3/g, 'GBP') // £ + .replace(/\u00a5/g, 'JPY') // ¥ + // Windows-1252 Sonderzeichen ersetzen + .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 { 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 doc = new PDFDocument({ 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 ?? []; for (const att of attachments) { + if (!att.mimetype.startsWith('image/')) continue; doc.addPage(); let yAtt = 40; yAtt = this.pdfSectionTitle(doc, `ANLAGE: ${att.filename}`, 40, yAtt, pageWidth - 80, accentColor); - if (att.mimetype.startsWith('image/')) { - try { - const imgBuffer = this.base64ToBuffer(att.data); - const maxW = pageWidth - 80; - const maxH = 680; - doc.image(imgBuffer, 40, yAtt, { fit: [maxW, maxH], align: 'center' }); - } catch (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 + try { + const imgBuffer = this.base64ToBuffer(att.data); + const maxW = pageWidth - 80; + const maxH = 680; + doc.image(imgBuffer, 40, yAtt, { fit: [maxW, maxH], align: 'center' }); + } catch (err) { + this.logger.warn(`Anhang-Bild konnte nicht gerendert werden: ${att.filename}`, err); doc.font('Helvetica').fontSize(9).fillColor('#777777'); - doc.text(`Typ: ${att.mimetype}`, 40, yAtt, { width: pageWidth - 80 }); - doc.moveDown(0.4); - doc.text(`(Datei-Anhang – nicht als Vorschau darstellbar)`, 40, doc.y, { width: pageWidth - 80 }); + doc.text(`(Bild konnte nicht dargestellt werden: ${att.filename})`, 40, yAtt, { width: pageWidth - 80 }); } } @@ -580,6 +598,34 @@ export class ProfileExportService { 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; + } } // ============================================================