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:
+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