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);
}