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:
Generated
+12
-1
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user