把螢幕圖表控制項複製成一張 96 DPI 點陣圖放進 PDF,在檢視器 100% 縮放下看著不錯。放大到 400%,或把頁面送進 600 DPI 雷射印表機,長條邊緣就軟掉、座標軸標籤糊成灰色污點,檔案還為了十幾個矩形背著幾百 KB 的像素。改畫成向量路徑,同一張圖表在每個縮放級別與列印解析度下都保持銳利,因為長條、軸線與標籤是 PDF 路徑與文字運算子,檢視器會按需要的比例重新渲染
向量輸出把版面工作搬進你的程式碼。得有東西算出每根長條、每個刻度與每個標籤落在哪裡,而對長條、折線與圓餅圖來說,那個計算就是一個座標映射加上幾個對矩形、線段與弧的迴圈。映射一旦做對,迴圈都很短
HotPDF 該用 DrawChart 還是手繪基本元素?
HotPDF 附了現成的圖表渲染器,標準報表圖表應該先試它。建一個 THPDFChart,用 AddSeries 加系列(種類有 hckLine、hckBar、hckArea、hckPie 與 hckScatter),用 AddPoint 餵資料,再帶著目標矩形呼叫 THPDFPage.DrawChart。渲染器自己搞定軸範圍、格線、刻度標籤、圖例與主題色;接受 structure parent 的那個多載還會把圖表寫成帶標籤的 figure,替代文字由資料生成——文件要過無障礙檢查時這很要緊
var
Chart: THPDFChart;
Sales: THPDFChartSeries;
begin
Chart := THPDFChart.Create;
try
Chart.Title := 'Quarterly sales';
Chart.LegendPosition := hclBottom;
Sales := Chart.AddSeries(hckBar, 'Sales');
Sales.AddPoint(1, 420, 'Q1');
Sales.AddPoint(2, 515, 'Q2');
Sales.AddPoint(3, 388, 'Q3');
Sales.AddPoint(4, 610, 'Q4');
// 頁面單位的矩形:左、上、寬、高
HotPDF1.CurrentPage.DrawChart(Chart, 60, 120, 470, 280);
finally
Chart.Free;
end;
end;
不過渲染器的詞彙是固定的。多個長條系列在每個類別裡並排,圓餅圖必須是它那張圖表的唯一系列,外觀則來自三種主題加每系列顏色。堆疊長條、畫在長條後方的目標帶、單一系列裡每根長條不同的品牌色、從十二點鐘方向順時針出發的圓餅——設計一旦提出任何一項,用頁面畫布自己畫,比拗彎渲染器省事。那些基本元素是 Rectangle、MoveTo、LineTo、Pie、Fill、Stroke 與 TextOut,也就是畫布繪圖逐步解說涵蓋的同一批呼叫
為什麼我的長條圖會上下顛倒?
HotPDF 頁面座標把原點放在左上角、Y 向下增長,與 VCL 畫布同一個框架。PDF 使用者空間本身反著跑(原點左下、Y 向上,ISO 32000-1 §8.3.2.3),HotPDF 寫內容資料流時會把每個座標翻進那個空間。另一方面,圖表的數值是向上增長的。把縮放後的數值直接當 Y 餵進去,最高的長條就會像一根冰柱從繪圖區頂端垂下來
修正只用一條運算式。選一個繪圖矩形,把零映射到它的下邊緣、刻度頂端映射到上邊緣,每個數值在碰到頁面之前都先過這道映射。對刻度 0 到 MaxValue 上的值 V,頁面 Y 是 Plot.Bottom - (V / Plot.MaxValue) * Plot.Height,其中 Plot.Bottom 是基準線。下面的長條圖程式把它包進一個小小的 ValueToY 輔助函式,其他常式全部重用它
這條運算式裡藏著兩個決定。MaxValue 應該取最大資料點之上的一個整數——10 或 100 的下一個倍數——讓最高的長條不頂到上邊緣,格線標籤讀起來是 0、200、400 而不是 0、203.5、407。刻度則刻意從零開始。長條長度就是數值本身,一條從 350 起跳的數值軸會讓 388 的長條看起來只比 420 的長條高出一丁點,而實際差距不到 8%。內建渲染器正是為此把零拉進長條與面積系列的範圍。手繪程式碼裡請守同一條規則
單位是映射的另一半。Resolution 維持預設 72 時,一個頁面單位就是一個 PDF 點(1/72 英吋)。改了解析度,座標、線寬與文字量測就全部切換到新單位,Resolution 與繪圖單位文章講得很細。DrawChart 的目標矩形吃頁面單位,但它自己的邊距、字型大小與線寬在任何解析度下都以點規劃
長條圖就是一個對矩形的迴圈
映射到位之後,長條圖就是一個迴圈。把繪圖寬度切成每類別一個槽位,留出間隙讓相鄰長條不互貼,再把每根長條畫成從 ValueToY 頂端往下到基準線的填色矩形。Rectangle 收左上角、寬與高,正好直接配合這套映射:
type
TPlotArea = record
Left, Bottom, Width, Height: Single; // 頁面單位;Bottom 是基準線的 Y
MaxValue: Single; // 數值刻度的頂端
end;
// 你自己的輔助函式:值越大位置越高,在 Y 向下的頁面上
// 就代表 Y 越小
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
Result := Plot.Bottom - (V / Plot.MaxValue) * Plot.Height;
end;
procedure DrawBarChart(Page: THPDFPage; const Plot: TPlotArea;
const Values: array of Single; const Labels: array of string;
const BarColors: array of TColor);
var
I, Count: Integer;
SlotW, BarW, BarX, BarTop, Gap, GridV, GridY: Single;
begin
Count := Length(Values);
SlotW := Plot.Width / Count;
Gap := SlotW * 0.25; // 槽位的四分之一,左右分攤
BarW := SlotW - Gap;
// 先畫虛線格線,讓長條蓋在它們上面
Page.SetLineWidth(0.5);
Page.SetRGBStrokeColor(RGB(200, 200, 200));
Page.SetDash([2, 2], 0);
GridV := Plot.MaxValue / 4;
while GridV <= Plot.MaxValue do
begin
GridY := ValueToY(Plot, GridV);
Page.MoveTo(Plot.Left, GridY);
Page.LineTo(Plot.Left + Plot.Width, GridY);
Page.Stroke;
GridV := GridV + Plot.MaxValue / 4;
end;
Page.NoDash;
Page.SetFont('Arial', [], 9);
for I := 0 to Count - 1 do
begin
BarX := Plot.Left + I * SlotW + Gap / 2;
BarTop := ValueToY(Plot, Values[I]);
Page.SetRGBFillColor(BarColors[I mod Length(BarColors)]);
Page.Rectangle(BarX, BarTop, BarW, Plot.Bottom - BarTop);
Page.Fill;
// TextOut 把文字上緣放在 Y
Page.SetRGBFillColor(clBlack);
Page.TextOut(BarX, Plot.Bottom + 4, 0, Labels[I]);
Page.TextOut(BarX, BarTop - 12, 0, FormatFloat('0', Values[I]));
end;
// 基準線最後畫,讓它壓在長條底部之上
Page.SetLineWidth(1.0);
Page.SetRGBStrokeColor(clBlack);
Page.MoveTo(Plot.Left, Plot.Bottom);
Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
Page.Stroke;
end;
順序就是這裡全部的分層模型。PDF 用畫家模型:內容資料流裡越晚的運算子畫在越早的之上,所以格線先畫、長條蓋上、基準線最後。把格線區塊搬到迴圈下面,虛線就會橫穿每根長條
間隙取槽位的一個比例,而不是固定點數。470 點的繪圖區放四個類別,每槽 117.5 點寬,長條之間約有 29 點的空氣;放到四十個類別,同一段程式留下不到 3 點,看起來仍然是分開的長條。固定 12 點的間隙在四十個類別時會吞掉 470 點裡的 480 點,長條寬度直接變負數
不打架的軸標籤
標籤是手繪圖表第一個破功的地方,而且破法千篇一律。螢幕上放得下的文字在頁面上超出版面,因為開發者用「Sep」測試,德文報表印出來的卻是「September」或「Dezember」,還是 9 點字。數值軸有同一問題的安靜版本:固定 X 偏移的左對齊刻度標籤,數字長度一變就參差不齊,「1,200」會撞上「800」清得開的軸線
用量的,不要用猜的。TextWidth 回傳字串在當前字型下、以頁面單位計的寬度,TextHeight 回傳當前大小的字型 ascent。把每個刻度標籤對軸右對齊、對刻度置中:
procedure DrawValueAxis(Page: THPDFPage; const Plot: TPlotArea;
TickCount: Integer);
var
I: Integer;
TickV, TickY: Single;
S: string;
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); // 軸外側的短刻度
Page.LineTo(Plot.Left, TickY);
Page.Stroke;
S := FormatFloat('#,##0', TickV);
Page.TextOut(Plot.Left - 7 - Page.TextWidth(AnsiString(S)),
TickY - Page.TextHeight(AnsiString(S)) / 2, 0, S);
end;
end;
對類別標籤,同一個量測會告訴你什麼時候該放棄水平文字。畫任何東西之前,先拿最寬的標籤與槽位寬度比一比。放不下,把字型降一兩點;還放不下,給 TextOut 的角度參數傳 90 讓標籤立起來,然後在基準線下方為最長的那個留足邊距。測試時用報表真的可能產出的最寬標籤——本地化版本裡的月份名稱,很少恰好是英文那個
折線圖重用同一套映射
折線圖只改變點的連接方式。把資料走一遍,每個值用 ValueToY 轉換,再把點縫成一條路徑:第一點 MoveTo,之後每點 LineTo,最後一次 Stroke。如果點已經在陣列裡,Polyline 吃一個 array of THPDFCurrPoint,開一條新路徑,描邊留給你;少於兩點會丟例外,所以只有單一讀數的系列該用標記點而不是線
折線與畫在同一繪圖區的長條圖,分歧點在間距。長條按類別數切寬度,因為每根長條擁有一個槽位。折線按區間數 Count - 1 切,因為首尾兩點坐在繪圖區邊緣。兩者疊起來,折線的點會落在槽位邊界而不是長條中心。要讓折線騎在長條上,就把點放在每個槽位中心 Plot.Left + I * SlotW + SlotW / 2,徹底放棄 Count - 1 間距。要在每個點放標記,線描完之後補一個小 Circle 加 Fill 就夠了
用 Pie 畫扇形,從十二點鐘順時針
一塊扇形是兩個半徑加一條弧。手搓的建法從圓心出發,沿著圓周每隔兩三度走一小段 LineTo,再閉合回圓心——下圖畫的就是這個結構。它行得通,但一個 270 度的扇形會變成一百多段路徑,而且在重度縮放下圓周仍是個多邊形
改用 Pie。THPDFPage.Pie 跟隨 GDI 慣例:一個包圍矩形、以度為單位的起始角與結束角,0 度朝東、90 度朝北、正角逆時針。它用每段至多 90 度的貝茲段建弧,畫線到圓心,閉合路徑,所以圓周保持真曲線,一整塊扇形只花幾個運算子。商業圓餅通常從十二點鐘順時針走,用 GDI 的話說就是從 90 度開始把角度往下走:
procedure DrawPieChart(Page: THPDFPage; CX, CY, Radius: Single;
const Values: array of Double; const Colors: array of TColor);
var
I: Integer;
Total, Sweep, Angle: Double;
begin
Total := 0;
for I := 0 to High(Values) do
if Values[I] > 0 then
Total := Total + Values[I];
if Total <= 0 then
Exit;
Angle := 90; // 十二點鐘方向
Page.SetRGBStrokeColor(clWhite);
Page.SetLineWidth(0.8);
for I := 0 to High(Values) do
begin
if Values[I] <= 0 then
Continue; // 見後文:絕不傳入零或負的掃掠角
Sweep := Values[I] / Total * 360;
Page.SetRGBFillColor(Colors[I mod Length(Colors)]);
// 從 Angle - Sweep 逆時針到 Angle,就是位於目前位置
// 順時針那一側的扇形
Page.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
Angle - Sweep, Angle);
Page.FillAndStroke; // 白色描邊用來分隔扇形
Angle := Angle - Sweep;
end;
end;
Continue 那道防護是全段最要緊的一行。Pie 把小於起始角的結束角當成掃過 360 度再繞回來的掃掠——GDI 的讀法——這正是扇形能跨過 0 度線的原因。來源資料裡一筆退款或更正會給出負值,算出的結束角落到起始角之下,扇形就繞成近乎整個圓盤、蓋在之前畫的一切之上。零值溫和一點但仍看得到:閉合路徑塌成一條半徑,白色分隔描邊會把它畫成一條從圓周到圓心的游離線。兩種都在迴圈前濾掉,另外單獨決定報表怎麼呈現負數,因為圓餅根本畫不出負數
設計不堅持圓形時,一根水平堆疊長條只靠首尾相接的 Rectangle 就能呈現同樣的份額,而且讀者比較長度比比較角度準。超過五六個類別的組成圖,這是更好的選擇,上面的長條圖程式碼已經做了大半工作
本文的每一張圖表都用 HotPDF Delphi Component 的頁面畫布呼叫建成,標準的折線、長條、面積、圓餅與散佈版面則有 DrawChart 可用;這一切在 Delphi 或 C++Builder 裡執行,建置機器上不需要安裝任何圖表庫