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 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-19 21:08:14 +03:30
parent 1529bf0d46
commit 49f1ab07bd
3 changed files with 116 additions and 77 deletions
+12 -1
View File
@@ -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"
}
+1 -1
View File
@@ -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",
+103 -75
View File
@@ -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 {
</div>`;
}
// 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 `<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">
<style>${FONT_FACE_CSS}
:root{--font-peyda:'PeydaPDF';}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;background:#ffffff;}
</style></head><body>${buildInvoiceHtml(data)}</body></html>`;
// Weights actually used by the invoice template.
const PEYDA_WEIGHTS: Record<number, string> = {
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<string> {
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 <foreignObject>, 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<InvoicePngResult> {
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<void> {
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);
}