From e22f2cfa2bba6b673aaf2bf5b470cbdce6ab25c2 Mon Sep 17 00:00:00 2001 From: kelle Date: Tue, 21 Jul 2026 09:15:01 +0200 Subject: [PATCH] Fix: PDF-Bericht -- CJS-Interop, Leerseiten und sichtbare Fehler 1) pdfkit wird als externes CJS-Modul geladen. Je nach Interop kommt der Konstruktor direkt oder unter .default an -- trifft man die falsche Form, gelingt der Import, aber `new PDFDocument()` scheitert erst zur Laufzeit (passt zum gemeldeten 500: unauth. Aufruf gab 401, die Erzeugung 500). Beide Formen werden jetzt akzeptiert. 2) Die Fusszeile stand unterhalb des Satzspiegels -- pdfkit haengt dafuer automatisch Seiten an. Ein Bericht mit 6 Inhaltsseiten wurde so auf 18 aufgeblaeht. Unterer Rand wird fuers Schreiben auf 0 gesetzt; ein Test prueft jetzt die Seitenzahl im FERTIGEN PDF, nicht davor. 3) Rendern und Speichern melden ihre Ursache statt eines nackten 500. Spezifikation unveraendert, 1 Test ergaenzt (221 -> 222). Co-Authored-By: Claude Opus 4.8 --- src/app/api/plans/[planId]/reports/route.ts | 50 ++++++++++++++------- src/lib/report-pdf.ts | 30 +++++++++---- src/lib/report.test.ts | 12 +++++ 3 files changed, 69 insertions(+), 23 deletions(-) diff --git a/src/app/api/plans/[planId]/reports/route.ts b/src/app/api/plans/[planId]/reports/route.ts index f2454ad..dc6e68e 100644 --- a/src/app/api/plans/[planId]/reports/route.ts +++ b/src/app/api/plans/[planId]/reports/route.ts @@ -131,20 +131,40 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ analyses: analyses.map((a) => ({ name: a.name, type: a.type, result: a.result })), }); - const pdf = await renderReportPdf(model); + // Rendern und Ablegen sind die beiden Schritte, die zur Laufzeit scheitern können + // (Schriftdaten, Grösse des Datensatzes). Ein nacktes 500 wäre hier nicht diagnostizierbar, + // deshalb wird die Ursache protokolliert UND zurückgegeben -- es ist ein Ein-Personen- + // Werkzeug, und der Nutzer ist der Einzige, der den Fehler melden kann. + let pdf: Buffer; + try { + pdf = await renderReportPdf(model); + } catch (err) { + console.error("[reports] PDF-Erzeugung fehlgeschlagen", err); + return NextResponse.json( + { error: `PDF-Erzeugung fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}` }, + { status: 500 } + ); + } - const created = await prisma.report.create({ - data: { - planId, - title: cfg.title, - config: config as unknown as object, - model: model as unknown as object, - pdf: new Uint8Array(pdf), - pdfBytes: pdf.length, - createdById: userId, - }, - select: { id: true }, - }); - - return NextResponse.json({ report: { id: created.id, bytes: pdf.length } }, { status: 201 }); + try { + const created = await prisma.report.create({ + data: { + planId, + title: cfg.title, + config: config as unknown as object, + model: JSON.parse(JSON.stringify(model)) as object, + pdf: new Uint8Array(pdf), + pdfBytes: pdf.length, + createdById: userId, + }, + select: { id: true }, + }); + return NextResponse.json({ report: { id: created.id, bytes: pdf.length } }, { status: 201 }); + } catch (err) { + console.error("[reports] Speichern fehlgeschlagen", err); + return NextResponse.json( + { error: `Bericht konnte nicht gespeichert werden: ${err instanceof Error ? err.message : String(err)}` }, + { status: 500 } + ); + } } diff --git a/src/lib/report-pdf.ts b/src/lib/report-pdf.ts index 1b3d16a..311892f 100644 --- a/src/lib/report-pdf.ts +++ b/src/lib/report-pdf.ts @@ -7,9 +7,16 @@ // Die eingebauten Schriften (Helvetica) decken WinAnsi ab und damit alle deutschen // Umlaute; ein Font-Embedding ist nicht nötig. -import PDFDocument from "pdfkit"; +import PDFDocumentModule from "pdfkit"; import type { KeyFigure, ReportChart, ReportModel, ReportTable } from "@/lib/report"; +// pdfkit ist CommonJS und wird als EXTERNES Paket geladen (siehe next.config.ts). Je nach +// Interop des Bundlers kommt der Konstruktor direkt oder unter `.default` an. Trifft man die +// falsche Form, gelingt der Import trotzdem -- und erst `new PDFDocument()` scheitert zur +// Laufzeit. Deshalb hier beide Formen akzeptieren. +const PDFDocument = ((PDFDocumentModule as unknown as { default?: typeof PDFDocumentModule }).default ?? + PDFDocumentModule) as typeof PDFDocumentModule; + const A4 = { width: 595.28, height: 841.89 }; const M = 56; // Seitenrand const CONTENT = A4.width - 2 * M; @@ -332,24 +339,31 @@ export function renderReportPdf(model: ReportModel): Promise { } // Fusszeile mit Seitenzahlen auf allen Seiten. + // + // Die Fusszeile steht bewusst UNTERHALB des Satzspiegels. pdfkit fügt in dem Fall + // automatisch eine neue Seite an -- was den Bericht mit leeren Seiten flutet (je Seite + // zwei zusätzliche). Deshalb wird der untere Rand für das Schreiben auf 0 gesetzt und + // danach wiederhergestellt. const range = doc.bufferedPageRange(); + const footerY = A4.height - M + 12; for (let i = range.start; i < range.start + range.count; i++) { doc.switchToPage(i); + const keep = doc.page.margins.bottom; + doc.page.margins.bottom = 0; doc .fillColor(COLORS.faint) .font("Helvetica") .fontSize(7) - .text( - `${model.meta.planName} · ${model.meta.title} · ${created}`, - M, - A4.height - M + 12, - { width: CONTENT - 40, lineBreak: false } - ); - doc.text(`${i - range.start + 1} / ${range.count}`, M + CONTENT - 40, A4.height - M + 12, { + .text(`${model.meta.planName} · ${model.meta.title} · ${created}`, M, footerY, { + width: CONTENT - 40, + lineBreak: false, + }); + doc.text(`${i - range.start + 1} / ${range.count}`, M + CONTENT - 40, footerY, { width: 40, align: "right", lineBreak: false, }); + doc.page.margins.bottom = keep; } doc.end(); diff --git a/src/lib/report.test.ts b/src/lib/report.test.ts index aa49fa6..87757a9 100644 --- a/src/lib/report.test.ts +++ b/src/lib/report.test.ts @@ -185,6 +185,18 @@ describe("renderReportPdf", () => { expect(pdf.length).toBeGreaterThan(5000); }, 30000); + it("hängt keine leeren Seiten an", async () => { + // Die Fusszeile steht unterhalb des Satzspiegels; ohne Vorkehrung fügt pdfkit dafür je + // Seite neue Seiten an und der Bericht füllt sich mit Leerseiten. + const m = buildReport(input()); + const pdf = await renderReportPdf(m); + const s = pdf.toString("latin1"); + const pages = (s.match(/\/Type\s*\/Page[^s]/g) ?? []).length; + // Ein Einzelszenario ergibt Deckblatt + Szenario + Hinweise. + expect(pages).toBeGreaterThanOrEqual(3); + expect(pages).toBeLessThanOrEqual(5); + }, 30000); + it("kommt auch mit einem leeren Plan zurecht, statt zu werfen", async () => { // Robustheit: Ein Plan ohne Phasen darf keinen Absturz erzeugen. const leer = { ...plan("Leer"), phases: [], elements: [] } as unknown as PlanInput;