fix: PDF export — tab chars garbled, unconditional bullets, encrypted PDF attachments

Root causes identified via DB hex-dump and server logs:

1. Tab character in budget lines: DB stores e.g. `**Budget Verantwortung:**\t750.000 EUR`
   (byte 0x09 between `:**` and the amount). PDFKit can't render \t in WinAnsiEncoding,
   producing garbage output like `"sSãUU`. Fix: `.replace(/\t/g, ' ')` in cleaned text.

2. Unconditional bullet: `\u2022 ${sanitize(hasBullet ? cleaned : cleaned)}` always
   prepended `•` — the ternary was a no-op. Fix: only add `•` when hasBullet is true;
   `**...**` header lines now render as Helvetica-Bold without a bullet.

3. ITIL4 Foundation Cert.pdf is owner-password-encrypted. pdf-lib threw
   "Input document is encrypted" → cert was silently skipped.
   Fix: `PdfLib.load(attBuffer, { ignoreEncryption: true })`.

Applies to both PDF and DOCX export paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-14 13:56:57 +01:00
parent 98e7f48ce2
commit 1608f4e936

View file

@ -528,10 +528,9 @@ export class ProfileExportService {
yRight += doc.heightOfString(companyLine, { width: contentWidth }) + 2; yRight += doc.heightOfString(companyLine, { width: contentWidth }) + 2;
} }
// Aufgaben (Bullet nur wenn Original-Zeile ein Aufzählungszeichen hat) // Aufgaben: Bold für **...**-Zeilen, Bullet nur wenn Original-Zeile ein Aufzählungszeichen hat
if (proj.tasks) { if (proj.tasks) {
const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim()); const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim());
doc.font('Helvetica').fontSize(8).fillColor('#444444');
for (const task of taskLines) { for (const task of taskLines) {
if (yRight > pageBottom) { if (yRight > pageBottom) {
doc.addPage(); doc.addPage();
@ -539,12 +538,21 @@ export class ProfileExportService {
pageBreakOccurred = true; pageBreakOccurred = true;
} }
const raw = task.trim(); const raw = task.trim();
const isBold = /^\*\*/.test(raw);
const hasBullet = /^[•\u2022]\s/.test(raw) || /^\d+\.\s/.test(raw); const hasBullet = /^[•\u2022]\s/.test(raw) || /^\d+\.\s/.test(raw);
const cleaned = this.normalizeTaskLine(raw); const cleaned = this.normalizeTaskLine(raw).replace(/\t/g, ' ');
if (!cleaned) continue; if (!cleaned) continue;
const displayText = `\u2022 ${this.sanitizePdfText(hasBullet ? cleaned : cleaned)}`; const sanitized = this.sanitizePdfText(cleaned);
doc.text(displayText, contentX + 4, yRight, { width: contentWidth - 8 }); if (isBold) {
yRight += doc.heightOfString(displayText, { width: contentWidth - 8 }) + 1; doc.font('Helvetica-Bold').fontSize(8).fillColor('#333333');
doc.text(sanitized, contentX + 4, yRight, { width: contentWidth - 8 });
yRight += doc.heightOfString(sanitized, { width: contentWidth - 8 }) + 1;
} else {
const displayText = hasBullet ? `\u2022 ${sanitized}` : sanitized;
doc.font('Helvetica').fontSize(8).fillColor('#444444');
doc.text(displayText, contentX + 4, yRight, { width: contentWidth - 8 });
yRight += doc.heightOfString(displayText, { width: contentWidth - 8 }) + 1;
}
} }
} }
@ -609,7 +617,8 @@ export class ProfileExportService {
for (const att of pdfAttachments) { for (const att of pdfAttachments) {
try { try {
const attBuffer = this.base64ToBuffer(att.data); const attBuffer = this.base64ToBuffer(att.data);
const attPdf = await PdfLib.load(attBuffer); // ignoreEncryption: true erlaubt das Einbetten von PDFs mit Owner-Password-Schutz
const attPdf = await PdfLib.load(attBuffer, { ignoreEncryption: true });
const pageIndices = attPdf.getPageIndices(); const pageIndices = attPdf.getPageIndices();
const copiedPages = await merged.copyPages(attPdf, pageIndices); const copiedPages = await merged.copyPages(attPdf, pageIndices);
for (const page of copiedPages) { for (const page of copiedPages) {
@ -894,15 +903,15 @@ export class ProfileExportService {
const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim()); const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim());
for (const task of taskLines) { for (const task of taskLines) {
const raw = task.trim(); const raw = task.trim();
const isBold = /^\*\*/.test(raw);
const hasBullet = /^[•\u2022]\s/.test(raw) || /^\d+\.\s/.test(raw); const hasBullet = /^[•\u2022]\s/.test(raw) || /^\d+\.\s/.test(raw);
const cleaned = this.normalizeTaskLine(raw); const cleaned = this.normalizeTaskLine(raw).replace(/\t/g, ' ');
if (!cleaned) continue; if (!cleaned) continue;
const displayText = hasBullet ? `\u2022 ${cleaned}` : cleaned;
rightParagraphs.push( rightParagraphs.push(
new Paragraph({ new Paragraph({
children: [ children: isBold
new TextRun({ text: displayText, size: 16, color: '444444' }), ? [new TextRun({ text: cleaned, size: 16, color: '333333', bold: true })]
], : [new TextRun({ text: hasBullet ? `\u2022 ${cleaned}` : cleaned, size: 16, color: '444444' })],
spacing: { after: 20 }, spacing: { after: 20 },
}), }),
); );