مقاله فنی

رندر کردن جدول داده‌ها به 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، همانطور که نمونه‌های سریع تمایل به انجام آن دارند، دقیقاً همان چیزی است که عریض‌تر کردن جدول را در آینده دردناک می‌کند. لبه‌ها را یک بار، بر حسب پوینت از مبدأ پایین-چپ نام‌گذاری کنید، و هر فراخوانی رسم با نام به آن‌ها اشاره می‌کند:

const
  ColNo   = 70;    // left edge of the "No." column
  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;    // vertical distance between baselines

procedure PrintRow(Page: THPDFPage; Y: Single;
  const ANo, AName, AAddr, ACity: string; Shaded: boolean);
begin
  if Shaded then
  begin
    // A shaded band behind the row. Rectangle takes 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) به عنوان چیزی برخورد کنید که دوباره آن را رسم می‌کنید، نه چیزی که یک بار آن را می‌کشید. عناوین ستون‌ها و خطوط افقی را که آن‌ها را قاب می‌کنند در یک روتین واحد قرار دهید، و آن روتین را هم در ابتدا و هم دوباره هر بار که یک صفحه جدید باز می‌کنید فراخوانی کنید. از آنجا که سرصفحه و بدنه از ثابت‌های ستون یکسانی به اشتراک استفاده می‌کنند، در ساختار با هم هم‌تراز می‌شوند

procedure DrawHeader(Page: THPDFPage; var Y: Single; PageNo: Integer);
begin
  // Left: source label and page number. Right: generation time.
  Page.SetFont('Arial', [fsItalic], 10);
  Page.TextOut(RowLeft, Y, 0, 'customer.db   Page ' + IntToStr(PageNo));
  Page.TextOut(ColCity, Y, 0, DateTimeToStr(Now));

  // Two horizontal rules that box the column titles.
  Page.MoveTo(RowLeft, Y + 15);
  Page.LineTo(RowRight, Y + 15);
  Page.MoveTo(RowLeft, Y + 45);
  Page.LineTo(RowRight, Y + 45);
  Page.Stroke;

  // The column titles, in a heavier face so they read as headings.
  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;  // advance past the boxed header before the first body row
end;

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

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

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

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

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 title, once, at the top of the first 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
      // Out of room? Open a new page and repeat the header there.
      if Y < 60 then
      begin
        Pdf.AddPage;
        Page := Pdf.CurrentPage;   // AddPage moves CurrentPage forward
        Inc(PageNo);
        Y := 760;
        DrawHeader(Page, Y, PageNo);
      end;

      Shaded := not Shaded;
      Page.SetFont('Arial', [], 10);   // SetFont must be reissued on every new 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 است