技術文章

在 Delphi 中使用 HotPDF 將資料表轉譯為 PDF

資料集(dataset)是資料列和資料欄;而 PDF 頁面只是一個空白座標網格,對兩者皆沒有概念。彌合這一差距是此處的全部工作。HotPDF 中沒有可以接收資料集並傳回格式化網格的 DrawTable 呼叫。相反地,您所獲得的是構成網格的基礎元素:在特定點放置字串的 TextOut、選擇字型的 SetFont、對區段著色的 RectangleFill,以及用來繪製線條的 MoveTo / LineTo / Stroke。一個實用的表格匯出器(table exporter)是將列與欄的思想轉換為精確的 x 和 y 座標,並在資料超出頁面底部時讓這些座標保持精準的學問

接下來的範例會呈現客戶記錄,但繪圖程式碼中沒有任何內容知道或關心這些資料列的來源。最初的版本使用舊有的 TTable;而 FireDAC 查詢、記憶體內資料集(in-memory dataset)或單純的記錄陣列(array of records)也可以在不修改的情況下提供相同的常式。重要的是您可以一次走訪一行資料,並從中讀取四個字串欄位。將轉譯與資料來源分開,您就可以在不影響另一側的情況下更改任意一側

資料欄幾何結構置於首位

在繪製任何單一字元之前,請決定每一直欄的位置。這裡的表格有四個資料欄,因此它需要四個左邊界和一個已知的右邊界。像快速範例中常見的那樣,在每次 TextOut 呼叫中寫死(hard-coding)一個魔術數字,正是日後擴展表格時痛苦的原因。在以左下角為原點的點數中為這些邊緣命名一次,此後的每個繪圖呼叫都按名稱引用它們:

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;

這裡有兩個細節非常有用。著色區段首先繪製,然後在上方繪製文字,因為 PDF 中的繪製順序是 Z 軸順序(z-order):在文字之後填滿矩形,您將會覆蓋該資料列。而且,交替的陰影並非僅為了裝飾。在密集的報表上,這是防止視線滑動到錯誤行上的最廉價方法,這就是為什麼後續的迴圈會在每一列上切換布林值,並將其直接傳遞給 Shaded 的原因

上述資料欄位置是固定的,這對於您控制架構的報表而言是合理的。當資料是變動時,請進行量測而非猜測。HotPDF 在頁面物件上顯露了文字寬度量測功能,因此 PrintRow 的生產版本可以獲取每欄中預期最長的值,在選定的字型大小下量測一次,並從這些寬度加上裝訂邊(gutter)導出左邊界。該常式的結構不會改變;改變的只是常數的來源

頁首、線條及其所屬的單一位置

超出頁面並在下一頁重新開始、但沒有資料欄標籤的表格是無法閱讀的。修復方法是將頁首視為您需要重新繪製的內容,而不是只繪製一次的內容。將資料欄標題和框架它們的水平線放在單個常式中,並在開始時以及每次開啟新頁面時都呼叫該常式。因為頁首和主體共用相同的資料欄常數,所以它們在結構上是對齊的

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 並將其移步。呼叫端永遠不需要記住頁首有多高;負責繪製它的常式就是知道高度的常式。這一單一擁有權規則是當您日後向頁首區域添加商標或篩選摘要時,防止版面配置發生漂移的關鍵。主體迴圈完全不需要知道這些。它只需從 Y 目前指向的任何地方繼續繪製資料列即可

橫線本身是清單與表格之間的區別。垂直資料欄分隔線是套用於 x 軸的相同想法:在每個資料欄邊緣進行 MoveTo / LineTo / Stroke,從頂部橫線一直延伸到頁面最後一列的底部。本範例僅保留水平橫線以保持可讀性,但一旦存在資料欄常數,生產步驟就是機械式的了

指標迴圈決定分頁

兩個座標事實驅動了整個迴圈。PDF 從左下角向上量測 Y,因此每次從 Y 中減去 RowStep 時,資料列就會沿著頁面向下延伸,且當 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 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;

另一個事實幾乎會讓每個人都出錯一次。AddPage 建立一個新頁面並將 CurrentPage 重新指向它,但它不攜帶任何內容:沒有字型、沒有填滿顏色、也沒有位置。這就是為什麼在每次 AddPage 之後都要從 CurrentPage 重新讀取 Page,以及為什麼要在主體資料列之前重新發出 SetFont 的原因。如果跳過重新讀取,您將會繼續繪製在剛離開的頁面上;如果跳過字型,新頁面將會以檢視器預設的任何字型進行轉譯

會毀壞表格匯出器的案例

大多數表格 Bug 不會出現在幾十行整潔資料列的正常路徑上。它們存在於邊緣,而一旦您知道它們在哪裡,測試邊緣的成本就很低

  • 空白資料集。針對零列進行迴圈會產生一個帶有頁首且下方沒有任何內容的頁面,這至少看起來是刻意的。沒有頁首的空白頁看起來就像一個失敗。在出貨前決定您想要哪一種
  • 剛好落在邊界上的資料列。產生一份最後一列位於邊界上方一步的報表,然後產生一份下一列位於邊界下方一步的報表。差一錯誤(Off-by-one)的分頁會一直隱藏,直到資料長度剛好出現問題
  • 超長的值。寬度大於其資料欄的客戶名稱將會延伸到下一個資料欄中。量測欄位並決定策略:換行到第二行、裁剪(clip)或以省略號截斷。保持沉默不是一項策略
  • Null 欄位。直接將 Null 讀入 TextOut 可能會呈現為字面文字 Null 或空白,具體取決於您如何轉換它。請自覺地選擇轉譯方式,而不是讓變體(variant)轉換替您做出選擇

在您宣稱完成之前,請在多個檢視器中執行結果。字型替換和裁剪在不同的轉譯器中表現不同,在某一個 PDF 閱讀器中看起來很整齊的表格,在另一個 PDF 閱讀器中可能會顯示對齊不良的資料欄或被裁剪的城市。請確認重複的頁首、資料列陰影和邊距在移動後仍然完好,並且在資料跨越邊界後頁碼保持連續

自己繪製網格而不是依賴視覺化報表設計器需要更多的程式碼,其折衷權衡值得明確說出:您擁有了每一個座標,這正是您對於伺服器端批次作業、發票和稽核匯出(它們必須在每台電腦上進行完全相同的轉譯)所需要的,也是您在處理一次性內部清單時寧願避免的開銷。對於前者,當報表在實際運作中第一次必須與在您桌上時呈現完全相同的外觀時,這種控制力就體現了它的價值

如果您希望首先單獨處理 RectangleMoveToLineTo 呼叫,上述橫線和著色區段依賴於畫布繪圖逐步指南中所涵蓋的相同向量和色彩基礎元素。此處使用的繪圖基礎元素屬於適用於 Delphi 和 C++Builder 的 HotPDF 元件的一部分