feat: Update poster dimensions to A4 size and enhance PDF generation tests
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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("خط زیرتخصص از بودجهٔ کاراکتر نمیگذرد", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user