From 6ae8b7ad9e580c5b6a7b80dc957dcb2ff4295c45 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 28 Oct 2026 08:37:36 +0100 Subject: [PATCH] =?UTF-8?q?fix(pdf):=20=C3=9Cberweisungs-QR-Code=20=C3=BCb?= =?UTF-8?q?erdeckt=20keine=20Positionen=20und=20Summen=20mehr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reichen Positionen oder Summen auf der letzten Seite in den Bereich des QR-Codes, wird er auf einer eigenen Folgeseite gedruckt. Die MCP-Vorschau meldet das und schlägt einen früheren Seitenumbruch vor. Co-Authored-By: Claude Opus 5.5 --- backend/src/mcp/outgoingDocumentLayout.ts | 27 ++++++++-------- backend/src/utils/pdf.ts | 29 ++++++++++++++--- .../tests/mcpOutgoingDocumentPreview.test.ts | 31 ++++++++++++++++++- 3 files changed, 69 insertions(+), 18 deletions(-) diff --git a/backend/src/mcp/outgoingDocumentLayout.ts b/backend/src/mcp/outgoingDocumentLayout.ts index bc774bb1..d5fce28e 100644 --- a/backend/src/mcp/outgoingDocumentLayout.ts +++ b/backend/src/mcp/outgoingDocumentLayout.ts @@ -49,24 +49,25 @@ export function analyzeOutgoingDocumentLayout(layout: InvoicePdfLayout, maxConte if (last?.mode === "title" && page.page < layout.pageCount) { warnings.push({ type: "orphanTitle", page: page.page, message: `Überschrift ${last.pos || last.index + 1} steht allein am Seitenende.`, insertPagebreakBeforeIndex: last.index }) } - if (!page.rows.length && page.page > 1 && !blocks.some((block) => block.page === page.page)) { + if (!page.rows.length && page.page > 1 && page.page !== layout.paymentQr?.page && !blocks.some((block) => block.page === page.page)) { warnings.push({ type: "emptyPage", page: page.page, message: "Die Seite ist leer, vermutlich durch doppelte oder abschließende Seitenumbrüche." }) } } - // The payment QR code is drawn at a fixed spot on the last page, right next to the amount column. + // The renderer moves the payment QR code to an extra page when the amounts reach its spot. const qr = layout.paymentQr - if (qr) { - const covered = layout.rows.find((row) => row.page === qr.page && row.mode !== "pagebreak" && row.bottomMm > qr.topMm) - const totalsCovered = layout.totals?.page === qr.page && layout.totals.bottomMm > qr.topMm - if (covered || totalsCovered) { - warnings.push({ - type: "paymentQrOverlap", - page: qr.page, - message: `Der Überweisungs-QR-Code (ab ${round(qr.topMm)} mm, rechts) überdeckt ${covered ? `Position ${covered.pos || covered.index + 1}` : "den Summenblock"}. Positionen und Summen auf der letzten Seite müssen oberhalb enden.`, - insertPagebreakBeforeIndex: covered && covered.index > 0 ? covered.index : layout.rows.length, - }) - } + const qrAlone = qr && !blocks.some((block) => block.page === qr.page) + if (qr && qrAlone) { + const previous = pages[qr.page - 2] + const reaching = previous?.rows.find((row) => row.bottomMm > qr.topMm) + let before = reaching ? previous.rows.indexOf(reaching) : -1 + while (before > 0 && previous.rows[before - 1].mode === "title") before -= 1 + warnings.push({ + type: "paymentQrOwnPage", + page: qr.page, + message: `Der Überweisungs-QR-Code steht allein auf Seite ${qr.page}, weil Positionen oder Summen auf Seite ${qr.page - 1} tiefer als ${round(qr.topMm)} mm reichen. Ein früherer Seitenumbruch hält ihn bei den Summen.`, + insertPagebreakBeforeIndex: before > 0 ? previous.rows[before].index : layout.rows.length, + }) } for (const [name, block] of [["Summenblock", layout.totals], ["Schlusstext", layout.endText]] as const) { diff --git a/backend/src/utils/pdf.ts b/backend/src/utils/pdf.ts index a7bea5fc..1a6d07f3 100644 --- a/backend/src/utils/pdf.ts +++ b/backend/src/utils/pdf.ts @@ -163,8 +163,14 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi - paymentQrLayout.sizeMm * mm } + // Top edge (mm from the top) of the QR label at its fixed bottom-right spot. + const getPaymentQrTopMm = (page: any) => page.getHeight() / mm + - (paymentQrLayout.bottomMarginMm + paymentQrLayout.sizeMm + paymentQrLayout.labelGapMm) - 8 / mm + // Set once the amounts are placed: the QR moves to its own page if it would cover them. + let paymentQrOnNewPage = false + const getTextWidthBeforePaymentQr = (page: any, startX: number) => { - if (!paymentQrCode) return 500 + if (!paymentQrCode || paymentQrOnNewPage) return 500 return Math.max( 240, @@ -902,6 +908,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi let endTextDiff = 35 const layoutContentEnd = rowHeight + // Visible bottom of the last row (rowHeight is the next baseline); totals extend it below. + let amountsBottomMm = rowHeight - 3 if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) { pages[pageCounter - 1].drawLine({ @@ -1016,6 +1024,11 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi } + // Totals are baselines 8 mm apart; tax notes (13b/19/12.3) add one more line below. + const totalLines = invoiceData.totalArray.length + (["13b UStG", "19 UStG", "12.3 UStG"].includes(invoiceData.taxType) ? 1 : 0) + amountsBottomMm = rowHeight + (totalLines - 1) * 8 + 1 + paymentQrOnNewPage = Boolean(paymentQrCode) && amountsBottomMm + 2 > getPaymentQrTopMm(pages[pageCounter - 1]) + const endTextPosition = getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1) if (layout) { @@ -1023,9 +1036,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi const lines = String(invoiceData.endText || "").split("\n") .reduce((sum, line) => sum + Math.max(1, Math.ceil(font.widthOfTextAtSize(line, 10) / maxWidth)), 0) const topMm = rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8 - // Totals are baselines 8 mm apart; tax notes (13b/19/12.3) add one more line below. - const totalLines = invoiceData.totalArray.length + (["13b UStG", "19 UStG", "12.3 UStG"].includes(invoiceData.taxType) ? 1 : 0) - layout.totals = { page: pageCounter, topMm: layoutContentEnd, bottomMm: rowHeight + (totalLines - 1) * 8 + 1 } + layout.totals = { page: pageCounter, topMm: layoutContentEnd, bottomMm: amountsBottomMm } // drawText places the baseline of the first line; 10 pt line height ≈ 3.53 mm. layout.endText = { page: pageCounter, topMm: topMm - 3.53, bottomMm: topMm + (lines - 1) * 3.53 + 1 } } @@ -1041,6 +1052,16 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi } if (paymentQrCode) { + // Invoice-like documents decide this before placing the end text. + if (deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) { + paymentQrOnNewPage = amountsBottomMm + 2 > getPaymentQrTopMm(pages[pages.length - 1]) + } + if (paymentQrOnNewPage) { + // Positions or totals reach the QR area; never draw the code over amounts. + const qrPage = pdfDoc.addPage() + qrPage.drawPage(secondPageBackground, { x: 0, y: 0 }) + pages.push(qrPage) + } const lastPage = pages[pages.length - 1] const qrSize = paymentQrLayout.sizeMm * mm const bottomMargin = paymentQrLayout.bottomMarginMm * mm diff --git a/backend/tests/mcpOutgoingDocumentPreview.test.ts b/backend/tests/mcpOutgoingDocumentPreview.test.ts index 43c5d964..f0318465 100644 --- a/backend/tests/mcpOutgoingDocumentPreview.test.ts +++ b/backend/tests/mcpOutgoingDocumentPreview.test.ts @@ -77,7 +77,9 @@ test("rendered preview records rows per page and rasterizes pages", { skip: !has const data = buildOutgoingDocumentPdfData(document(rows), context) const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 } const pdf: any = await createInvoicePDF({} as any, "base64", data, "letterhead.pdf", layout) - assert.equal(layout.pageCount, 2) + // Rows overflow page 2, so the payment QR code gets an extra third page. + assert.equal(layout.pageCount, 3) + assert.equal(layout.paymentQr!.page, 3) assert.equal(layout.rows.find((row) => row.index === 13)!.page, 2) assert.ok(layout.endText && layout.totals) const analysis = analyzeOutgoingDocumentLayout(layout) @@ -94,3 +96,30 @@ test("rendered preview records rows per page and rasterizes pages", { skip: !has test("preview tool is read-only for outgoing documents", () => { assert.deepEqual(mcpToolMap.get("accounting.outgoing_documents.preview")!.requiredPermissions, ["accounting.outgoing_documents.read"]) }) + +test("payment QR code moves to its own page instead of covering amounts", { skip: !hasPdftoppm && "pdftoppm fehlt" }, async (t) => { + const letterhead = await PDFDocument.create() + letterhead.addPage([595.28, 841.89]).drawText("Briefpapier", { x: 40, y: 40, size: 8 }) + const background = Buffer.from(await letterhead.save()) + await initS3() + t.mock.method(s3, "send", async () => ({ Body: Readable.from([background]) })) + const render = async (count: number) => { + const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 } + await createInvoicePDF({} as any, "base64", buildOutgoingDocumentPdfData(document(positions(count)), context), "letterhead.pdf", layout) + return layout + } + + const short = await render(3) + assert.equal(short.pageCount, 1) + assert.equal(short.paymentQr!.page, 1) + assert.ok(short.totals!.bottomMm < short.paymentQr!.topMm) + + const long = await render(6) + assert.equal(long.pageCount, 2) + assert.equal(long.paymentQr!.page, 2) + assert.ok(long.rows.every((row) => row.page === 1)) + const analysis = analyzeOutgoingDocumentLayout(long) + const warning = analysis.warnings.find((item) => item.type === "paymentQrOwnPage") + assert.ok(warning, JSON.stringify(analysis.warnings)) + assert.ok(!analysis.warnings.some((item) => item.type === "emptyPage")) +})