Fix: PDF-Bericht -- CJS-Interop, Leerseiten und sichtbare Fehler
Deploy App / deploy (push) Successful in 1m10s
Deploy App / deploy (push) Successful in 1m10s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 })),
|
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({
|
try {
|
||||||
data: {
|
const created = await prisma.report.create({
|
||||||
planId,
|
data: {
|
||||||
title: cfg.title,
|
planId,
|
||||||
config: config as unknown as object,
|
title: cfg.title,
|
||||||
model: model as unknown as object,
|
config: config as unknown as object,
|
||||||
pdf: new Uint8Array(pdf),
|
model: JSON.parse(JSON.stringify(model)) as object,
|
||||||
pdfBytes: pdf.length,
|
pdf: new Uint8Array(pdf),
|
||||||
createdById: userId,
|
pdfBytes: pdf.length,
|
||||||
},
|
createdById: userId,
|
||||||
select: { id: true },
|
},
|
||||||
});
|
select: { id: true },
|
||||||
|
});
|
||||||
return NextResponse.json({ report: { id: created.id, bytes: pdf.length } }, { status: 201 });
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-8
@@ -7,9 +7,16 @@
|
|||||||
// Die eingebauten Schriften (Helvetica) decken WinAnsi ab und damit alle deutschen
|
// Die eingebauten Schriften (Helvetica) decken WinAnsi ab und damit alle deutschen
|
||||||
// Umlaute; ein Font-Embedding ist nicht nötig.
|
// 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";
|
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 A4 = { width: 595.28, height: 841.89 };
|
||||||
const M = 56; // Seitenrand
|
const M = 56; // Seitenrand
|
||||||
const CONTENT = A4.width - 2 * M;
|
const CONTENT = A4.width - 2 * M;
|
||||||
@@ -332,24 +339,31 @@ export function renderReportPdf(model: ReportModel): Promise<Buffer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fusszeile mit Seitenzahlen auf allen Seiten.
|
// 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 range = doc.bufferedPageRange();
|
||||||
|
const footerY = A4.height - M + 12;
|
||||||
for (let i = range.start; i < range.start + range.count; i++) {
|
for (let i = range.start; i < range.start + range.count; i++) {
|
||||||
doc.switchToPage(i);
|
doc.switchToPage(i);
|
||||||
|
const keep = doc.page.margins.bottom;
|
||||||
|
doc.page.margins.bottom = 0;
|
||||||
doc
|
doc
|
||||||
.fillColor(COLORS.faint)
|
.fillColor(COLORS.faint)
|
||||||
.font("Helvetica")
|
.font("Helvetica")
|
||||||
.fontSize(7)
|
.fontSize(7)
|
||||||
.text(
|
.text(`${model.meta.planName} · ${model.meta.title} · ${created}`, M, footerY, {
|
||||||
`${model.meta.planName} · ${model.meta.title} · ${created}`,
|
width: CONTENT - 40,
|
||||||
M,
|
lineBreak: false,
|
||||||
A4.height - M + 12,
|
});
|
||||||
{ width: CONTENT - 40, lineBreak: false }
|
doc.text(`${i - range.start + 1} / ${range.count}`, M + CONTENT - 40, footerY, {
|
||||||
);
|
|
||||||
doc.text(`${i - range.start + 1} / ${range.count}`, M + CONTENT - 40, A4.height - M + 12, {
|
|
||||||
width: 40,
|
width: 40,
|
||||||
align: "right",
|
align: "right",
|
||||||
lineBreak: false,
|
lineBreak: false,
|
||||||
});
|
});
|
||||||
|
doc.page.margins.bottom = keep;
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.end();
|
doc.end();
|
||||||
|
|||||||
@@ -185,6 +185,18 @@ describe("renderReportPdf", () => {
|
|||||||
expect(pdf.length).toBeGreaterThan(5000);
|
expect(pdf.length).toBeGreaterThan(5000);
|
||||||
}, 30000);
|
}, 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 () => {
|
it("kommt auch mit einem leeren Plan zurecht, statt zu werfen", async () => {
|
||||||
// Robustheit: Ein Plan ohne Phasen darf keinen Absturz erzeugen.
|
// Robustheit: Ein Plan ohne Phasen darf keinen Absturz erzeugen.
|
||||||
const leer = { ...plan("Leer"), phases: [], elements: [] } as unknown as PlanInput;
|
const leer = { ...plan("Leer"), phases: [], elements: [] } as unknown as PlanInput;
|
||||||
|
|||||||
Reference in New Issue
Block a user