fix(core): PDF-Export Seitenumbruch und Sonderzeichen repariert

- pageBottom von 800 auf 740 reduziert: verhindert PDFKit-Auto-Seitenumbrüche
  mitten in Projekt-Einträgen (bisher: Datum/Rolle/Firma je auf eigener Seite)
- Vor jedem Projekt-Eintrag Header-Höhe (Datum+Rolle+Firma) vorberechnen und
  Seitenumbruch proaktiv auslösen, bevor der Header gezeichnet wird
- sanitizePdfText() Hilfsmethode: ersetzt €→EUR sowie Zeichen außerhalb Latin-1
  die Helvetica (WinAnsiEncoding) nicht rendern kann (bisher: Zeichensalat)
- sanitizePdfText auf Projekt-Texte und Zertifizierungs-Texte angewendet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-14 11:08:56 +01:00
parent 90a0388b22
commit 9d7dcaaaea

View file

@ -187,6 +187,19 @@ export class ProfileExportService {
.trim();
}
/**
* Ersetzt Sonderzeichen, die PDFKit's eingebauter Helvetica-Font (WinAnsiEncoding)
* nicht darstellen kann (z.B. -Zeichen Zeichensalat).
*/
private sanitizePdfText(text: string): string {
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, '?');
}
// ============================================================
// PDF Export
// ============================================================
@ -248,7 +261,9 @@ export class ProfileExportService {
const leftColX = 40;
const rightColX = leftColX + leftColWidth + 20;
const rightColWidth = pageWidth - rightColX - 40;
const pageBottom = 800;
// pageBottom deutlich unter der echten Seitengrenze (~841px) halten,
// damit PDFKit keine automatischen Seitenumbrüche mitten in Einträgen auslöst
const pageBottom = 740;
let yLeft = 40;
let yRight = 40;
@ -388,12 +403,12 @@ export class ProfileExportService {
// Titel
doc.font('Helvetica-Bold').fontSize(9).fillColor(accentColor);
doc.text(cert.title, certContentX, yLeft, { width: certContentWidth });
yLeft += doc.heightOfString(cert.title, { width: certContentWidth }) + 2;
doc.text(this.sanitizePdfText(cert.title), certContentX, yLeft, { width: certContentWidth });
yLeft += doc.heightOfString(this.sanitizePdfText(cert.title), { width: certContentWidth }) + 2;
// Zertifizierungsstelle
doc.font('Helvetica').fontSize(8).fillColor('#555555');
doc.text(cert.issuingBody, certContentX, yLeft, { width: certContentWidth });
doc.text(this.sanitizePdfText(cert.issuingBody), certContentX, yLeft, { width: certContentWidth });
yLeft += 11;
yLeft += 4;
@ -445,8 +460,21 @@ export class ProfileExportService {
for (let i = 0; i < profile.projects.length; i++) {
const proj = profile.projects[i];
// Seitenumbruch prüfen
if (yRight > pageBottom) {
// Vor jedem Eintrag: Höhe des Headers schätzen (Datum + Rolle + Firma)
// damit der komplette Header auf einer Seite bleibt
const dateRange = this.formatDateRange(proj.fromMonth, proj.fromYear, proj.toMonth, proj.toYear, proj.isCurrent);
const roleText = this.sanitizePdfText(proj.role);
const companyLine = proj.company
? this.sanitizePdfText([proj.company, proj.industry].filter(Boolean).join(' \u00b7 '))
: '';
doc.font('Helvetica-Bold').fontSize(10);
const roleH = doc.heightOfString(roleText, { width: contentWidth });
doc.fontSize(9);
const companyH = companyLine ? doc.heightOfString(companyLine, { width: contentWidth }) + 2 : 0;
const headerH = 14 + roleH + 2 + companyH + 8; // Datum + Rolle + Firma + Puffer
// Seitenumbruch wenn Header nicht mehr passt
if (yRight + headerH > pageBottom) {
doc.addPage();
yRight = 40;
yRight = this.pdfSectionTitle(doc, 'BERUFSERFAHRUNG / PROJEKTE (Forts.)', rightColX, yRight, rightColWidth, accentColor);
@ -459,19 +487,17 @@ export class ProfileExportService {
doc.circle(timelineX, entryStartY + 4, 3.5).fill(accentColor);
// Zeitraum (fett, größer)
const dateRange = this.formatDateRange(proj.fromMonth, proj.fromYear, proj.toMonth, proj.toYear, proj.isCurrent);
doc.font('Helvetica-Bold').fontSize(10).fillColor('#555555');
doc.text(dateRange, contentX, yRight, { width: contentWidth });
doc.text(this.sanitizePdfText(dateRange), contentX, yRight, { width: contentWidth });
yRight += 14;
// Rolle
doc.font('Helvetica-Bold').fontSize(10).fillColor(accentColor);
doc.text(proj.role, contentX, yRight, { width: contentWidth });
yRight += doc.heightOfString(proj.role, { width: contentWidth }) + 2;
doc.text(roleText, contentX, yRight, { width: contentWidth });
yRight += doc.heightOfString(roleText, { width: contentWidth }) + 2;
// Firma + Branche (fett)
if (proj.company) {
const companyLine = [proj.company, proj.industry].filter(Boolean).join(' · ');
if (companyLine) {
doc.font('Helvetica-Bold').fontSize(9).fillColor('#333333');
doc.text(companyLine, contentX, yRight, { width: contentWidth });
yRight += doc.heightOfString(companyLine, { width: contentWidth }) + 2;
@ -491,7 +517,7 @@ export class ProfileExportService {
const hasBullet = /^[•\u2022]\s/.test(raw) || /^\d+\.\s/.test(raw);
const cleaned = this.normalizeTaskLine(raw);
if (!cleaned) continue;
const displayText = hasBullet ? `\u2022 ${cleaned}` : cleaned;
const displayText = `\u2022 ${this.sanitizePdfText(hasBullet ? cleaned : cleaned)}`;
doc.text(displayText, contentX + 4, yRight, { width: contentWidth - 8 });
yRight += doc.heightOfString(displayText, { width: contentWidth - 8 }) + 1;
}