diff --git a/frontend/src/lib/invoice-pdf.ts b/frontend/src/lib/invoice-pdf.ts index 365e4d4..eb2b66d 100644 --- a/frontend/src/lib/invoice-pdf.ts +++ b/frontend/src/lib/invoice-pdf.ts @@ -220,27 +220,65 @@ export function buildInvoiceHtml(data: InvoicePdfData): string { `; } +// Self-contained @font-face block so the PDF renders with the Peyda typeface +// regardless of the host page. Rendering happens inside an isolated iframe (see +// below) so the app's global CSS — which uses oklch() colors that html2canvas +// 1.x cannot parse — never reaches the canvas. +const FONT_FACE_CSS = [400, 500, 600, 700, 800, 900] + .map((weight) => { + const file = + { 400: 'Regular', 500: 'Medium', 600: 'SemiBold', 700: 'Bold', 800: 'ExtraBold', 900: 'Black' }[weight]; + return `@font-face{font-family:'PeydaPDF';src:url('/fonts/peyda/Peyda-${file}.woff') format('woff');font-weight:${weight};font-style:normal;font-display:swap;}`; + }) + .join('\n'); + +function buildIframeDocument(data: InvoicePdfData): string { + return ` +${buildInvoiceHtml(data)}`; +} + export async function downloadInvoicePdf(data: InvoicePdfData): Promise { const [{ default: html2canvas }, { jsPDF }] = await Promise.all([ import('html2canvas'), import('jspdf'), ]); - const host = document.createElement('div'); - host.style.position = 'fixed'; - host.style.left = '-10000px'; - host.style.top = '0'; - host.style.zIndex = '-1'; - host.innerHTML = buildInvoiceHtml(data); - document.body.appendChild(host); + // Render inside an isolated iframe so none of the app's stylesheets (Tailwind v4 + // emits oklch() colors that crash html2canvas) leak into the captured tree. + const iframe = document.createElement('iframe'); + iframe.setAttribute('aria-hidden', 'true'); + iframe.style.position = 'fixed'; + iframe.style.left = '-10000px'; + iframe.style.top = '0'; + iframe.style.width = '820px'; + iframe.style.height = '1200px'; + iframe.style.border = '0'; + document.body.appendChild(iframe); try { - const target = host.firstElementChild as HTMLElement; + const doc = iframe.contentDocument!; + doc.open(); + doc.write(buildIframeDocument(data)); + doc.close(); + + // Wait for the Peyda webfont to load inside the iframe before capturing. + if (doc.fonts?.ready) { + await doc.fonts.ready; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + + const target = doc.body.firstElementChild as HTMLElement; const canvas = await html2canvas(target, { scale: 2, backgroundColor: '#ffffff', useCORS: true, logging: false, + windowWidth: target.scrollWidth, + windowHeight: target.scrollHeight, }); const pdf = new jsPDF({ unit: 'pt', format: 'a4', orientation: 'portrait' }); @@ -265,6 +303,6 @@ export async function downloadInvoicePdf(data: InvoicePdfData): Promise { pdf.save(data.fileName); } finally { - document.body.removeChild(host); + document.body.removeChild(iframe); } }