203 lines
8.0 KiB
TypeScript
203 lines
8.0 KiB
TypeScript
import html2canvas from "html2canvas";
|
|
import { jsPDF } from "jspdf";
|
|
|
|
const fontFile = "Vazirmatn-Regular.ttf";
|
|
const fontFamily = "VazirmatnReceipt";
|
|
const pageMargin = 24;
|
|
const pdfCaptureScale = 3;
|
|
const jpegQuality = 0.94;
|
|
const ignoredReceiptControls = ".registration-copy-reference, .registration-event-details-link";
|
|
const omittedReceiptContent = `${ignoredReceiptControls}, [data-receipt-status-badge], [data-receipt-reference]`;
|
|
|
|
function toBase64(buffer: ArrayBuffer) {
|
|
const bytes = new Uint8Array(buffer);
|
|
let binary = "";
|
|
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
|
}
|
|
return window.btoa(binary);
|
|
}
|
|
|
|
function toUtf16Hex(value: string) {
|
|
let result = "FEFF";
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
result += value.charCodeAt(index).toString(16).padStart(4, "0");
|
|
}
|
|
return result.toUpperCase();
|
|
}
|
|
|
|
async function addSelectableTextLayer(
|
|
pdf: jsPDF,
|
|
element: HTMLElement,
|
|
canvas: HTMLCanvasElement,
|
|
image: { x: number; y: number; scale: number },
|
|
) {
|
|
const response = await fetch(`/fonts/${fontFile}`, { cache: "force-cache" });
|
|
if (!response.ok) throw new Error("فونت رسید بارگذاری نشد.");
|
|
const base64 = toBase64(await response.arrayBuffer());
|
|
pdf.addFileToVFS(fontFile, base64);
|
|
pdf.addFont(fontFile, fontFamily, "normal");
|
|
pdf.setFont(fontFamily, "normal");
|
|
pdf.setR2L(false);
|
|
(pdf as unknown as { __private__: { setPdfVersion: (version: string) => void } }).__private__.setPdfVersion("1.4");
|
|
|
|
const rootRect = element.getBoundingClientRect();
|
|
const captureScale = canvas.width / rootRect.width;
|
|
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
let node: Node | null = walker.nextNode();
|
|
const write = (pdf.internal as unknown as { write: (value: string) => void }).write;
|
|
|
|
while (node) {
|
|
const textNode = node as Text;
|
|
const parent = textNode.parentElement;
|
|
const value = textNode.nodeValue ?? "";
|
|
if (
|
|
parent
|
|
&& value.trim()
|
|
&& !parent.closest(`${omittedReceiptContent}, button, svg, [aria-hidden="true"]`)
|
|
&& getComputedStyle(parent).display !== "none"
|
|
&& getComputedStyle(parent).visibility !== "hidden"
|
|
) {
|
|
const words = value.matchAll(/\S+/gu);
|
|
for (const match of words) {
|
|
const word = match[0];
|
|
const offset = match.index;
|
|
if (!word) continue;
|
|
const range = document.createRange();
|
|
range.setStart(textNode, offset);
|
|
range.setEnd(textNode, offset + word.length);
|
|
const rect = range.getBoundingClientRect();
|
|
if (!rect.width || !rect.height) continue;
|
|
|
|
const computedStyle = getComputedStyle(parent);
|
|
const fontSize = Number.parseFloat(computedStyle.fontSize) || 14;
|
|
const x = image.x + (rect.left - rootRect.left) * captureScale * image.scale;
|
|
const y = image.y + (rect.bottom - rootRect.top) * captureScale * image.scale;
|
|
const pdfFontSize = Math.max(4, fontSize * 0.75 * captureScale * image.scale);
|
|
const following = value.slice(offset + word.length, offset + word.length + 1);
|
|
const actualText = `${word}${following && /\s/u.test(following) ? " " : ""}`;
|
|
|
|
// The page image keeps the original visual layout; this invisible layer preserves selectable Unicode text.
|
|
write(`/Span << /ActualText <${toUtf16Hex(actualText)}> >> BDC`);
|
|
pdf.setFontSize(pdfFontSize);
|
|
pdf.text(word, x, y, { align: "left", renderingMode: "invisible" });
|
|
write("EMC");
|
|
}
|
|
}
|
|
node = walker.nextNode();
|
|
}
|
|
}
|
|
|
|
async function prepareReceiptImage(image: HTMLImageElement) {
|
|
const source = image.currentSrc || image.src;
|
|
if (!source) return;
|
|
|
|
const parsedSource = new URL(source, window.location.href);
|
|
if (!["http:", "https:"].includes(parsedSource.protocol)) {
|
|
await image.decode();
|
|
return;
|
|
}
|
|
|
|
image.removeAttribute("srcset");
|
|
const encodeForReceipt = async (imageBlob: Blob) => {
|
|
if (!imageBlob.type.startsWith("image/")) throw new Error("پاسخ تصویر رویداد معتبر نیست.");
|
|
const bitmap = await createImageBitmap(imageBlob);
|
|
const resize = Math.min(1, 640 / Math.max(bitmap.width, bitmap.height));
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = Math.max(1, Math.round(bitmap.width * resize));
|
|
canvas.height = Math.max(1, Math.round(bitmap.height * resize));
|
|
const context = canvas.getContext("2d");
|
|
if (!context) {
|
|
bitmap.close();
|
|
throw new Error("آمادهسازی تصویر رویداد برای رسید انجام نشد.");
|
|
}
|
|
|
|
context.fillStyle = "#ffffff";
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
bitmap.close();
|
|
return canvas.toDataURL("image/jpeg", 0.88);
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(parsedSource.href, {
|
|
mode: "cors",
|
|
credentials: parsedSource.origin === window.location.origin ? "same-origin" : "omit",
|
|
});
|
|
if (!response.ok) throw new Error("تصویر رویداد برای رسید بارگذاری نشد.");
|
|
image.src = await encodeForReceipt(await response.blob());
|
|
} catch {
|
|
if (parsedSource.origin === window.location.origin) {
|
|
throw new Error("تصویر رویداد برای رسید بارگذاری نشد.");
|
|
}
|
|
|
|
const optimizedUrl = new URL("/_next/image", window.location.origin);
|
|
optimizedUrl.searchParams.set("url", parsedSource.href);
|
|
optimizedUrl.searchParams.set("w", "640");
|
|
optimizedUrl.searchParams.set("q", "75");
|
|
const response = await fetch(optimizedUrl, { credentials: "same-origin" });
|
|
if (!response.ok) throw new Error("تصویر رویداد برای رسید بارگذاری نشد.");
|
|
image.src = await encodeForReceipt(await response.blob());
|
|
}
|
|
|
|
await image.decode();
|
|
}
|
|
|
|
export async function downloadRegistrationReceipt(element: HTMLElement, fileName: string) {
|
|
await document.fonts.ready;
|
|
|
|
const canvas = await html2canvas(element, {
|
|
scale: pdfCaptureScale,
|
|
useCORS: true,
|
|
allowTaint: false,
|
|
backgroundColor: "#ffffff",
|
|
onclone: async (documentClone, clonedElement) => {
|
|
documentClone.documentElement.classList.remove("dark");
|
|
clonedElement.querySelectorAll(omittedReceiptContent).forEach((control) => control.remove());
|
|
|
|
const receiptHeader = clonedElement.querySelector("header");
|
|
if (receiptHeader) {
|
|
receiptHeader.style.alignItems = "start";
|
|
const headerText = receiptHeader.firstElementChild as HTMLElement | null;
|
|
if (headerText) headerText.style.alignSelf = "start";
|
|
}
|
|
|
|
const eventCard = clonedElement.querySelector("section h2")?.closest("section");
|
|
if (eventCard) {
|
|
(eventCard as HTMLElement).style.alignItems = "start";
|
|
const eventText = eventCard.firstElementChild as HTMLElement | null;
|
|
if (eventText) eventText.style.alignSelf = "start";
|
|
}
|
|
|
|
const images = Array.from(clonedElement.querySelectorAll("img"));
|
|
await Promise.all(images.map(prepareReceiptImage));
|
|
},
|
|
});
|
|
|
|
const pdf = new jsPDF({
|
|
orientation: "portrait",
|
|
unit: "pt",
|
|
format: "a4",
|
|
compress: true,
|
|
putOnlyUsedFonts: true,
|
|
});
|
|
const pageWidth = pdf.internal.pageSize.getWidth();
|
|
const pageHeight = pdf.internal.pageSize.getHeight();
|
|
const scale = Math.min(
|
|
(pageWidth - pageMargin * 2) / canvas.width,
|
|
(pageHeight - pageMargin * 2) / canvas.height,
|
|
);
|
|
const width = canvas.width * scale;
|
|
const height = canvas.height * scale;
|
|
const x = (pageWidth - width) / 2;
|
|
const y = pageMargin;
|
|
const compressedImage = canvas.toDataURL("image/jpeg", jpegQuality);
|
|
|
|
pdf.setFillColor(255, 255, 255);
|
|
pdf.rect(0, 0, pageWidth, pageHeight, "F");
|
|
pdf.addImage(compressedImage, "JPEG", x, y, width, height, undefined, "FAST");
|
|
await addSelectableTextLayer(pdf, element, canvas, { x, y, scale });
|
|
pdf.setProperties({ title: "رسید ثبتنام رویداد", subject: "رسید ثبتنام", creator: "سامانهٔ انجمن" });
|
|
pdf.save(fileName);
|
|
}
|