مقاله فنی

رندر کردن جدول داده‌ها به PDF در Delphi با HotPDF

یک مجموعه داده شامل ردیف‌ها و ستون‌ها است؛ یک صفحه PDF یک شبکه مختصات خالی است که هیچ مفهومی از هیچ کدام ندارد. پر کردن این شکاف تمام کار در اینجا است. در HotPDF هیچ فراخوانی DrawTable وجود ندارد که یک مجموعه داده را بگیرد و یک شبکه قالب‌بندی شده به شما تحویل دهد. آنچه به جای آن دریافت می‌کنید عناصر اولیه‌ای (primitives) هستند که یک شبکه از آن‌ها ساخته شده است: TextOut برای قرار دادن یک رشته در یک نقطه، SetFont برای انتخاب نوع آن، Rectangle و Fill برای سایه‌زدن یک نوار، و MoveTo / LineTo / Stroke برای کشیدن خطوط. یک صادرکننده جدول (table exporter) کاربردی نظمی است برای تبدیل تفکر ردیف و ستون به مختصات صریح x و y، و سپس صادق نگه داشتن آن مختصات زمانی که داده‌ها از پایین صفحه عبور می‌کنند

مثالی که در ادامه می‌آید رکوردهای مشتریان را گزارش می‌دهد، اما هیچ چیز در کد رسم نمی‌داند یا برایش مهم نیست که ردیف‌ها از کجا می‌آیند. نسخه اصلی از یک TTable قدیمی استفاده می‌کرد؛ یک پرس‌وجوی FireDAC، یک مجموعه داده در حافظه (in-memory dataset)، یا یک آرایه ساده از رکوردها دقیقاً همان روتین‌ها را بدون تغییر تغذیه می‌کنند. آنچه مهم است این است که بتوانید داده‌ها را هر بار یک ردیف پیمایش کنید و چهار فیلد رشته‌ای را از هر کدام بخوانید. رندرینگ را جدا از منبع داده نگه دارید تا بتوانید هر طرف را بدون ایجاد مزاحمت برای طرف دیگر تغییر دهید

هندسه ستون (Column geometry) در درجه اول قرار دارد

قبل از کشیده شدن حتی یک کاراکتر، تصمیم بگیرید که هر ستون در کجا قرار دارد. یک جدول در اینجا چهار ستون دارد، بنابراین به چهار لبه چپ و یک حاشیه راست مشخص نیاز دارد. کدنویسی سخت (hard-coding) یک عدد جادویی در هر فراخوانی TextOut، همانطور که نمونه‌های سریع تمایل به انجام آن دارند، دقیقاً همان چیزی است که عریض‌تر کردن جدول را در آینده دردناک می‌کند. لبه‌ها را یک بار، بر حسب پوینت از مبدأ پایین-چپ نام‌گذاری کنید، و هر فراخوانی رسم با نام به آن‌ها اشاره می‌کند:

هندسهٔ ستون جدول HotPDF در Delphi؛ لبه‌های x نام‌دار در 70 و 110 و 300 و 480 پوینت میان خطوط قاب در 50 و 570 پوینت
چهار لبه چپ نام‌دار و یک حاشیه راست شناخته‌شده، کل هندسه جدول را پیش از اولین فراخوانی TextOut ثابت می‌کنند
const
  ColNo   = 70;    // لبه چپ ستون "No."
  ColName = 110;   // company name
  ColAddr = 300;   // street address
  ColCity = 480;   // city
  RowLeft = 50;    // table frame: left rule
  RowRight = 570;  // table frame: right rule
  RowStep = 20;    // فاصله عمودی بین baselineها

procedure PrintRow(Page: THPDFPage; Y: Single;
  const ANo, AName, AAddr, ACity: string; Shaded: boolean);
begin
  if Shaded then
  begin
    // یک نوار سایه‌دار پشت row. Rectangle، X، Y، Width و Height را می‌گیرد
    Page.SetRGBFillColor($00FFF3DD);
    Page.Rectangle(RowLeft, Y - 4, RowRight - RowLeft, RowStep);
    Page.Fill;
    Page.SetRGBFillColor(clBlack);
  end;
  Page.TextOut(ColNo,   Y, 0, ANo);
  Page.TextOut(ColName, Y, 0, AName);
  Page.TextOut(ColAddr, Y, 0, AAddr);
  Page.TextOut(ColCity, Y, 0, ACity);
end;

در اینجا دو جزئیات ارزش خود را نشان می‌دهند. نوار سایه‌دار اول کشیده می‌شود، سپس متن روی آن، زیرا ترتیب رنگ‌آمیزی (painting order) در PDF همان z-order است: مستطیل را بعد از متن پر کنید تا ردیف را دفن کنید. و سایه متناوب برای صرفاً تزئین نیست. در یک گزارش متراکم این ارزان‌ترین راه برای جلوگیری از لغزش چشم روی خط اشتباه است، به همین دلیل است که حلقه در ادامه یک متغیر بولین (boolean) را در هر ردیف معکوس می‌کند و آن را مستقیماً به Shaded پاس می‌دهد

موقعیت‌های ستون در بالا ثابت هستند، که برای گزارشی که طرح (schema) آن را کنترل می‌کنید صادقانه است. وقتی داده‌ها متغیر هستند، به جای حدس زدن اندازه بگیرید. HotPDF اندازه‌گیری عرض متن را روی شیء صفحه (page object) قرار می‌دهد، بنابراین نسخه تولیدی PrintRow می‌تواند طولانی‌ترین مقدار مورد انتظار در هر ستون را بگیرد، آن را یک بار در اندازه فونت انتخاب شده اندازه بگیرد، و لبه‌های چپ را از آن عرض‌ها به علاوه یک فاصله (gutter) استخراج کند. شکل روتین تغییر نمی‌کند؛ فقط منبع ثابت‌ها تغییر می‌کند

سرصفحه، خطوط، و یک مکان که مالک آن‌هاست

جدولی که از یک صفحه خارج می‌شود و در صفحه بعدی بدون برچسب ستون‌ها از سر گرفته می‌شود قابل خواندن نیست. راه حل این است که با سرصفحه (header) به عنوان چیزی برخورد کنید که دوباره آن را رسم می‌کنید، نه چیزی که یک بار آن را می‌کشید. عناوین ستون‌ها و خطوط افقی را که آن‌ها را قاب می‌کنند در یک روتین واحد قرار دهید، و آن روتین را هم در ابتدا و هم دوباره هر بار که یک صفحه جدید باز می‌کنید فراخوانی کنید. از آنجا که سرصفحه و بدنه از ثابت‌های ستون یکسانی به اشتراک استفاده می‌کنند، در ساختار با هم هم‌تراز می‌شوند

HotPDF عنوان‌ها و خطوط DrawHeader را در صفحهٔ یک و دوباره پس از هر AddPage بازمی‌کشد تا هر دو صفحهٔ PDF با سربرگ یکسانی آغاز شوند
روتین سرصفحه در هر صفحه جدید دوباره اجرا می‌شود، پس عنوان‌ها و خطوط ذاتیاً در همان جا فرود می‌آیند
procedure DrawHeader(Page: THPDFPage; var Y: Single; PageNo: Integer);
begin
  // Left: label مبدأ و شماره page. Right: زمان تولید
  Page.SetFont('Arial', [fsItalic], 10);
  Page.TextOut(RowLeft, Y, 0, 'customer.db   Page ' + IntToStr(PageNo));
  Page.TextOut(ColCity, Y, 0, DateTimeToStr(Now));

  // دو rule افقی که titleهای ستون را در قاب می‌گیرند
  Page.MoveTo(RowLeft, Y + 15);
  Page.LineTo(RowRight, Y + 15);
  Page.MoveTo(RowLeft, Y + 45);
  Page.LineTo(RowRight, Y + 45);
  Page.Stroke;

  // titleهای ستون، با font سنگین‌تر تا به‌شکل heading خوانده شوند
  Page.SetFont('Times New Roman', [fsBold], 12);
  Page.SetRGBFillColor(clNavy);
  PrintRow(Page, Y + 25, 'No.', 'Company', 'Address', 'City', False);
  Page.SetRGBFillColor(clBlack);

  Y := Y + RowStep + 45;  // پیش از اولین row بدنه از header قاب‌گرفته عبور کن
end;

توجه داشته باشید که DrawHeader متغیر Y را با ارجاع (by reference) می‌گیرد و آن را به جلو می‌برد. فراخوان هرگز مجبور نیست به خاطر بسپارد که سرصفحه چقدر ارتفاع دارد؛ روتینی که آن را رسم می‌کند روتینی است که می‌داند. این قانون مالکیت واحد چیزی است که باعث می‌شود چیدمان زمانی که بعداً یک لوگو یا خلاصه فیلتر به نوار سرصفحه اضافه می‌کنید، از هم نپاشد. حلقه بدنه بی‌خبر می‌ماند. این فقط به رسم ردیف‌ها از هر جایی که Y در حال حاضر به آن اشاره می‌کند ادامه می‌دهد

خود خطوط (rules) تفاوت بین یک لیست و یک جدول هستند. جداکننده‌های عمودی ستون همان ایده اعمال شده در محور x هستند: یک MoveTo / LineTo / Stroke در هر لبه ستون، که از خط بالایی تا پایین آخرین ردیف در صفحه اجرا می‌شود. این نمونه برای خوانا ماندن به خطوط افقی بسنده می‌کند، اما مرحله تولید پس از وجود ثابت‌های ستون به صورت مکانیکی انجام می‌شود

حلقه مکان‌نما مالک شکست صفحه (page break) است

رسم کردن نیمه آسان کار است. نیمه‌ای که یک اسباب‌بازی را از یک گزارش جدا می‌کند صفحه‌بندی (pagination) است: دانستن اینکه قبل از رسم یک ردیف، آیا هنوز جا می‌شود، و در صورتی که اینطور نیست، شروع یک صفحه جدید با یک سرصفحه جدید. این تصمیم دقیقاً به یک مکان تعلق دارد، حلقه‌ای که روی داده‌ها حرکت می‌کند، و نه هیچ جای دیگر

فلوچارت حلقهٔ نشانگر Delphi؛ Y زیر 60 باعث AddPage و خوانش دوبارهٔ CurrentPage و صادر دوبارهٔ SetFont و تکرار سربرگ پیش از ردیف بعدی جدول می‌شود
حلقه مکان‌نما تنها جایی است که وقتی Y زیر حاشیه پایین برود صفحه تازه‌ای باز و دوباره مقداردهی می‌کند
var
  Pdf: THotPDF;
  Page: THPDFPage;
  Y: Single;
  PageNo: Integer;
  Shaded: boolean;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'CustomerReport.pdf';
    Pdf.BeginDoc;
    Page := Pdf.CurrentPage;

    // عنوان report، یک‌بار در بالای page اول
    Page.SetFont('Arial', [fsBold], 24);
    Page.TextOut(200, 800, 0, 'Customer Report');

    PageNo := 1;
    Y := 760;
    DrawHeader(Page, Y, PageNo);
    Shaded := False;

    CustomerTable.First;
    while not CustomerTable.Eof do
    begin
      // جا کم آورده‌ای؟ page جدید باز کن و header را آنجا تکرار کن
      if Y < 60 then
      begin
        Pdf.AddPage;
        Page := Pdf.CurrentPage;   // AddPage، CurrentPage را جلو می‌برد
        Inc(PageNo);
        Y := 760;
        DrawHeader(Page, Y, PageNo);
      end;

      Shaded := not Shaded;
      Page.SetFont('Arial', [], 10);   // SetFont باید در هر page جدید دوباره صادر شود
      PrintRow(Page, Y,
        VarToStr(CustomerTable['CustNo']),
        VarToStr(CustomerTable['Company']),
        VarToStr(CustomerTable['Addr1']),
        VarToStr(CustomerTable['City']),
        Shaded);

      Y := Y - RowStep;
      CustomerTable.Next;
    end;

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

دو واقعیت مختصاتی کل حلقه را هدایت می‌کنند. PDF مختصات y را به سمت بالا از گوشه پایین-چپ اندازه می‌گیرد، بنابراین ردیف‌ها با کم کردن هر بار RowStep از Y به سمت پایین صفحه حرکت می‌کنند، و آزمایش پر بودن صفحه زمانی فعال می‌شود که Y به جای رفتن به بالای مقداری بالاترین نقطه، به پایین حاشیه پایین بیفتد. مسیر را برعکس بروید تا اولین ردیف شما خارج از لبه پایینی چاپ شود در حالی که حلقه فکر می‌کند یک صفحه کامل فضای خالی دارد

واقعیت دیگر تقریباً همه را یک بار درگیر خود می‌کند. AddPage یک صفحه جدید ایجاد می‌کند و CurrentPage را به آن ارجاع می‌دهد، اما هیچ چیزی را به آن منتقل نمی‌کند: نه فونت، نه رنگ پر کردن، و نه موقعیت را. به همین دلیل است که Page پس از هر AddPage مجدداً از CurrentPage خوانده می‌شود، و چرا SetFont قبل از ردیف‌های بدنه مجدداً صادر می‌شود. خواندن مجدد را نادیده بگیرید تا همچنان روی صفحه‌ای که به تازگی پشت سر گذاشته‌اید نقاشی کنید؛ فونت را نادیده بگیرید تا صفحه جدید با هر پیش‌فرضی که نمایشگر به آن برمی‌گردد رندر شود

مواردی که یک صادرکننده جدول را خراب می‌کنند

بیشتر باگ‌های جدول در مسیر خوشایند (happy path) چند ده ردیف مرتب نشان داده نمی‌شوند. آن‌ها در لبه‌ها زندگی می‌کنند، و وقتی بدانید آن‌ها در کجا هستند، آزمایش لبه‌ها ارزان است

  • مجموعه داده‌های خالی. یک حلقه روی صفر ردیف یک صفحه با سرصفحه و بدون هیچ چیزی در زیر آن تولید می‌کند، که حداقل عمدی به نظر می‌رسد. یک صفحه خالی بدون سرصفحه شبیه یک شکست به نظر می‌رسد. قبل از ارسال (shipping) تصمیم بگیرید که کدام را می‌خواهید
  • ردیفی که دقیقاً روی مرز قرار می‌گیرد. گزارشی ایجاد کنید که آخرین ردیف آن یک پله بالاتر از حاشیه باشد، سپس گزارشی که ردیف بعدی آن یک پله پایین‌تر از آن باشد. خطای صفحه‌بندی off-by-one پنهان می‌ماند تا زمانی که داده‌ها دقیقاً دارای طول اشتباه باشند
  • مقادیر بیش از حد طولانی. نام شرکتی که پهن‌تر از ستون خود باشد وارد ستون بعدی می‌شود. فیلد را اندازه بگیرید و در مورد یک خط‌مشی تصمیم بگیرید: پیچیدن (wrap) به خط دوم، بریدن (clip)، یا کوتاه کردن با نقطه‌چین (ellipsis). سکوت یک خط‌مشی نیست
  • فیلدهای تهی (Null fields). خواندن یک فیلد تهی به طور مستقیم در TextOut بسته به نحوه تبدیل آن، می‌تواند به عنوان متن واقعی Null یا به صورت یک فضای خالی ظاهر شود. رندرینگ را به طور آگاهانه انتخاب کنید تا اینکه اجازه دهید تبدیل نوع (variant conversion) برای شما انتخاب کند

قبل از اینکه آن را تمام شده بدانید، نتیجه را در بیش از یک نمایشگر (viewer) بررسی کنید. جایگزینی فونت و برش (clipping) در میان رندرکننده‌ها رفتار متفاوتی دارند، و جدولی که در یک پی‌دی‌اف خوان (PDF reader) مربع به نظر می‌رسد، در خواننده‌ای دیگر می‌تواند ستون ناتراز یا یک شهر بریده شده را نشان دهد. تأیید کنید که سرصفحه تکرار شده، سایه ردیف‌ها و حاشیه‌ها در این جابجایی دست‌نخورده باقی می‌مانند، و اعداد صفحه پس از عبور داده‌ها از یک مرز، پیوسته می‌مانند

رسم شبکه توسط خودتان به جای تکیه بر یک طراح گزارش بصری کدهای بیشتری می‌طلبد، و مصالحه (tradeoff) ارزش آن را دارد که به صراحت نام برده شود: شما مالک تمام مختصات هستید، که دقیقاً همان چیزی است که برای کارهای دسته‌ای سمت سرور، فاکتورها، و خروجی‌های حسابرسی که باید به طور یکسان در هر دستگاه رندر شوند می‌خواهید، و دقیقاً همان سرباری است که ترجیح می‌دهید برای یک لیست‌بندی داخلی یک‌باره از آن اجتناب کنید. برای مورد اول، این کنترل اولین باری که گزارش در تولید باید دقیقاً شبیه به زمانی باشد که روی میز شما بود، ارزش خود را نشان می‌دهد

خطوط و نوارهای سایه‌دار بالا بر روی همان بردارها (vectors) و عناصر اولیه رنگی (color primitives) تکیه می‌کنند که در راهنمای قدم به قدم رسم بوم (canvas drawing) پوشش داده شده است، اگر می‌خواهید فراخوانی‌های Rectangle، MoveTo، و LineTo در ابتدا به تنهایی بررسی شوند. عناصر اولیه رسم استفاده شده در اینجا بخشی از کامپوننت HotPDF برای Delphi و C++Builder است