From 268a947c85f0490bd0ca1b15346e1591d87e6738 Mon Sep 17 00:00:00 2001 From: BOHA Date: Wed, 10 Jun 2026 17:44:11 +0200 Subject: [PATCH] feat(issued-orders-pdf): true per-page footer (Vystavil + Strana X z Y), sections flow inline The PDF now carries a real repeating footer on EVERY page via Puppeteer's footerTemplate (Chromium has no CSS margin-box support, so this is the only true footer): 'Vystavil: ' on the left, 'Strana X z Y' / 'Page X of Y' centered, localized by the document language. htmlToPdf gained an optional footerTemplate option (empty header suppresses Chromium's default; bottom margin grows to 18mm). The old body footer pinned by flex min-height is gone. The 'Obsah' sections no longer start a new page with a repeated header - they flow inline right after the items/totals block and paginate naturally (break-inside: avoid per section). Applied at all three render sites (PDF route, ?save=1 archive, /file fallback). Co-Authored-By: Claude Fable 5 --- src/__tests__/issued-orders.test.ts | 55 +++++++++---- src/routes/admin/issued-orders-pdf.ts | 112 ++++++++++++-------------- src/routes/admin/issued-orders.ts | 9 ++- src/utils/html-to-pdf.ts | 32 +++++++- 4 files changed, 129 insertions(+), 79 deletions(-) diff --git a/src/__tests__/issued-orders.test.ts b/src/__tests__/issued-orders.test.ts index 9255b38..c5aa6fa 100644 --- a/src/__tests__/issued-orders.test.ts +++ b/src/__tests__/issued-orders.test.ts @@ -29,6 +29,7 @@ import { import issuedOrdersRoutes from "../routes/admin/issued-orders"; import issuedOrdersPdfRoutes, { renderIssuedOrderHtml, + buildIssuedOrderPdfFooter, } from "../routes/admin/issued-orders-pdf"; import { nasOrdersManager } from "../services/nas-financials-manager"; import { htmlToPdf } from "../utils/html-to-pdf"; @@ -793,15 +794,32 @@ describe("renderIssuedOrderHtml", () => { expect(en).not.toContain("Amounts are in"); }); - it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => { + it("body carries NO footer — Vystavil moved to the Puppeteer footer template", () => { const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer); - // Footer: Vystavil from authData. No e-mail line. - expect(html).toContain("Jan Novák"); + // The issuer prints in the per-page footerTemplate, never in the body. + expect(html).not.toContain("Jan Novák"); + expect(html).not.toContain("Vystavil:"); + expect(html).not.toContain("invoice-footer"); expect(html).not.toContain("E-mail:"); - // The old two-column signature footer is gone. - expect(html).not.toContain("footer-row"); expect(html).not.toContain("Schválil:"); }); + + it("buildIssuedOrderPdfFooter: Vystavil left, page counter centered, per language", () => { + const cs = buildIssuedOrderPdfFooter("cs", "Jan Novák"); + expect(cs).toContain("Vystavil:"); + expect(cs).toContain("Jan Novák"); + expect(cs).toContain('Strana '); + expect(cs).toContain('z '); + + const en = buildIssuedOrderPdfFooter("en", "Jan Novák"); + expect(en).toContain("Issued by:"); + expect(en).toContain('Page '); + expect(en).toContain('of '); + + // Escaping: a malicious issuer name must not break out of the template. + const evil = buildIssuedOrderPdfFooter("cs", ''); + expect(evil).not.toContain(" { ]; const issuer = { name: "Jan Novák" }; - it("renders the scope page: cs prefers title_cz, sanitizes content, repeats the PO header", () => { + it("renders sections INLINE after the items (no new page, no repeated header); cs prefers title_cz, sanitizes content", () => { const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer, [ { title: "Scope EN", @@ -1210,7 +1228,19 @@ describe("renderIssuedOrderHtml sections (scope page)", () => { }, { title: "Only EN", title_cz: null, content: null }, ]); - expect(html).toContain('class="scope-page"'); + expect(html).toContain('class="scope-sections"'); + // No separate page: no page-break wrapper, header NOT repeated + // ( + the one page header = exactly 2 occurrences). + expect(html).not.toContain("scope-page"); + expect(html).not.toContain("page-break-before"); + expect(html.split("OBJEDNÁVKA č. 26720001").length - 1).toBe(2); + // Sections flow INSIDE the content block, after the items/totals. + expect(html.indexOf('class="scope-sections"')).toBeGreaterThan( + html.indexOf("total-cell"), + ); + expect(html.indexOf('class="scope-sections"')).toBeLessThan( + html.indexOf("/.invoice-content"), + ); // cs → title_cz preferred when non-empty… expect(html).toContain("Rozsah CZ"); expect(html).not.toContain("Scope EN"); @@ -1219,9 +1249,6 @@ describe("renderIssuedOrderHtml sections (scope page)", () => { expect(html).toContain("Obsah"); expect(html).not.toContain("<script>"); expect(html).not.toContain("alert(1)"); - // The PO header block repeats on the scope page: <title> + main page - // header + scope page header = 3 occurrences (2 without the scope page). - expect(html.split("OBJEDNÁVKA č. 26720001").length - 1).toBe(3); }); it("uses the EN title for lang=en", () => { @@ -1232,15 +1259,15 @@ describe("renderIssuedOrderHtml sections (scope page)", () => { expect(html).not.toContain("Rozsah CZ"); }); - it("suppresses the whole page when every section is empty (tag-stripped)", () => { + it("suppresses the sections block when every section is empty (tag-stripped)", () => { const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer, [ { title: "", title_cz: " ", content: "<p><br></p>" }, ]); - expect(html).not.toContain('class="scope-page"'); + expect(html).not.toContain('class="scope-sections"'); }); - it("omits the scope page when there are no sections at all", () => { + it("omits the sections block when there are no sections at all", () => { const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer); - expect(html).not.toContain('class="scope-page"'); + expect(html).not.toContain('class="scope-sections"'); }); }); diff --git a/src/routes/admin/issued-orders-pdf.ts b/src/routes/admin/issued-orders-pdf.ts index 5ad4553..b341a1e 100644 --- a/src/routes/admin/issued-orders-pdf.ts +++ b/src/routes/admin/issued-orders-pdf.ts @@ -212,13 +212,37 @@ function stripTags(html: string | null | undefined): string { return (html || "").replace(/<[^>]*>/g, "").trim(); } +/** + * Repeating per-page footer for the issued-order PDF, rendered by Puppeteer + * in the bottom page margin (the only true repeating footer Chromium + * supports): "Vystavil: <name>" left, "Strana X z Y" / "Page X of Y" + * (by document language) centered. Styles must stay inline and the + * font-size explicit — Chromium defaults footer text to 0. + */ +export function buildIssuedOrderPdfFooter( + lang: Lang, + issuerName: string, +): string { + const t = translations[lang]; + const pageWord = lang === "cs" ? "Strana" : "Page"; + const ofWord = lang === "cs" ? "z" : "of"; + return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 10mm; box-sizing:border-box; display:flex; align-items:center;"> + <div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div> + <div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div> + <div style="flex:1;"></div> + </div>`; +} + export function renderIssuedOrderHtml( order: IssuedOrderPdfData, items: IssuedOrderPdfItem[], supplier: Record<string, unknown> | null, settings: Record<string, unknown> | null, lang: Lang, - issuer: { name: string }, + // Kept for signature stability across the many call sites: the issuer no + // longer renders in the BODY — it prints in the Puppeteer footer template + // (buildIssuedOrderPdfFooter) on every page. + _issuer: { name: string }, sections: IssuedOrderPdfSection[] = [], ): string { const t = translations[lang]; @@ -283,19 +307,20 @@ export function renderIssuedOrderHtml( // Quill indent CSS const indentCSS = buildQuillIndentCss(); - // ── Sections (scope) page — mirrors offers-pdf's .scope-page, but repeats - // THIS template's red PO header (logo + heading + dates), not the offers' - // monochrome one. The whole page is suppressed when every section is empty - // (tag-stripped check, so an empty-Quill "<p><br></p>" doesn't count). - // cs prefers the Czech title when present; en (or a missing Czech title) - // falls back to the EN title (same rule as offers-pdf). + // ── Sections ("Obsah") — flow INLINE after the items/totals block (user + // decision: no separate page, no repeated header). Each section keeps + // break-inside: avoid; long content paginates naturally and the Puppeteer + // footer template stamps every page. Suppressed entirely when every + // section is empty (tag-stripped check, so an empty-Quill "<p><br></p>" + // doesn't count). cs prefers the Czech title when present; en (or a + // missing Czech title) falls back to the EN title (same rule as offers). const resolveSectionTitle = (s: IssuedOrderPdfSection): string => lang === "cs" && (s.title_cz || "").trim() ? s.title_cz || "" : s.title || ""; // Visibility must use the SAME per-language title rule as the render loop — // otherwise a section with only the other language's title would count as - // content and produce a scope page holding nothing but the header. + // content and produce an empty sections block. const visibleSections = sections.filter( (s) => resolveSectionTitle(s).trim() || stripTags(s.content), ); @@ -303,20 +328,7 @@ export function renderIssuedOrderHtml( let scopeHtml = ""; if (hasSectionContent) { - const scopeDates = `<div class="scope-dates"><span class="lbl">${escapeHtml(t.issue_date)}</span> <span class="val">${escapeHtml(issueDateStr) || "—"}</span>${ - deliveryDateStr - ? ` <span class="sep">·</span> <span class="lbl">${escapeHtml(t.delivery_date)}</span> <span class="val">${escapeHtml(deliveryDateStr)}</span>` - : "" - }</div>`; - scopeHtml += '<div class="scope-page">'; - scopeHtml += ` - <div class="invoice-header"> - <div class="left"> - ${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""} - </div> - <div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div> - </div> - ${scopeDates}`; + scopeHtml += '<div class="scope-sections">'; for (const section of visibleSections) { const title = resolveSectionTitle(section); const content = (section.content || "").trim(); @@ -348,15 +360,9 @@ export function renderIssuedOrderHtml( width: 186mm; } - .invoice-page { - display: flex; - flex-direction: column; - min-height: calc(297mm - 27mm); - } - .invoice-content { flex: 1 1 auto; } - .invoice-footer { - flex-shrink: 0; - } + /* The repeating "Vystavil / Strana X z Y" footer lives in Puppeteer's + footerTemplate (bottom page margin), NOT in the body — the old flex + min-height pinning is gone with it. */ .accent { color: #de3a3a; } @@ -561,14 +567,6 @@ export function renderIssuedOrderHtml( border-bottom: 2.5pt solid #de3a3a; padding-bottom: 1mm; } - /* Vystavil */ - .issued-by { - font-size: 9pt; - margin: 2mm 0; - line-height: 1.4; - } - .issued-by .lbl { font-weight: 600; } - /* Upozorneni */ .notice { font-size: 8pt; @@ -577,18 +575,12 @@ export function renderIssuedOrderHtml( line-height: 1.3; } - /* Sekce (scope) na vlastni strance — zrcadli offers-pdf .scope-page, - hlavicka se opakuje v cervenem stylu teto sablony */ - .scope-page { - page-break-before: always; + /* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka, + dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou + stranu. */ + .scope-sections { + margin-top: 6mm; } - .scope-dates { - font-size: 9pt; - color: #666; - margin: 1mm 0 4mm 0; - } - .scope-dates .val { font-weight: 600; color: #1a1a1a; } - .scope-dates .sep { color: #d0d0d0; } .scope-section { width: 100%; max-width: 100%; @@ -644,7 +636,7 @@ ${indentCSS} min-height: 100vh; overflow-x: hidden; } - .invoice-page, .scope-page { + .invoice-page { width: 210mm; min-height: 297mm; padding: 15mm; @@ -720,17 +712,11 @@ ${indentCSS} </div> </div> + ${scopeHtml} + </div><!-- /.invoice-content --> -<div class="invoice-footer"> - - <!-- Vystavil (jmeno prihlaseneho uzivatele) --> - <div class="issued-by"><span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuer.name)}</div> - -</div><!-- /.invoice-footer --> </div><!-- /.invoice-page --> -${scopeHtml} - </body> </html>`; } @@ -808,7 +794,9 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) { ? new Date(order.order_date) : new Date(); nasOrdersManager.cleanIssued(order.po_number); - const pdfBuffer = await htmlToPdf(html); + const pdfBuffer = await htmlToPdf(html, { + footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name), + }); nasOrdersManager.saveIssuedPdf( order.po_number, baseDate.getFullYear(), @@ -818,7 +806,9 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) { return success(reply, null, 200, "PDF uloženo"); } - const pdf = await htmlToPdf(html); + const pdf = await htmlToPdf(html, { + footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name), + }); const filename = `Objednavka-${order.po_number || String(id)}.pdf`; return reply .type("application/pdf") diff --git a/src/routes/admin/issued-orders.ts b/src/routes/admin/issued-orders.ts index b20ea6b..a6d749a 100644 --- a/src/routes/admin/issued-orders.ts +++ b/src/routes/admin/issued-orders.ts @@ -18,7 +18,10 @@ import { deleteIssuedOrder, getNextIssuedOrderNumberPreview, } from "../../services/issued-orders.service"; -import { renderIssuedOrderHtml } from "./issued-orders-pdf"; +import { + renderIssuedOrderHtml, + buildIssuedOrderPdfFooter, +} from "./issued-orders-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf"; import { nasOrdersManager } from "../../services/nas-financials-manager"; import { contentDisposition } from "../../utils/content-disposition"; @@ -306,7 +309,9 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) { issuer, sections, ); - const pdf = await htmlToPdf(html); + const pdf = await htmlToPdf(html, { + footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name), + }); if (nasOrdersManager.isConfigured()) { const baseDate = order.order_date diff --git a/src/utils/html-to-pdf.ts b/src/utils/html-to-pdf.ts index 5760ecc..5fa8746 100644 --- a/src/utils/html-to-pdf.ts +++ b/src/utils/html-to-pdf.ts @@ -63,7 +63,23 @@ async function getBrowser(): Promise<Browser> { } } -export async function htmlToPdf(html: string): Promise<Buffer> { +export interface HtmlToPdfOptions { + /** + * Chromium footerTemplate HTML rendered in the bottom margin of EVERY page + * — the only true repeating footer Puppeteer supports (CSS @page margin + * boxes are not implemented in Chromium). Styles must be inline and the + * font-size explicit (Chromium defaults it to 0); `.pageNumber` / + * `.totalPages` spans are substituted by Chromium. When set, the bottom + * margin grows to make room and an empty headerTemplate suppresses + * Chromium's default date/title header. + */ + footerTemplate?: string; +} + +export async function htmlToPdf( + html: string, + options: HtmlToPdfOptions = {}, +): Promise<Buffer> { const b = await getBrowser(); const page = await b.newPage(); // Per-request timeouts so one stuck render (e.g. image tag pointing at a @@ -78,7 +94,19 @@ export async function htmlToPdf(html: string): Promise<Buffer> { const pdf = await page.pdf({ format: "A4", printBackground: true, - margin: { top: "10mm", bottom: "10mm", left: "10mm", right: "10mm" }, + margin: { + top: "10mm", + bottom: options.footerTemplate ? "18mm" : "10mm", + left: "10mm", + right: "10mm", + }, + ...(options.footerTemplate + ? { + displayHeaderFooter: true, + headerTemplate: "<span></span>", + footerTemplate: options.footerTemplate, + } + : {}), timeout: 15_000, }); return Buffer.from(pdf);