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: <logged-in user>' 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 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
|||||||
import issuedOrdersRoutes from "../routes/admin/issued-orders";
|
import issuedOrdersRoutes from "../routes/admin/issued-orders";
|
||||||
import issuedOrdersPdfRoutes, {
|
import issuedOrdersPdfRoutes, {
|
||||||
renderIssuedOrderHtml,
|
renderIssuedOrderHtml,
|
||||||
|
buildIssuedOrderPdfFooter,
|
||||||
} from "../routes/admin/issued-orders-pdf";
|
} from "../routes/admin/issued-orders-pdf";
|
||||||
import { nasOrdersManager } from "../services/nas-financials-manager";
|
import { nasOrdersManager } from "../services/nas-financials-manager";
|
||||||
import { htmlToPdf } from "../utils/html-to-pdf";
|
import { htmlToPdf } from "../utils/html-to-pdf";
|
||||||
@@ -793,15 +794,32 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
expect(en).not.toContain("Amounts are in");
|
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);
|
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||||
// Footer: Vystavil <name> from authData. No e-mail line.
|
// The issuer prints in the per-page footerTemplate, never in the body.
|
||||||
expect(html).toContain("Jan Novák");
|
expect(html).not.toContain("Jan Novák");
|
||||||
|
expect(html).not.toContain("Vystavil:");
|
||||||
|
expect(html).not.toContain("invoice-footer");
|
||||||
expect(html).not.toContain("E-mail:");
|
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:");
|
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 <span class="pageNumber"></span>');
|
||||||
|
expect(cs).toContain('z <span class="totalPages"></span>');
|
||||||
|
|
||||||
|
const en = buildIssuedOrderPdfFooter("en", "Jan Novák");
|
||||||
|
expect(en).toContain("Issued by:");
|
||||||
|
expect(en).toContain('Page <span class="pageNumber"></span>');
|
||||||
|
expect(en).toContain('of <span class="totalPages"></span>');
|
||||||
|
|
||||||
|
// Escaping: a malicious issuer name must not break out of the template.
|
||||||
|
const evil = buildIssuedOrderPdfFooter("cs", '<img src=x onerror="1">');
|
||||||
|
expect(evil).not.toContain("<img");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
@@ -1201,7 +1219,7 @@ describe("renderIssuedOrderHtml sections (scope page)", () => {
|
|||||||
];
|
];
|
||||||
const issuer = { name: "Jan Novák" };
|
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, [
|
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer, [
|
||||||
{
|
{
|
||||||
title: "Scope EN",
|
title: "Scope EN",
|
||||||
@@ -1210,7 +1228,19 @@ describe("renderIssuedOrderHtml sections (scope page)", () => {
|
|||||||
},
|
},
|
||||||
{ title: "Only EN", title_cz: null, content: null },
|
{ 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
|
||||||
|
// (<title> + 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…
|
// cs → title_cz preferred when non-empty…
|
||||||
expect(html).toContain("Rozsah CZ");
|
expect(html).toContain("Rozsah CZ");
|
||||||
expect(html).not.toContain("Scope EN");
|
expect(html).not.toContain("Scope EN");
|
||||||
@@ -1219,9 +1249,6 @@ describe("renderIssuedOrderHtml sections (scope page)", () => {
|
|||||||
expect(html).toContain("Obsah");
|
expect(html).toContain("Obsah");
|
||||||
expect(html).not.toContain("<script>");
|
expect(html).not.toContain("<script>");
|
||||||
expect(html).not.toContain("alert(1)");
|
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", () => {
|
it("uses the EN title for lang=en", () => {
|
||||||
@@ -1232,15 +1259,15 @@ describe("renderIssuedOrderHtml sections (scope page)", () => {
|
|||||||
expect(html).not.toContain("Rozsah CZ");
|
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, [
|
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer, [
|
||||||
{ title: "", title_cz: " ", content: "<p><br></p>" },
|
{ 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);
|
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||||
expect(html).not.toContain('class="scope-page"');
|
expect(html).not.toContain('class="scope-sections"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -212,13 +212,37 @@ function stripTags(html: string | null | undefined): string {
|
|||||||
return (html || "").replace(/<[^>]*>/g, "").trim();
|
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(
|
export function renderIssuedOrderHtml(
|
||||||
order: IssuedOrderPdfData,
|
order: IssuedOrderPdfData,
|
||||||
items: IssuedOrderPdfItem[],
|
items: IssuedOrderPdfItem[],
|
||||||
supplier: Record<string, unknown> | null,
|
supplier: Record<string, unknown> | null,
|
||||||
settings: Record<string, unknown> | null,
|
settings: Record<string, unknown> | null,
|
||||||
lang: Lang,
|
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[] = [],
|
sections: IssuedOrderPdfSection[] = [],
|
||||||
): string {
|
): string {
|
||||||
const t = translations[lang];
|
const t = translations[lang];
|
||||||
@@ -283,19 +307,20 @@ export function renderIssuedOrderHtml(
|
|||||||
// Quill indent CSS
|
// Quill indent CSS
|
||||||
const indentCSS = buildQuillIndentCss();
|
const indentCSS = buildQuillIndentCss();
|
||||||
|
|
||||||
// ── Sections (scope) page — mirrors offers-pdf's .scope-page, but repeats
|
// ── Sections ("Obsah") — flow INLINE after the items/totals block (user
|
||||||
// THIS template's red PO header (logo + heading + dates), not the offers'
|
// decision: no separate page, no repeated header). Each section keeps
|
||||||
// monochrome one. The whole page is suppressed when every section is empty
|
// break-inside: avoid; long content paginates naturally and the Puppeteer
|
||||||
// (tag-stripped check, so an empty-Quill "<p><br></p>" doesn't count).
|
// footer template stamps every page. Suppressed entirely when every
|
||||||
// cs prefers the Czech title when present; en (or a missing Czech title)
|
// section is empty (tag-stripped check, so an empty-Quill "<p><br></p>"
|
||||||
// falls back to the EN title (same rule as offers-pdf).
|
// 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 =>
|
const resolveSectionTitle = (s: IssuedOrderPdfSection): string =>
|
||||||
lang === "cs" && (s.title_cz || "").trim()
|
lang === "cs" && (s.title_cz || "").trim()
|
||||||
? s.title_cz || ""
|
? s.title_cz || ""
|
||||||
: s.title || "";
|
: s.title || "";
|
||||||
// Visibility must use the SAME per-language title rule as the render loop —
|
// 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
|
// 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(
|
const visibleSections = sections.filter(
|
||||||
(s) => resolveSectionTitle(s).trim() || stripTags(s.content),
|
(s) => resolveSectionTitle(s).trim() || stripTags(s.content),
|
||||||
);
|
);
|
||||||
@@ -303,20 +328,7 @@ export function renderIssuedOrderHtml(
|
|||||||
|
|
||||||
let scopeHtml = "";
|
let scopeHtml = "";
|
||||||
if (hasSectionContent) {
|
if (hasSectionContent) {
|
||||||
const scopeDates = `<div class="scope-dates"><span class="lbl">${escapeHtml(t.issue_date)}</span> <span class="val">${escapeHtml(issueDateStr) || "—"}</span>${
|
scopeHtml += '<div class="scope-sections">';
|
||||||
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}`;
|
|
||||||
for (const section of visibleSections) {
|
for (const section of visibleSections) {
|
||||||
const title = resolveSectionTitle(section);
|
const title = resolveSectionTitle(section);
|
||||||
const content = (section.content || "").trim();
|
const content = (section.content || "").trim();
|
||||||
@@ -348,15 +360,9 @@ export function renderIssuedOrderHtml(
|
|||||||
width: 186mm;
|
width: 186mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-page {
|
/* The repeating "Vystavil / Strana X z Y" footer lives in Puppeteer's
|
||||||
display: flex;
|
footerTemplate (bottom page margin), NOT in the body — the old flex
|
||||||
flex-direction: column;
|
min-height pinning is gone with it. */
|
||||||
min-height: calc(297mm - 27mm);
|
|
||||||
}
|
|
||||||
.invoice-content { flex: 1 1 auto; }
|
|
||||||
.invoice-footer {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accent { color: #de3a3a; }
|
.accent { color: #de3a3a; }
|
||||||
|
|
||||||
@@ -561,14 +567,6 @@ export function renderIssuedOrderHtml(
|
|||||||
border-bottom: 2.5pt solid #de3a3a;
|
border-bottom: 2.5pt solid #de3a3a;
|
||||||
padding-bottom: 1mm;
|
padding-bottom: 1mm;
|
||||||
}
|
}
|
||||||
/* Vystavil */
|
|
||||||
.issued-by {
|
|
||||||
font-size: 9pt;
|
|
||||||
margin: 2mm 0;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.issued-by .lbl { font-weight: 600; }
|
|
||||||
|
|
||||||
/* Upozorneni */
|
/* Upozorneni */
|
||||||
.notice {
|
.notice {
|
||||||
font-size: 8pt;
|
font-size: 8pt;
|
||||||
@@ -577,18 +575,12 @@ export function renderIssuedOrderHtml(
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sekce (scope) na vlastni strance — zrcadli offers-pdf .scope-page,
|
/* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
|
||||||
hlavicka se opakuje v cervenem stylu teto sablony */
|
dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
|
||||||
.scope-page {
|
stranu. */
|
||||||
page-break-before: always;
|
.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 {
|
.scope-section {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -644,7 +636,7 @@ ${indentCSS}
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
.invoice-page, .scope-page {
|
.invoice-page {
|
||||||
width: 210mm;
|
width: 210mm;
|
||||||
min-height: 297mm;
|
min-height: 297mm;
|
||||||
padding: 15mm;
|
padding: 15mm;
|
||||||
@@ -720,17 +712,11 @@ ${indentCSS}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${scopeHtml}
|
||||||
|
|
||||||
</div><!-- /.invoice-content -->
|
</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 -->
|
</div><!-- /.invoice-page -->
|
||||||
|
|
||||||
${scopeHtml}
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
@@ -808,7 +794,9 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
|||||||
? new Date(order.order_date)
|
? new Date(order.order_date)
|
||||||
: new Date();
|
: new Date();
|
||||||
nasOrdersManager.cleanIssued(order.po_number);
|
nasOrdersManager.cleanIssued(order.po_number);
|
||||||
const pdfBuffer = await htmlToPdf(html);
|
const pdfBuffer = await htmlToPdf(html, {
|
||||||
|
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||||
|
});
|
||||||
nasOrdersManager.saveIssuedPdf(
|
nasOrdersManager.saveIssuedPdf(
|
||||||
order.po_number,
|
order.po_number,
|
||||||
baseDate.getFullYear(),
|
baseDate.getFullYear(),
|
||||||
@@ -818,7 +806,9 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
|||||||
return success(reply, null, 200, "PDF uloženo");
|
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`;
|
const filename = `Objednavka-${order.po_number || String(id)}.pdf`;
|
||||||
return reply
|
return reply
|
||||||
.type("application/pdf")
|
.type("application/pdf")
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import {
|
|||||||
deleteIssuedOrder,
|
deleteIssuedOrder,
|
||||||
getNextIssuedOrderNumberPreview,
|
getNextIssuedOrderNumberPreview,
|
||||||
} from "../../services/issued-orders.service";
|
} 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 { htmlToPdf } from "../../utils/html-to-pdf";
|
||||||
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
||||||
import { contentDisposition } from "../../utils/content-disposition";
|
import { contentDisposition } from "../../utils/content-disposition";
|
||||||
@@ -306,7 +309,9 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
issuer,
|
issuer,
|
||||||
sections,
|
sections,
|
||||||
);
|
);
|
||||||
const pdf = await htmlToPdf(html);
|
const pdf = await htmlToPdf(html, {
|
||||||
|
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
|
||||||
|
});
|
||||||
|
|
||||||
if (nasOrdersManager.isConfigured()) {
|
if (nasOrdersManager.isConfigured()) {
|
||||||
const baseDate = order.order_date
|
const baseDate = order.order_date
|
||||||
|
|||||||
@@ -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 b = await getBrowser();
|
||||||
const page = await b.newPage();
|
const page = await b.newPage();
|
||||||
// Per-request timeouts so one stuck render (e.g. image tag pointing at a
|
// 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({
|
const pdf = await page.pdf({
|
||||||
format: "A4",
|
format: "A4",
|
||||||
printBackground: true,
|
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,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
return Buffer.from(pdf);
|
return Buffer.from(pdf);
|
||||||
|
|||||||
Reference in New Issue
Block a user