feat: Update poster dimensions to A4 size and enhance PDF generation tests

This commit is contained in:
hamed
2026-08-09 13:59:37 +03:30
parent 54bebbd41a
commit 5e86f115d3
6 changed files with 131 additions and 17 deletions
+22 -9
View File
@@ -15,6 +15,9 @@ import jsPDF from "jspdf";
import { Button } from "@mui/material"; import { Button } from "@mui/material";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const VAZIR_WEIGHTS = [ const VAZIR_WEIGHTS = [
[400, "Vazirmatn-RD-FD-Regular.ttf"], [400, "Vazirmatn-RD-FD-Regular.ttf"],
[500, "Vazirmatn-RD-FD-Medium.ttf"], [500, "Vazirmatn-RD-FD-Medium.ttf"],
@@ -99,17 +102,27 @@ function List({ hiddenRef, data, qrReady }) {
pxHeight = canvas.height; pxHeight = canvas.height;
} }
const imgWidth = 210; // کاغذ همیشه A4 است، نه اندازه‌ای که از نسبت تصویر درمی‌آید: خروجی قبلی
const pxPerMm = pxWidth / imgWidth; // ۲۱۰×۲۶۲٫۵ میلی‌متر بود و روی هیچ پرینتری A4 حساب نمی‌شد.
const imgHeight = pxHeight / pxPerMm; const pdf = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const pdf = new jsPDF({ // پوستر خودش نسبت A4 دارد، پس عملاً کل صفحه را پر می‌کند. این محاسبه تور
orientation: "portrait", // ایمنی است تا اگر ابعاد پوستر روزی عوض شد، تصویر از کاغذ بیرون نزند.
unit: "mm", let width = A4_WIDTH_MM;
format: [imgWidth, imgHeight], let height = (pxHeight / pxWidth) * width;
}); if (height > A4_HEIGHT_MM) {
height = A4_HEIGHT_MM;
width = (pxWidth / pxHeight) * height;
}
pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight); pdf.addImage(
imgData,
"PNG",
(A4_WIDTH_MM - width) / 2,
(A4_HEIGHT_MM - height) / 2,
width,
height,
);
const safeName = (data?.name ?? "doctor").replace(/[\\/:*?"<>|]+/g, "-"); const safeName = (data?.name ?? "doctor").replace(/[\\/:*?"<>|]+/g, "-");
pdf.save(`poster-${safeName}.pdf`); pdf.save(`poster-${safeName}.pdf`);
} catch { } catch {
+1 -1
View File
@@ -41,7 +41,7 @@ function Share({ data }) {
<ShareDI /> <ShareDI />
<p className="hidden lg:flex">اشتراک گذاری</p> <p className="hidden lg:flex">اشتراک گذاری</p>
</Button> </Button>
<div className="fixed w-[1080px] h-[1350px] top-0 left-0 opacity-0 pointer-events-none -z-10"> <div className="fixed w-[1080px] h-[1527px] top-0 left-0 opacity-0 pointer-events-none -z-10">
<div ref={hiddenRef}> <div ref={hiddenRef}>
{variant === "dark" ? ( {variant === "dark" ? (
<Poster data={data} onQrReady={handleQrReady} /> <Poster data={data} onQrReady={handleQrReady} />
@@ -0,0 +1,94 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent } from "@testing-library/dom";
// خروجی قبلی کاغذِ [۲۱۰ × ارتفاعِ تصویر] می‌ساخت، یعنی ۲۱۰×۲۶۲٫۵ میلی‌متر. روی هیچ
// پرینتری A4 حساب نمی‌شد. این تست همان قرارداد را قفل می‌کند: یک صفحهٔ A4.
const jsPdfCalls = { ctor: [], addImage: [], save: [] };
vi.mock("jspdf", () => ({
default: class {
constructor(options) {
jsPdfCalls.ctor.push(options);
}
addImage(...args) {
jsPdfCalls.addImage.push(args);
}
save(name) {
jsPdfCalls.save.push(name);
}
},
}));
vi.mock("html-to-image", () => ({
toPng: vi.fn(async () => "data:image/png;base64,AAAA"),
}));
vi.mock("html2canvas", () => ({ default: vi.fn() }));
vi.mock("react-toastify", () => ({
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
// فونت وزیر با fetch به data-url تبدیل می‌شود؛ در jsdom باید تأمین شود وگرنه مسیر
// جایگزینِ html2canvas اجرا می‌شود و تست چیز دیگری را می‌سنجد.
vi.stubGlobal("fetch", vi.fn(async () => ({ blob: async () => new Blob(["x"]) })));
import List from "./List";
// پوستر A4 است: ۱۰۸۰×۱۵۲۷ فیزیکی، که با pixelRatio=2 دو برابر می‌شود.
function posterRef({ width = 1080, height = 1527 } = {}) {
const element = document.createElement("div");
Object.defineProperty(element, "offsetWidth", { value: width });
Object.defineProperty(element, "offsetHeight", { value: height });
element.querySelectorAll = () => [];
return { current: element };
}
async function download(ref) {
render(<List hiddenRef={ref} data={{ name: "مرضیه حسینی" }} qrReady />);
fireEvent.click(screen.getByLabelText("دانلود"));
await waitFor(() => expect(jsPdfCalls.save.length).toBe(1));
}
describe("PDF پوستر", () => {
beforeEach(() => {
jsPdfCalls.ctor = [];
jsPdfCalls.addImage = [];
jsPdfCalls.save = [];
});
it("کاغذ A4 است، نه اندازه‌ای که از نسبت تصویر درمی‌آید", async () => {
await download(posterRef());
expect(jsPdfCalls.ctor[0]).toMatchObject({
orientation: "portrait",
unit: "mm",
format: "a4",
});
});
it("پوستر A4 کل صفحه را پر می‌کند و یک صفحه بیشتر نیست", async () => {
await download(posterRef());
const [, , x, y, width, height] = jsPdfCalls.addImage[0];
expect(jsPdfCalls.addImage).toHaveLength(1);
expect(width).toBeCloseTo(210, 1);
expect(height).toBeCloseTo(297, 0);
expect(x).toBeCloseTo(0, 1);
expect(y).toBeCloseTo(0, 0);
});
/** تور ایمنی: اگر ابعاد پوستر روزی بلندتر شد، تصویر باید کوچک شود نه اینکه از کاغذ بزند بیرون. */
it("پوستر بلندتر از A4 داخل کاغذ جا داده می‌شود، نه بریده", async () => {
await download(posterRef({ width: 1080, height: 2000 }));
const [, , x, y, width, height] = jsPdfCalls.addImage[0];
expect(height).toBeLessThanOrEqual(297);
expect(width).toBeLessThanOrEqual(210);
expect(x).toBeGreaterThan(0);
expect(y).toBeCloseTo(0, 1);
});
});
+4 -2
View File
@@ -47,7 +47,7 @@ function PosterLight({ data, onQrReady }) {
return ( return (
<div <div
dir="rtl" dir="rtl"
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col bg-[#F6F9FD] text-center" className="relative w-[1080px] h-[1527px] overflow-hidden flex flex-col bg-[#F6F9FD] text-center"
> >
{/* top brand band */} {/* top brand band */}
<div <div
@@ -71,7 +71,9 @@ function PosterLight({ data, onQrReady }) {
</div> </div>
</div> </div>
<div className="flex-1 px-[72px] flex flex-col items-center"> {/* بالا-چین می‌ماند تا آواتار روی نوار بالایی سوار بماند؛ فاصلهٔ اضافیِ کاغذ A4
به‌صورت فضای خالی بالای فوتر می‌نشیند، نه بین آواتار و نوار. */}
<div className="flex-1 px-[72px] pb-[40px] flex flex-col items-center">
{/* avatar overlapping band */} {/* avatar overlapping band */}
<div className="-mt-[8px] pt-[40px]"> <div className="-mt-[8px] pt-[40px]">
<div <div
+5 -3
View File
@@ -84,7 +84,7 @@ function Poster({ data, onQrReady }) {
return ( return (
<div <div
dir="rtl" dir="rtl"
className="relative w-[1080px] h-[1350px] overflow-hidden flex flex-col p-[64px] text-right" className="relative w-[1080px] h-[1527px] overflow-hidden flex flex-col p-[64px] text-right"
style={{ background: POSTER_BG }} style={{ background: POSTER_BG }}
> >
<div className="pointer-events-none absolute -top-[180px] -left-[180px] w-[520px] h-[520px] rounded-full bg-[#57A6FF]/20 blur-[80px]" /> <div className="pointer-events-none absolute -top-[180px] -left-[180px] w-[520px] h-[520px] rounded-full bg-[#57A6FF]/20 blur-[80px]" />
@@ -176,9 +176,11 @@ function Poster({ data, onQrReady }) {
</section> </section>
{/* body */} {/* body */}
<section className="relative mt-[36px] flex-1"> {/* کارت‌ها به هم چسبیده می‌مانند و فاصلهٔ اضافیِ کاغذ A4 بالای فوتر می‌نشیند:
حفرهٔ وسطِ صفحه شبیه محتوای جاافتاده دیده می‌شد. */}
<section className="relative mt-[36px] flex-1 flex flex-col gap-[28px]">
{shownServices.length > 0 && ( {shownServices.length > 0 && (
<div className="rounded-[28px] bg-white/6 border border-white/10 p-[32px] mb-[28px]"> <div className="rounded-[28px] bg-white/6 border border-white/10 p-[32px]">
<SectionTitle icon={<ClipBoard />}>خدمات</SectionTitle> <SectionTitle icon={<ClipBoard />}>خدمات</SectionTitle>
<div className="flex flex-wrap items-center gap-x-[12px] gap-y-[14px]"> <div className="flex flex-wrap items-center gap-x-[12px] gap-y-[14px]">
{shownServices.map((s, i) => ( {shownServices.map((s, i) => (
+5 -2
View File
@@ -49,13 +49,16 @@ describe.each([
expect(screen.getByText(/تخصص دیگر/)).toBeInTheDocument(); expect(screen.getByText(/تخصص دیگر/)).toBeInTheDocument();
}); });
it("کادر ثابت ۱۰۸۰×۱۳۵۰ و overflow-hidden دست‌نخورده است", () => { // ۱۰۸۰×۱۵۲۷ همان نسبت A4 است (۲۱۰:۲۹۷). هر عدد دیگری یعنی PDF یا حاشیهٔ سفید
// می‌گیرد یا از کاغذ بیرون می‌زند.
it("کادر ثابت ۱۰۸۰×۱۵۲۷ با نسبت A4 و overflow-hidden دست‌نخورده است", () => {
const { container } = render(<Component data={JAHANTAB} />); const { container } = render(<Component data={JAHANTAB} />);
const frame = container.firstChild; const frame = container.firstChild;
expect(frame.className).toContain("w-[1080px]"); expect(frame.className).toContain("w-[1080px]");
expect(frame.className).toContain("h-[1350px]"); expect(frame.className).toContain("h-[1527px]");
expect(frame.className).toContain("overflow-hidden"); expect(frame.className).toContain("overflow-hidden");
expect(1527 / 1080).toBeCloseTo(297 / 210, 2);
}); });
it("خط زیرتخصص از بودجهٔ کاراکتر نمی‌گذرد", () => { it("خط زیرتخصص از بودجهٔ کاراکتر نمی‌گذرد", () => {