import { localDateCzStr } from "./date"; /** * Shared helpers for the four PDF routes (offers-pdf, issued-orders-pdf, * invoices-pdf, orders-pdf). These used to be near-verbatim copies in each * route file; the canonical variants live here: * * - `cleanQuillHtml` is the STRICT variant (strips javascript:/data:/vbscript: * in BOTH href and src — defense-in-depth, DOMPurify runs first). * - `formatNum` uses the NBSP (U+00A0) thousands separator so amounts never * line-wrap inside the number. * - `formatCurrency` is the per-currency-symbol variant from offers * (€ / $ / Kč / £, anything else falls back to " "). */ /** dd.MM.yyyy in Europe/Prague; invalid input echoes back as-is. */ export function formatDate(date: Date | string | null | undefined): string { if (!date) return ""; const d = new Date(date); if (isNaN(d.getTime())) return String(date); return localDateCzStr(d); } /** Format number with comma decimal separator and NBSP thousands separator. */ export function formatNum(n: number, decimals = 2): string { const abs = Math.abs(n); const fixed = abs.toFixed(decimals); const [intPart, decPart] = fixed.split("."); const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " "); const result = decPart ? `${withSep},${decPart}` : withSep; return n < 0 ? `-${result}` : result; } /** Per-currency symbol formatting (offers variant). */ export function formatCurrency(amount: number, currency: string): string { const n = Number(amount) || 0; switch (currency) { case "EUR": return `${formatNum(n, 2)} €`; case "USD": return `$${Math.abs(n) .toFixed(2) .replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`; case "CZK": return `${formatNum(n, 2)} Kč`; case "GBP": return `£${Math.abs(n) .toFixed(2) .replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`; default: return `${formatNum(n, 2)} ${currency}`; } } export function escapeHtml(str: string | null | undefined): string { if (!str) return ""; return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } /** * Company logo from company_settings.logo_data as a data: URI (mime sniffed * from magic bytes). Data URIs are the ONLY image source Chromium loads in * header/footer templates; the body templates reuse the same URI. */ export function logoDataUriFromSettings( settings: Record | null, ): string { if (!settings?.logo_data) return ""; const buf = Buffer.from(settings.logo_data as Buffer); let mime = "image/png"; if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif"; else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp"; return `data:${escapeHtml(mime)};base64,${buf.toString("base64")}`; } /** * Unified repeating per-page header for the red-accent PDF family (invoices + * issued orders), rendered by Puppeteer in the top page margin: company logo * left (max 22mm tall — same size as the documents' original body header), * red bold heading right, 2pt #de3a3a rule underneath. * * Contract shared with the footers: inline styles only, explicit font-size * (Chromium defaults template text to 0), images as data: URIs only, padding * matched to the documents' 12mm @page side margins. The @page top margin * must be 32mm to make room (htmlToPdf grows it when headerTemplate is set, * but Chromium lays out by the CSS @page margins — keep them in sync). */ export function buildPdfHeaderTemplate( logoDataUri: string, heading: string, ): string { return `
${logoDataUri ? `` : ""}
${escapeHtml(heading)}
`; } /** * Sanitize Quill HTML for the PDF templates: remove dangerous tags, event * handlers, javascript:/data:/vbscript: URLs (href AND src), inline styles, * and merge adjacent identical spans. Runs AFTER DOMPurify at every call site * (regex pass = defense-in-depth, not the primary sanitizer). */ export function cleanQuillHtml(html: string | null | undefined): string { if (!html) return ""; let s = html; // Remove dangerous tags with content s = s.replace( /<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi, "", ); s = s.replace( /<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi, "", ); // Strip event handlers s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ""); s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, ""); // Strip javascript:/data:/vbscript: in href and src s = s.replace( /href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi, 'href="#"', ); s = s.replace( /src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi, 'src=""', ); // Replace   with regular space (outside of tags) s = s.replace(/( )/g, " "); s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ""); // Merge adjacent spans with same attributes let prev = ""; while (prev !== s) { prev = s; s = s.replace(/]*)>(.*?)<\/span>\s*/gs, "$2"); } return s; } /** CSS rules for Quill's .ql-indent-1..9 classes (identical in all templates). */ export function buildQuillIndentCss(): string { let indentCSS = ""; for (let n = 1; n <= 9; n++) { const pad = n * 3; const liPad = n * 3 + 1.5; indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`; indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`; } return indentCSS; }