บทความเทคนิค

การเรนเดอร์ตารางข้อมูลเป็น PDF ใน Delphi ด้วย HotPDF

ชุดข้อมูล (dataset) ประกอบด้วยแถวและคอลัมน์ ส่วนหน้า PDF คือกริดพิกัดว่างเปล่าที่ไม่มีแนวคิดของทั้งสองอย่าง การเชื่อมโยงช่องว่างนั้นคือหน้าที่ทั้งหมดในที่นี้ HotPDF ไม่มีคำสั่ง DrawTable ที่รับชุดข้อมูลแล้วส่งคืนกริดที่จัดรูปแบบแล้วให้คุณ สิ่งที่คุณได้มาคือองค์ประกอบพื้นฐานที่ใช้สร้างกริด: TextOut เพื่อวางสตริงที่จุด ๆ หนึ่ง, SetFont เพื่อเลือกแบบอักษร, Rectangle และ Fill เพื่อระบายสีแถบ และ MoveTo / LineTo / Stroke เพื่อวาดเส้น ตัวส่งออกตารางที่ทำงานได้จริงต้องใช้ความมีระเบียบวินัยในการเปลี่ยนแนวคิดของแถวและคอลัมน์ให้เป็นพิกัด x และ y ที่ชัดเจน และรักษาพิกัดเหล่านั้นให้ถูกต้องเมื่อข้อมูลยาวเลยขอบล่างของหน้ากระดาษ

ตัวอย่างต่อไปนี้เป็นรายงานข้อมูลลูกค้า แต่ไม่มีโค้ดวาดส่วนใดที่รู้หรือสนใจว่าแถวข้อมูลมาจากไหน ตัวอย่างต้นฉบับใช้ TTable รุ่นเก่า แต่ไม่ว่าจะเป็นคิวรี FireDAC ชุดข้อมูลในหน่วยความจำ หรืออาร์เรย์เรคอร์ดธรรมดาก็สามารถส่งให้รูทีนเดียวกันนี้ได้โดยไม่ต้องแก้ไข สิ่งสำคัญคือคุณสามารถวนลูปข้อมูลได้ทีละแถวและอ่านฟิลด์สตริงสี่ฟิลด์จากแต่ละแถวได้ หากแยกระบบการวาดออกจากแหล่งข้อมูล คุณก็สามารถเปลี่ยนแปลงฝั่งใดฝั่งหนึ่งได้โดยไม่กระทบอีกฝั่ง

รูปทรงของคอลัมน์ต้องมาก่อน

ก่อนจะวาดตัวอักษรตัวแรก ให้ตัดสินใจก่อนว่าแต่ละคอลัมน์จะอยู่ที่ไหน ตารางนี้มีสี่คอลัมน์ จึงต้องมีขอบซ้ายสี่ด้านและระยะขอบขวาที่แน่นอน การฝังค่าตัวเลขลงไปตรง ๆ ในทุกครั้งที่เรียก 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;

มีรายละเอียดสองจุดที่สำคัญที่นี่ แถบแรเงาจะถูกวาดก่อน จากนั้นจึงวาดข้อความทับลงไป เนื่องจากลำดับการระบายคือ z-order ใน PDF: หากคุณเติมสีสี่เหลี่ยมหลังจากวาดข้อความ ข้อความของแถวนั้นจะถูกฝังทับ และการสลับสีแรเงาไม่ได้มีไว้เพื่อความสวยงามเพียงอย่างเดียว ในรายงานที่หนาแน่น มันเป็นวิธีที่ประหยัดที่สุดในการป้องกันไม่ให้สายตาเลื่อนผิดบรรทัด ซึ่งเป็นเหตุผลที่การวนลูปในภายหลังจะสลับค่าบูลีนในทุกแถวและส่งตรงเข้าไปที่ Shaded

ตำแหน่งคอลัมน์ด้านบนนี้เป็นค่าคงที่ ซึ่งเหมาะสมสำหรับรายงานที่คุณควบคุมโครงสร้างได้ เมื่อข้อมูลสามารถผันแปรได้ ให้ใช้วิธีวัดแทนการเดา HotPDF รองรับการวัดความกว้างของข้อความในออบเจกต์หน้ากระดาษ ดังนั้นเวอร์ชันจริงของ PrintRow จึงสามารถรับค่าที่คาดว่าจะยาวที่สุดในแต่ละคอลัมน์ วัดค่าเพียงครั้งเดียวที่ขนาดฟอนต์ที่เลือก และดึงขอบซ้ายมาจากความกว้างเหล่านั้นบวกกับระยะช่องว่าง โครงสร้างของรูทีนจะไม่เปลี่ยนแปลง สิ่งที่เปลี่ยนไปคือที่มาของค่าคงที่เท่านั้น

ส่วนหัว เส้นบรรทัด และจุดเดียวที่ควบคุมสิ่งเหล่านี้

ตารางที่เลื่อนหลุดหน้ากระดาษและไปต่อในหน้าถัดไปโดยไม่มีชื่อคอลัมน์กำกับนั้นอ่านไม่ออก วิธีแก้ปัญหาคือต้องมองว่าส่วนหัวตารางคือสิ่งที่คุณต้องวาดใหม่ซ้ำ ๆ ไม่ใช่สิ่งที่คุณวาดเพียงครั้งเดียวเดียว วางชื่อคอลัมน์และเส้นกรอบแนวนอนไว้ในรูทีนเดียว และเรียกรูทีนนั้นทั้งที่จุดเริ่มต้นและทุกครั้งที่คุณเปิดหน้าใหม่ เนื่องจากส่วนหัวและส่วนเนื้อหาใช้ค่าคงที่ของคอลัมน์ชุดเดียวกัน พวกมันจึงเรียงตรงกันตามโครงสร้าง

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 แบบ reference และเลื่อนค่าไปข้างหน้า ผู้เรียกไม่ต้องจำว่าส่วนหัวตารางสูงเท่าไหร่ รูทีนที่วาดมันคือรูทีนที่รู้ กฎการรับผิดชอบจุดเดียวนี่แหละที่จะช่วยให้เลย์เอาต์ไม่ผิดเพี้ยนเวลาคุณเพิ่มโลโก้หรือผลรวมการกรองลงในแถบส่วนหัวในภายหลัง ลูปสำหรับส่วนเนื้อหาก็ไม่ต้องสนใจอะไร มันแค่ทำหน้าที่วาดแถวต่อไปเรื่อย ๆ จากตำแหน่งใดก็ตามที่ Y ชี้อยู่

เส้นบรรทัดเองคือสิ่งที่สร้างความแตกต่างระหว่างรายการกับตาราง เส้นคั่นคอลัมน์แนวตั้งก็ใช้แนวคิดเดียวกับแกน x: คือการ MoveTo / LineTo / Stroke ที่แต่ละขอบคอลัมน์ โดยลากจากเส้นบรรทัดบนสุดไปจนถึงขอบล่างสุดของบรรทัดสุดท้ายในหน้ากระดาษ ตัวอย่างนี้ใช้เฉพาะเส้นบรรทัดแนวนอนเพื่อให้ดูอ่านง่าย แต่ในขั้นตอนการใช้งานจริงก็ทำได้ง่ายเหมือนเครื่องจักรเมื่อคุณมีค่าคงที่ของคอลัมน์แล้ว

การขึ้นหน้าใหม่ควบคุมโดยลูปเลื่อน

การวาดเป็นเพียงส่วนที่ง่าย ส่วนที่แยกแยะของเล่นออกจากรายงานจริง ๆ คือการแบ่งหน้า: การรู้ตัวก่อนจะวาดแถวว่ายังมีพื้นที่เหลือหรือไม่ และการเริ่มหน้ากระดาษใหม่พร้อมกับส่วนหัวใหม่เมื่อไม่มีพื้นที่แล้ว การตัดสินใจนี้ต้องอยู่ที่จุดเดียวเท่านั้น นั่นคือลูปที่วนผ่านข้อมูล และไม่ควรอยู่ที่อื่น

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 จาก CurrentPage ใหม่อีกครั้งทุกครั้งหลังจากทำ AddPage และเหตุใดจึงต้องเรียก SetFont ใหม่ก่อนที่จะเริ่มพิมพ์แถวในส่วนเนื้อหา หากข้ามการอ่านใหม่ไป คุณจะยังคงวาดลงบนหน้าที่เพิ่งจากมา และหากข้ามการตั้งฟอนต์ไป หน้าใหม่จะเรนเดอร์ด้วยค่าเริ่มต้นใด ๆ ก็ตามที่โปรแกรมอ่านใช้

กรณีที่ทำให้ตัวส่งออกตารางมีปัญหา

ข้อผิดพลาดเกี่ยวกับตารางส่วนใหญ่มักไม่ค่อยแสดงอาการเมื่อข้อมูลมีแค่ไม่กี่สิบแถวที่เป็นระเบียบเรียบร้อย มันซ่อนอยู่ที่ขอบ และขอบเหล่านั้นตรวจสอบได้ไม่ยากเมื่อคุณรู้ว่ามันอยู่ที่ไหน

  • ชุดข้อมูลว่างเปล่า ลูปที่ทำงานบนศูนย์แถวจะสร้างหน้ากระดาษที่มีส่วนหัวแต่ไม่มีอะไรอยู่ด้านล่าง ซึ่งอย่างน้อยก็ดูเหมือนว่าตั้งใจให้เป็นแบบนั้น หน้ากระดาษเปล่าที่ไม่มีส่วนหัวจะดูเหมือนความล้มเหลว ตัดสินใจเลือกแบบที่ต้องการก่อนจะเปิดใช้งานระบบจริง
  • แถวที่ตกกระทบเส้นขอบพอดิบพอดี ลองสร้างรายงานที่แถวสุดท้ายอยู่สูงกว่าเส้นขอบ 1 สเต็ป จากนั้นรายงานถัดไปมีอีกแถวที่ต่ำกว่าเส้นขอบ 1 สเต็ป ปัญหาการแบ่งหน้าที่คลาดเคลื่อนไปหนึ่งช่องจะซ่อนตัวอยู่จนกว่าความยาวข้อมูลจะไม่พอดีกันเป๊ะ ๆ
  • ค่าที่ยาวเกินไป ชื่อบริษัทที่กว้างกว่าคอลัมน์จะวิ่งทะลุเข้าไปในคอลัมน์ถัดไป ลองวัดฟิลด์ดูและตัดสินใจเลือกนโยบาย: ตัดขึ้นบรรทัดใหม่, ตัดส่วนเกินทิ้ง, หรือตัดทิ้งด้วยเครื่องหมายจุดไข่ปลา (...) การปล่อยผ่านเงียบ ๆ ไม่ใช่นโยบาย
  • ฟิลด์ที่ค่าเป็น Null การอ่านค่า Null เข้าไปใน TextOut ตรง ๆ อาจแสดงออกมาเป็นข้อความว่า Null อย่างจัง หรืออาจจะเป็นที่ว่าง ขึ้นอยู่กับวิธีที่คุณแปลงค่า เลือกการแสดงผลอย่างมีสติ แทนที่จะปล่อยให้ฟังก์ชันแปลงตัวแปรกำหนดให้คุณ

ลองรันผลลัพธ์ผ่านโปรแกรมอ่านมากกว่าหนึ่งตัวก่อนจะสรุปว่าเสร็จสมบูรณ์ การแทนที่แบบอักษรและการตัดขอบจะแสดงผลต่างกันไปในแต่ละตัวเรนเดอร์ และตารางที่ดูเป็นสี่เหลี่ยมจัตุรัสในโปรแกรมอ่าน PDF ตัวหนึ่งอาจแสดงให้เห็นคอลัมน์ที่เยื้องกันหรือเมืองที่ถูกตัดในโปรแกรมอ่านอีกตัว ยืนยันว่าส่วนหัวที่ซ้ำกัน การแรเงาแถว และระยะขอบนั้นยังคงอยู่ และหมายเลขหน้าจะต้องต่อเนื่องกันแม้ข้อมูลจะข้ามเส้นแบ่งหน้าไปแล้ว

การวาดกริดด้วยตัวเองแทนที่จะพึ่งพาโปรแกรมออกแบบรายงานแบบเห็นภาพนั้นต้องเขียนโค้ดเยอะกว่า และการแลกเปลี่ยนนี้ก็คุ้มค่าที่จะระบุให้ชัดเจน: คุณสามารถควบคุมได้ทุกพิกัด ซึ่งเป็นสิ่งที่คุณต้องการเป๊ะ ๆ สำหรับงานประมวลผลเป็นชุดจากฝั่งเซิร์ฟเวอร์ ใบแจ้งหนี้ และการส่งออกข้อมูลการตรวจสอบที่ต้องเรนเดอร์ออกมาเหมือนกันเป๊ะบนทุกเครื่อง และนี่คือภาระหน้าที่ที่คุณคงไม่อยากเจอสำหรับการดูรายการภายในแบบครั้งเดียวทิ้ง สำหรับงานแบบแรก การที่คุณควบคุมมันได้จะคุ้มค่าคุ้มราคาก็ตอนที่รายงานออกมาเหมือนกับตอนที่คุณดูบนเครื่องของคุณทุกประการ

กฎและแถบแรเงาด้านบนนี้ใช้รูปแบบเวกเตอร์และสีพื้นฐานเดียวกันกับที่ครอบคลุมไว้ในคำแนะนำการวาดแคนวาส หากคุณต้องการศึกษาเกี่ยวกับการเรียกใช้ Rectangle, MoveTo และ LineTo แยกต่างหาก องค์ประกอบพื้นฐานสำหรับการวาดที่ใช้ที่นี่เป็นส่วนหนึ่งของ HotPDF Component สำหรับ Delphi และ C++Builder