From 49f1ab07bdd1aaf39a6950d061f4bb84cf6e1cca Mon Sep 17 00:00:00 2001 From: keyhan Date: Fri, 19 Jun 2026 21:08:14 +0330 Subject: [PATCH] fix(invoices): render invoice PDF with html-to-image for correct Persian html2canvas re-implements text layout and mangled Persian RTL output (joined words, broken spacing, reordered emails). Switch the rasterizer to html-to-image, which renders through an SVG foreignObject using the browser's native text engine, so Persian shaping/spacing/bidi and oklch() colors all come out right. Peyda is embedded as base64 @font-face via fontEmbedCSS so html-to-image skips its slow document-wide font scan. Drop the now-unused html2canvas dependency. Co-Authored-By: Claude Opus 4.8 --- frontend/package-lock.json | 13 ++- frontend/package.json | 2 +- frontend/src/lib/invoice-pdf.ts | 178 ++++++++++++++++++-------------- 3 files changed, 116 insertions(+), 77 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cef11a2..8b94918 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,7 +15,7 @@ "axios": "^1.17.0", "clsx": "^2.1.0", "framer-motion": "^12.40.0", - "html2canvas": "^1.4.1", + "html-to-image": "^1.11.13", "jspdf": "^4.2.1", "lenis": "^1.3.23", "lucide-react": "^1.18.0", @@ -2829,6 +2829,7 @@ "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", "license": "MIT", + "optional": true, "engines": { "node": ">= 0.6.0" } @@ -3196,6 +3197,7 @@ "resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", "license": "MIT", + "optional": true, "dependencies": { "utrie": "^1.0.2" } @@ -4564,11 +4566,18 @@ "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", "license": "Apache-2.0" }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmmirror.com/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", "license": "MIT", + "optional": true, "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" @@ -7162,6 +7171,7 @@ "resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", "license": "MIT", + "optional": true, "dependencies": { "utrie": "^1.0.2" } @@ -7637,6 +7647,7 @@ "resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", "license": "MIT", + "optional": true, "dependencies": { "base64-arraybuffer": "^1.0.2" } diff --git a/frontend/package.json b/frontend/package.json index 3e96de6..3b52543 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "axios": "^1.17.0", "clsx": "^2.1.0", "framer-motion": "^12.40.0", - "html2canvas": "^1.4.1", + "html-to-image": "^1.11.13", "jspdf": "^4.2.1", "lenis": "^1.3.23", "lucide-react": "^1.18.0", diff --git a/frontend/src/lib/invoice-pdf.ts b/frontend/src/lib/invoice-pdf.ts index eb2b66d..c8c3349 100644 --- a/frontend/src/lib/invoice-pdf.ts +++ b/frontend/src/lib/invoice-pdf.ts @@ -1,6 +1,7 @@ // Client-side invoice PDF generation. Builds a styled, RTL Persian invoice -// document off-screen in the live DOM (so the Peyda webfont renders correctly), -// rasterizes it with html2canvas, and emits a paginated A4 PDF via jsPDF. +// document off-screen in the live DOM, rasterizes it with html-to-image (which +// uses the browser's native text engine, so Persian shaping/spacing is correct), +// and emits a paginated A4 PDF via jsPDF. import type { Invoice, InvoiceStatus } from '@/types'; import type { Dictionary } from '@/i18n/dictionaries/fa'; @@ -220,89 +221,116 @@ 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'); +export interface InvoicePngResult { + dataUrl: string; + width: number; + height: number; +} -function buildIframeDocument(data: InvoicePdfData): string { - return ` -${buildInvoiceHtml(data)}`; +// Weights actually used by the invoice template. +const PEYDA_WEIGHTS: Record = { + 400: 'Regular', + 600: 'SemiBold', + 700: 'Bold', + 800: 'ExtraBold', + 900: 'Black', +}; + +let cachedFontCss: string | null = null; + +/** + * Build a @font-face stylesheet with the Peyda woff files inlined as base64. + * Passed to html-to-image as `fontEmbedCSS` so it embeds exactly these fonts and + * skips its slow (and sometimes hanging) scan of every stylesheet in the app. + */ +async function getEmbeddedFontCss(): Promise { + if (cachedFontCss !== null) return cachedFontCss; + const faces = await Promise.all( + Object.entries(PEYDA_WEIGHTS).map(async ([weight, file]) => { + try { + const res = await fetch(`/fonts/peyda/Peyda-${file}.woff`); + const buf = await res.arrayBuffer(); + let binary = ''; + const bytes = new Uint8Array(buf); + for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]); + const b64 = btoa(binary); + return `@font-face{font-family:'PeydaPDF';font-weight:${weight};font-style:normal;src:url(data:font/woff;base64,${b64}) format('woff');}`; + } catch { + return ''; + } + }), + ); + cachedFontCss = faces.join('\n'); + return cachedFontCss; +} + +/** + * Rasterize the invoice template to a PNG via html-to-image. + * + * html-to-image renders through an SVG , i.e. the browser's own + * layout/text engine, so Persian RTL shaping, spacing and bidi (e.g. emails) + * come out correct — unlike a re-implemented canvas text layout, which mangles + * Persian. oklch() colors are handled natively too. + * Exposed separately so it can be exercised/verified without a download. + */ +export async function renderInvoicePng(data: InvoicePdfData): Promise { + const [{ toPng }, fontEmbedCSS] = await Promise.all([import('html-to-image'), getEmbeddedFontCss()]); + + const host = document.createElement('div'); + host.style.position = 'fixed'; + host.style.left = '-10000px'; + host.style.top = '0'; + host.style.zIndex = '-1'; + host.style.background = '#ffffff'; + // Resolve the template's var(--font-peyda) to our embedded face. + host.style.setProperty('--font-peyda', "'PeydaPDF'"); + host.innerHTML = buildInvoiceHtml(data); + document.body.appendChild(host); + + try { + const node = host.firstElementChild as HTMLElement; + if (document.fonts?.ready) await document.fonts.ready; + await new Promise((resolve) => setTimeout(resolve, 50)); + + const width = node.scrollWidth; + const height = node.scrollHeight; + const dataUrl = await toPng(node, { + pixelRatio: 2, + backgroundColor: '#ffffff', + width, + height, + fontEmbedCSS, + }); + return { dataUrl, width, height }; + } finally { + document.body.removeChild(host); + } } export async function downloadInvoicePdf(data: InvoicePdfData): Promise { - const [{ default: html2canvas }, { jsPDF }] = await Promise.all([ - import('html2canvas'), + const [{ jsPDF }, { dataUrl, width, height }] = await Promise.all([ import('jspdf'), + renderInvoicePng(data), ]); - // 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); + const pdf = new jsPDF({ unit: 'pt', format: 'a4', orientation: 'portrait' }); + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const imgWidth = pageWidth; + const imgHeight = (height * imgWidth) / width; - try { - const doc = iframe.contentDocument!; - doc.open(); - doc.write(buildIframeDocument(data)); - doc.close(); + let heightLeft = imgHeight; + let position = 0; - // 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)); + pdf.addImage(dataUrl, 'PNG', 0, position, imgWidth, imgHeight); + heightLeft -= pageHeight; - 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' }); - const pageWidth = pdf.internal.pageSize.getWidth(); - const pageHeight = pdf.internal.pageSize.getHeight(); - const imgWidth = pageWidth; - const imgHeight = (canvas.height * imgWidth) / canvas.width; - - const imgData = canvas.toDataURL('image/png'); - let heightLeft = imgHeight; - let position = 0; - - pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight); + while (heightLeft > 0) { + position -= pageHeight; + pdf.addPage(); + pdf.addImage(dataUrl, 'PNG', 0, position, imgWidth, imgHeight); heightLeft -= pageHeight; - - while (heightLeft > 0) { - position -= pageHeight; - pdf.addPage(); - pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight); - heightLeft -= pageHeight; - } - - pdf.save(data.fileName); - } finally { - document.body.removeChild(iframe); } + + pdf.save(data.fileName); }