技術文章

在 PDF 中使用 HotPDF 基礎元素繪製圖表

HotPDF 沒有圖表物件。沒有 TPDFChart,沒有 AddBarSeries,也沒有任何可以接收數字陣列並傳回轉譯後圖表的工具。相反地,它提供給您的是一個頁面畫布(canvas),其中包含每個 PDF 繪圖模型都使用的相同低階字彙:置於精確座標處的矩形、線條、圓形、填滿(fill)、筆劃(stroke)和文字。因此,HotPDF 文件中的圖表是您建置出來的,而不是請求出來的。這聽起來似乎比實際工作量更多,但一旦您寫過一次座標數學,長條圖就只是矩形上的迴圈,折線圖是折線(polyline),而圓餅圖是弧形扇區,且您能控制結果的每一個像素

這非常重要,因為人們首先想到的替代方案(將螢幕圖表控制項點陣化(rasterizing)為點陣圖(bitmap)並將影像貼入頁面中)會讓圖表受限於螢幕解析度,導致列印模糊並使檔案膨脹。使用 HotPDF 的向量基礎元素繪製圖表可以使輸出在任何縮放比例和任何列印 DPI 下保持清晰,因為長條和軸線是真正的 PDF 路徑運算子,而非像素。代價是您必須自行負責版面配置。其運作機制歸結為幾個步驟:困擾所有人的座標翻轉、實際運作的長條圖、折線圖的折線技巧,以及圓餅圖扇區的弧形數學

唯一困難的部分是座標系統

螢幕圖形將原點(origin)設在左上角,且 Y 軸向下延伸。PDF 則恰恰相反。原點位於頁面的左下角,且 Y 軸向上延伸,以點(1/72 英吋)為單位。HotPDF 中的每個繪圖呼叫 TextOutRectangleMoveToLineToCircle 都使用左下角、Y 軸向上的約定。如果您沿用螢幕圖形的直覺,您的第一個圖表將會倒過來繪製,並超出頁面底部

因此,在任何圖表中,真正的工作是一項對應:將資料值轉換為遵循 Y 軸向上的 Y 座標。決定一個繪圖矩形(圖表所在的區域,由四個數字表示),然後將資料中的最小值對應到下邊緣,最大值對應到上邊緣。對於刻度從 0 到 MaxValue 且值為 V 的長條,長條的上邊緣為 PlotBottom + (V / MaxValue) * PlotHeight,且長條從 PlotBottom 向上延伸。實踐這一個運算式,其他一切都只是例行工作。底下的輔助常式保存了繪圖幾何資訊並進行轉換,因此繪圖程式碼不需要重複處理原始算術:

type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // PDF points, bottom-left origin
    MaxValue: Single;                     // top of the value scale
  end;

// Map a data value to its Y coordinate inside the plot, Y growing upward.
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom + (V / Plot.MaxValue) * Plot.Height;
end;

MaxValue 中隱藏了一個判斷。如果您將其精確設定為最大的資料點,則最高的長條將會觸及繪圖區域的上邊緣,看起來會被裁切。請將其無條件進位到最大值之上的整數,例如 10 或 100 的下一個倍數,這樣圖表就有頂部空間,且格線(gridline)標籤讀取起來會是整數,而不是資料剛好達到的峰值

長條圖是矩形上的迴圈

隨著對應關係的確定,長條圖的編寫就變得十分簡單。將繪圖寬度劃分為每個類別一個位置,在長條之間留出間隙以防接觸,並將每個長條繪製為填滿的矩形,其高度來自 ValueToY。HotPDF 的 Rectangle 接收左下角座標加上寬度和高度,這正好與從基準線(baseline)向上延伸的長條對齊。先設定填滿顏色,規劃好路徑,然後呼叫 Fill 來繪製它。類別標籤位於基準線下方,數值位於長條上方:

procedure DrawBarChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single; const Labels: array of string);
var
  I, Count: Integer;
  SlotW, BarW, BarX, BarH, Gap: Single;
begin
  Count := Length(Values);
  SlotW := Plot.Width / Count;
  Gap := SlotW * 0.25;          // quarter-slot gap on each side
  BarW := SlotW - Gap;

  // Baseline (the X axis) along the bottom of the plot.
  Page.SetLineWidth(1.0);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
  Page.Stroke;

  Page.SetFont('Arial', [], 9);
  for I := 0 to Count - 1 do
  begin
    BarX := Plot.Left + I * SlotW + Gap / 2;
    BarH := ValueToY(Plot, Values[I]) - Plot.Bottom;

    Page.SetRGBFillColor(RGB(56, 110, 219));
    Page.Rectangle(BarX, Plot.Bottom, BarW, BarH);  // X, Y, Width, Height
    Page.Fill;

    // Category label below the baseline, value above the bar.
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom - 14, 0, Labels[I]);
    Page.TextOut(BarX, Plot.Bottom + BarH + 4, 0,
      FormatFloat('0', Values[I]));
  end;
end;

有兩個細節非常實用。Gap 是位置的一部分,而不是固定的點數,因此無論您是繪製四個類別還是四十個類別,長條都會保持比例間隔。此外,數值標籤定位使用與長條相同的 ValueToY 導出高度,因此它總是剛好位於其專屬長條的上方,而不是浮動在猜測的偏移量處。如果您希望在長條後方繪製水平格線,請在迴圈之前繪製它們:選擇三或四個整數值,在每個數值上執行 ValueToY,並在該 Y 處橫向繪製一條微弱的線條。先繪製它們可以將其置於長條後方,這符合 PDF 使用的畫家模型(painter's-model)堆疊方式

軸線、刻度及標籤只是更多的線條和文字

圖表必須要讓讀者能明白這些長條所代表的意義才算完成,而這完全是軸線的工作。垂直軸是沿著繪圖區域左邊緣繪製的一條直線,帶有少數幾個刻度標記(tick marks)及其數值。重複使用 ValueToY 以便刻度落在長條使用的相同比例尺上,否則長條與其格線不一致,圖表就會默默地出錯:

procedure DrawValueAxis(Page: THPDFPage; const Plot: TPlotArea;
  TickCount: Integer);
var
  I: Integer;
  TickV, TickY: Single;
begin
  Page.SetLineWidth(1.0);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left, Plot.Bottom + Plot.Height);
  Page.Stroke;

  Page.SetFont('Arial', [], 8);
  for I := 0 to TickCount do
  begin
    TickV := (Plot.MaxValue / TickCount) * I;
    TickY := ValueToY(Plot, TickV);
    Page.MoveTo(Plot.Left - 4, TickY);   // short tick outside the axis
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;
    Page.TextOut(Plot.Left - 30, TickY - 3, 0, FormatFloat('0', TickV));
  end;
end;

標籤是圖表在實際運作中最常出錯的地方,且失效情況總是相同:在螢幕上合適的文字,在 PDF 中超出了其空間。長類別名稱會與相鄰標籤碰撞,且像「septembre」或「Dezember」這樣本地化的月份名稱會比您測試用的英文「Sep」更寬。這裡沒有自動大小調整(autosize)可以救您,因此請在基準線下方保留足夠的邊距,針對密集的類別集將字型縮小一或二點,如果名稱真的很長,請旋轉它們。TextOut 接收一個角度作為其第三個引數,因此傳入 90 會使標籤直立,為您爭取空間而不重疊。在匯出出貨前,請使用您預期最寬的標籤進行版面配置測試,而不是最短的標籤

折線圖:通過對應點的一條折線

折線圖重複使用整個數值對應關係,僅改變了點的連接方式。與每個類別一個矩形不同,您只需對資料進行一次走訪,使用 ValueToY 將每個值轉換為其 (X, Y) 座標,並使用單個 MoveTo 後跟 LineTo 呼叫將這些點連接起來,最後進行筆劃繪製。第一個點開闢路徑,隨後的每個點都會延伸它:

procedure DrawLineChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single);
var
  I, Count: Integer;
  StepX, X, Y: Single;
begin
  Count := Length(Values);
  if Count < 2 then Exit;
  StepX := Plot.Width / (Count - 1);

  Page.SetLineWidth(1.5);
  Page.SetRGBStrokeColor(RGB(214, 92, 36));
  for I := 0 to Count - 1 do
  begin
    X := Plot.Left + I * StepX;
    Y := ValueToY(Plot, Values[I]);
    if I = 0 then
      Page.MoveTo(X, Y)        // open the path at the first point
    else
      Page.LineTo(X, Y);       // extend it through every later point
  end;
  Page.Stroke;                 // one stroke paints the whole polyline
end;

請注意間距差異。長條圖用寬度除以長條數量,因為每個長條佔用一個位置。折線圖除以間隔數 Count - 1,因為起點和終點位於繪圖區域邊緣,而折線跨越它們之間的間隙。混淆這兩者通常會導致折線圖偏離其原本要疊加的長條圖半個位置。如果您希望在每個資料點處都有一個標記,請在折線繪製完成後,在每個 (X, Y) 處放置一個小的 Circle 並進行 Fill

圓餅圖:弧形,或保持簡單時使用楔形

圓餅圖扇區是唯一需要三角學的形狀,因為楔形由兩個半徑和一條弧線界定。老實說,實作版本是透過沿著圓周移動微小線段來掃掠弧形,這在逼近曲線方面已經足夠接近,讀者根本無法察覺。每個扇區的掃掠角度是其佔總數的比例 (Value / Total) * 2π,並且您在繞行時累積目前的累積角度:

procedure DrawPieChart(Page: THPDFPage; CX, CY, Radius: Single;
  const Values: array of Single; const Colors: array of TColor);
var
  I, Step, Steps: Integer;
  Total, Start, Sweep, A: Single;
begin
  Total := 0;
  for I := 0 to High(Values) do Total := Total + Values[I];
  Start := 0;

  for I := 0 to High(Values) do
  begin
    Sweep := (Values[I] / Total) * 2 * Pi;
    Steps := Round(Sweep / (Pi / 90)) + 1;  // ~2 degrees per segment

    Page.SetRGBFillColor(Colors[I]);
    Page.MoveTo(CX, CY);                     // wedge apex at the center
    for Step := 0 to Steps do
    begin
      A := Start + Sweep * (Step / Steps);
      Page.LineTo(CX + Radius * Cos(A), CY + Radius * Sin(A));
    end;
    Page.LineTo(CX, CY);                      // close back to the center
    Page.Fill;

    Start := Start + Sweep;                   // advance to the next slice
  end;
end;

由中心向外的路徑(頂點最先,然後是弧線,最後回到頂點)會提供一個閉合的楔形,Fill 會對其進行實心繪製。區段(segment)數量在平滑度與路徑大小之間進行權衡:在任何合理的半徑下,每步大約兩度看起來就很圓,且不會產生龐大的內容串流。如果您不需要真正的圓形,您可以完全跳過三角函數,並將相同的資料呈現為單個水平堆疊長條,每個區段的寬度與其份額成比例。無論如何,這通常比圓餅圖更易讀,而且它只是將矩形端對端放置的長條圖程式碼。只有當設計要求使用實際的圓形時,才使用弧形版本

所有這些都不依賴於安裝任何圖表庫,這是直接繪製基礎圖形的無聲優勢。用來放置商標(logo)或簽名框的相同畫布繪圖呼叫建置了這些圖表,且標記表單欄位的相同 TextOut 標記了座標軸。將繪圖幾何結構放入記錄(record)中,對應數值到 Y 一次,長條圖、折線圖或圓餅圖就是一個由 RectangleLineToCircle 組成的簡短常式,您可以將其拖入任何報表中。此處使用的 RectangleMoveToLineToCircleFillStrokeTextOut 呼叫均屬於適用於 Delphi 和 C++Builder 的 HotPDF 元件的一部分