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",
|
"axios": "^1.17.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html-to-image": "^1.11.13",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"lenis": "^1.3.23",
|
"lenis": "^1.3.23",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
@@ -2829,6 +2829,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6.0"
|
"node": ">= 0.6.0"
|
||||||
}
|
}
|
||||||
@@ -3196,6 +3197,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"utrie": "^1.0.2"
|
"utrie": "^1.0.2"
|
||||||
}
|
}
|
||||||
@@ -4564,11 +4566,18 @@
|
|||||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/html2canvas": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"css-line-break": "^2.1.0",
|
"css-line-break": "^2.1.0",
|
||||||
"text-segmentation": "^1.0.3"
|
"text-segmentation": "^1.0.3"
|
||||||
@@ -7162,6 +7171,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"utrie": "^1.0.2"
|
"utrie": "^1.0.2"
|
||||||
}
|
}
|
||||||
@@ -7637,6 +7647,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
|
||||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-arraybuffer": "^1.0.2"
|
"base64-arraybuffer": "^1.0.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"axios": "^1.17.0",
|
"axios": "^1.17.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html-to-image": "^1.11.13",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"lenis": "^1.3.23",
|
"lenis": "^1.3.23",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
|
|||||||
+103
-75
@@ -1,6 +1,7 @@
|
|||||||
// Client-side invoice PDF generation. Builds a styled, RTL Persian invoice
|
// Client-side invoice PDF generation. Builds a styled, RTL Persian invoice
|
||||||
// document off-screen in the live DOM (so the Peyda webfont renders correctly),
|
// document off-screen in the live DOM, rasterizes it with html-to-image (which
|
||||||
// rasterizes it with html2canvas, and emits a paginated A4 PDF via jsPDF.
|
// 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 { Invoice, InvoiceStatus } from '@/types';
|
||||||
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
||||||
@@ -220,89 +221,116 @@ export function buildInvoiceHtml(data: InvoicePdfData): string {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-contained @font-face block so the PDF renders with the Peyda typeface
|
export interface InvoicePngResult {
|
||||||
// regardless of the host page. Rendering happens inside an isolated iframe (see
|
dataUrl: string;
|
||||||
// below) so the app's global CSS — which uses oklch() colors that html2canvas
|
width: number;
|
||||||
// 1.x cannot parse — never reaches the canvas.
|
height: number;
|
||||||
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 {
|
// Weights actually used by the invoice template.
|
||||||
return `<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">
|
const PEYDA_WEIGHTS: Record<number, string> = {
|
||||||
<style>${FONT_FACE_CSS}
|
400: 'Regular',
|
||||||
:root{--font-peyda:'PeydaPDF';}
|
600: 'SemiBold',
|
||||||
*{box-sizing:border-box;}
|
700: 'Bold',
|
||||||
html,body{margin:0;padding:0;background:#ffffff;}
|
800: 'ExtraBold',
|
||||||
</style></head><body>${buildInvoiceHtml(data)}</body></html>`;
|
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> {
|
export async function downloadInvoicePdf(data: InvoicePdfData): Promise<void> {
|
||||||
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
|
const [{ jsPDF }, { dataUrl, width, height }] = await Promise.all([
|
||||||
import('html2canvas'),
|
|
||||||
import('jspdf'),
|
import('jspdf'),
|
||||||
|
renderInvoicePng(data),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Render inside an isolated iframe so none of the app's stylesheets (Tailwind v4
|
const pdf = new jsPDF({ unit: 'pt', format: 'a4', orientation: 'portrait' });
|
||||||
// emits oklch() colors that crash html2canvas) leak into the captured tree.
|
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||||
const iframe = document.createElement('iframe');
|
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||||
iframe.setAttribute('aria-hidden', 'true');
|
const imgWidth = pageWidth;
|
||||||
iframe.style.position = 'fixed';
|
const imgHeight = (height * imgWidth) / width;
|
||||||
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 {
|
let heightLeft = imgHeight;
|
||||||
const doc = iframe.contentDocument!;
|
let position = 0;
|
||||||
doc.open();
|
|
||||||
doc.write(buildIframeDocument(data));
|
|
||||||
doc.close();
|
|
||||||
|
|
||||||
// Wait for the Peyda webfont to load inside the iframe before capturing.
|
pdf.addImage(dataUrl, 'PNG', 0, position, imgWidth, imgHeight);
|
||||||
if (doc.fonts?.ready) {
|
heightLeft -= pageHeight;
|
||||||
await doc.fonts.ready;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
const target = doc.body.firstElementChild as HTMLElement;
|
while (heightLeft > 0) {
|
||||||
const canvas = await html2canvas(target, {
|
position -= pageHeight;
|
||||||
scale: 2,
|
pdf.addPage();
|
||||||
backgroundColor: '#ffffff',
|
pdf.addImage(dataUrl, 'PNG', 0, position, imgWidth, imgHeight);
|
||||||
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);
|
|
||||||
heightLeft -= pageHeight;
|
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