一段從 PDF content stream 範例抄來的頁尾橫線,寫成 72 72 m 540 72 l S:移到 (72, 72),畫線到 (540, 72),然後描邊。在原始 PDF 裡,這條線位於 US Letter 頁面底緣上方一英吋處。把同樣四個數字餵給從左上角起量的繪圖 API,這條線就落在頂端下方一英吋處,正好穿過信頭。線本身沒有問題,變的只是參考框架;在 792 點高的頁面上,兩個框架之間的換算只是一次減法:792 減 Y
HotPDF 頁面上的 Y = 0 在哪裡?
HotPDF 的頁面繪圖呼叫把原點放在頁面左上角,X 向右遞增、Y 向下遞增,與 VCL TCanvas 使用同一套框架。定義於 ISO 32000-1 §8.3.2.3 的 PDF user space 則從左下角起算、Y 向上遞增,HotPDF 會在寫入 content stream 時轉換您傳入的每一個 Y。因此在 THotPDF.CurrentPage 上,Y := 72 就是頂端邊緣下方一英吋處,與頁面大小無關
頁面大小則決定另一端的位置。在 612 乘 792 點的 US Letter 頁面上,Y := 720 是底端上方一英吋處。新的 HotPDF 頁面預設是 A4,而 A4 高 842 點,同樣的 Y := 720 下方就剩下 122 點,約 1.7 英吋。用「792 減 Y」定位頁尾的做法,在 A4 上正是因為這個原因而向上漂移。高度請從 CurrentPage.Height 讀取,不要寫死在程式裡;或者在 BeginDoc 之後立即明確設定 CurrentPage.Size
單位是點(point),即 1/72 英吋,因為 THotPDF.Resolution 預設為 72。調高 Resolution 後,座標與尺寸就改以 1/Resolution 英吋為單位。做向量繪圖時請維持 72:線寬、虛線長度與座標便都與 PDF 檔案儲存的單位一致,content stream 裡看到的數值就是原始碼裡的數值
文字遵循同一套模型。預設情況下,您傳給 TextOut 的 Y 是文字的上緣,因此標籤與包住它的方框可以用同一個網格規劃。少數幾個呼叫作用在 PDF 檔案而非繪圖表面上,採用由下往上的 PDF 座標:SetCropBox(Left, Bottom, Right, Top) 這類 page box 設定器,以及為已載入文件蓋章的 StampLoadedPageText / StampLoadedPageImage 系列。大多數上下顛倒的版面都出自把它們與路徑呼叫混用,所以請把它們放在獨立的常式裡
建立路徑,然後繪製它
路徑先建立、後繪製。MoveTo 設定起點而不留下任何標記,每個 LineTo 追加一條直線段,在 Stroke 這類繪製呼叫以目前的描邊顏色與線寬動筆之前,頁面上不會出現任何東西。用 PDF 的話說,Stroke 發出 S 運算子,而每個繪製運算子都會結束目前路徑,所以下一個 MoveTo 從乾淨的狀態開始
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'DrawPaths.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.Size := Letter; // 612 x 792 pt;預設為 A4
// 線寬與顏色是頁面狀態:設定一次,之後每個 Stroke 都沿用
Pdf.CurrentPage.SetLineWidth(1.5);
Pdf.CurrentPage.SetRGBStrokeColor(clBlack);
// 頂端下方一英吋的一條橫線,兩側各留 72 pt 邊界
Pdf.CurrentPage.MoveTo(72, 72);
Pdf.CurrentPage.LineTo(540, 72);
Pdf.CurrentPage.Stroke;
// 開頭那條頁尾橫線:底端上方一英吋處
Pdf.CurrentPage.MoveTo(72, Pdf.CurrentPage.Height - 72);
Pdf.CurrentPage.LineTo(540, Pdf.CurrentPage.Height - 72);
Pdf.CurrentPage.Stroke;
// 一條路徑內三段相連的線段,更粗、且是藍色
Pdf.CurrentPage.SetLineWidth(3);
Pdf.CurrentPage.SetRGBStrokeColor(RGB(30, 90, 200));
Pdf.CurrentPage.MoveTo(72, 152);
Pdf.CurrentPage.LineTo(172, 102);
Pdf.CurrentPage.LineTo(272, 172);
Pdf.CurrentPage.LineTo(372, 112);
Pdf.CurrentPage.Stroke;
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
線寬是狀態,不是引數。SetLineWidth 寫入一個 w 運算子,之後頁面上每次描邊都沿用這個寬度,直到您再次變更;折線之所以是 3 點、兩條橫線之所以維持 1.5,正是這個原因。從不設定的話,套用 PDF 預設值 1.0(ISO 32000-1 §8.4.3.2)。圖形消失的原因則平實得多:鋪了幾何卻沒有後續的繪製呼叫,就永遠不會渲染出來
座標與線寬都是 Single,所以 72.25 這種位置和 0.5 點的極細線都能原樣進到檔案裡。零是特例。規格把寬度 0 定義為輸出裝置能畫出的最細線:螢幕上任何縮放比例下是一個像素,600 dpi 印表機上是 1/600 英吋,同一條線在檢視器裡看起來偏粗、在紙上卻幾乎消失。極細線請給真正的寬度,例如 0.25 或 0.5,線條在每種裝置上都能保住自己的粗細
為什麼閉合的三角形會有一個角缺一塊?
用三個 LineTo 畫回起點的三角形看起來閉合了,但在 PDF 裡它仍是開放的子路徑。ISO 32000-1 §8.5.3.2 只在線段於子路徑內部相接處套用轉角接合;兩個自由端套用的則是線端帽。預設的平帽(butt cap)配上 6 點的筆寬,起始角會被咬掉一個方口,另外兩個角卻是乾淨的斜切接角。ClosePath(即 h 運算子)會補上最後一段,並告訴渲染器把它與第一段接合起來
Pdf.CurrentPage.SetLineWidth(6);
Pdf.CurrentPage.SetRGBStrokeColor(clBlack);
// 看似閉合,其實沒有:平帽在 (72, 500) 相接,留下缺口
Pdf.CurrentPage.MoveTo(72, 500);
Pdf.CurrentPage.LineTo(132, 420);
Pdf.CurrentPage.LineTo(192, 500);
Pdf.CurrentPage.LineTo(72, 500);
Pdf.CurrentPage.Stroke;
// 閉合子路徑:三個角都得到同樣的 miter 接合
Pdf.CurrentPage.MoveTo(232, 500);
Pdf.CurrentPage.LineTo(292, 420);
Pdf.CurrentPage.LineTo(352, 500);
Pdf.CurrentPage.ClosePath;
Pdf.CurrentPage.Stroke; // 或用 ClosePathStroke 一次完成
// 填滿不需要 ClosePath:填滿會隱含閉合每個子路徑
Pdf.CurrentPage.SetRGBFillColor(RGB(220, 60, 60));
Pdf.CurrentPage.MoveTo(392, 500);
Pdf.CurrentPage.LineTo(452, 420);
Pdf.CurrentPage.LineTo(512, 500);
Pdf.CurrentPage.Fill;
凡是要描邊的多邊形,都用 ClosePath 或 ClosePathStroke 閉合,即使最後一點已經與第一點重合也一樣。1 點寬時缺口在螢幕上看不見;6 點寬時在正常縮放下就會現形,印在圖表圖例裡就像渲染瑕疵。填滿的圖形沒有這個問題;路徑自我交錯、想改用偶奇規則而非非零環繞時,則可用 EoFill
矩形、圓形與 TCanvas 的肌肉記憶
閉合的基本圖形省掉 MoveTo 的簿記工作。Rectangle(X, Y, Width, Height) 接收左上角與尺寸,Circle(X, Y, Radius) 接收圓心與半徑,兩者都用 Fill、Stroke 或 FillAndStroke 繪製。填色與描邊顏色各自獨立:SetRGBFillColor 與 SetRGBStrokeColor 都收一個 TColor,Delphi 色彩常數和 RGB(...) 可以直接用,SetRGBColor 則一次設定兩者
Rectangle 的引數形狀是陷阱所在。TCanvas.Rectangle 收 (Left, Top, Right, Bottom),多年 VCL 繪圖程式碼把您的手指訓練成習慣性傳入第二個角落。把 Rectangle(72, 202, 232, 292) 交給 HotPDF,得到的是寬 232 點、高 292 點的方框,而不是 160 乘 90。手上的數字真的是兩個角落時,改呼叫 MFRectangle(X, Y, X1, Y1),它接收角落座標並替您算好尺寸;Rectangle 留給以尺寸思考的程式碼
PDF 檔案裡沒有圓形運算子。Circle 以慣用的 0.5523 控制點係數寫出四段三次貝茲曲線,把頁面 content stream 解壓縮就能看到:一個 m 後面接四個 c 運算子。Ellipse(X, Y, Width, Height) 做的是同樣的事,只是包在左上角為 (X, Y) 的邊界框裡
HotPDF 也提供 THotPDF.Canvas,即目前頁面的標準 TCanvas;Graphic 範例集中的 CanvasDraw 程式就是用尋常的 Canvas.Rectangle、Ellipse 和 RoundRect 呼叫填滿一個 Ledger 頁面。移植既有的 VCL 繪圖程式碼時,這條路合理。新寫的繪圖程式碼建議走 CurrentPage:每個呼叫對應一個以點為單位的 PDF 運算子,輸出精確到數字本身
四次提交拼出一個標註方塊
標註一次用上前面所有東西:填色加描邊的面板、一條指標線、一個錨點圓點,以及用同樣的左上角座標放進面板裡的兩行文字。FillAndStroke(即 B 運算子)用一次呼叫同時畫出單一路徑的內部與邊框,矩形只需建立一次而非兩次
procedure DrawCallout(Page: THPDFPage);
begin
// 1. 面板:淡色填滿加較深的邊框,一條路徑,一次提交
Page.SetRGBFillColor(RGB(255, 244, 214));
Page.SetRGBStrokeColor(RGB(180, 130, 40));
Page.SetLineWidth(1);
Page.Rectangle(90, 122, 240, 70); // 左上角 (90, 122),240 x 70
Page.FillAndStroke;
// 2. 指標:從面板左緣往下指向目標
Page.SetLineWidth(1.5);
Page.MoveTo(90, 177);
Page.LineTo(66, 246);
Page.Stroke;
// 3. 指標末端的錨點圓點,用邊框色
Page.SetRGBFillColor(RGB(180, 130, 40));
Page.Circle(64, 250, 3);
Page.Fill;
// 4. 標籤:每個 Y 都是文字上緣,量在同一個網格上
Page.SetFont('Arial', [fsBold], 10);
Page.TextOut(102, 134, 0, 'Check this total');
Page.SetFont('Arial', [], 9);
Page.TextOut(102, 152, 0, 'Rounded to two decimals per line,');
Page.TextOut(102, 164, 0, 'then summed');
end;
// 在 BeginDoc 與 EndDoc 之間呼叫:
// DrawCallout(Pdf.CurrentPage);
每個區塊都在需要這些設定的圖形之前,就地設定自己的填色、描邊色與線寬,即使前一個區塊留下的就是同一個值也一樣。代價是檔案裡多幾個多餘的運算子。換來的是:任何區塊都能搬移、刪除或抽成獨立的 procedure,而不會從前面執行的程式碼繼承一道莫名的紅色填滿。對於隨報表需求不斷改動的繪圖程式碼,這筆交易每次都值得
顏色也值得同樣的紀律。用 Random($FFFFFF) 給填色下種子,示範頁面很熱鬧,產生的報告卻無法在兩次執行之間互相比較,因為相同資料建置出的兩份檔案位元組不同。像 CanvasDraw 範例那樣,用八個條目的 CanvasColors 表,從資料或固定色盤陣列驅動顏色,相同的輸入就會產出相同的檔案
虛線、曲線與透明度
SetDash 和線寬一樣是頁面狀態。SetDash([3, 3], 0) 讓之後每次描邊都畫 3 點、停 3 點,第二個引數決定圖案從哪裡起頭,NoDash 則恢復實線筆。虛線陣列是 array of Byte,長度是 1 到 255 的整數點,並按原樣寫入 content stream。為某組網格線設定的虛線,之後每一條線都會沿用,所以請把虛線段落包在 GStateSave 與 GStateRestore 之間:它們發出 q 與 Q,恢復儲存的狀態就一步帶回先前的虛線、線寬與顏色
曲線延伸的是同一條路徑。CurveToC(X1, Y1, X2, Y2, X3, Y3) 從目前點往 (X3, Y3) 追加一段帶兩個控制點的三次貝茲曲線;CurveToV 與 CurveToY 則涵蓋第一或第二個控制點與端點重合的情形。單一路徑可以在一次 Stroke 或 Fill 之前混合 LineTo 與 CurveToC 線段,圓角與平滑化的圖表線條就是這樣畫的
透明度存放在具名的圖形狀態裡,因為 PDF 把 alpha 存在 ExtGState 字典中,而不是存在顏色裡。THotPDF.RegisterExtGState(FillAlpha, StrokeAlpha) 接收 0 到 1 的數值並傳回一個名稱;CurrentPage.SetGraphicsState(Name) 把它套用到之後繪製的一切。每種不透明度在每份文件註冊一次並重用名稱,檔案裡每種不透明度就只帶一個字典,無論多少圖形使用它。Alpha 需要 PDF 1.4,HotPDF 會在需要時把輸出版本提高到 1.4;設定 StrictVersionLock 時則不動版本並傳回空名稱。當 PDFACompliance 選了 PDF/A-1 設定檔,低於 1 的 alpha 會引發例外,因為 ISO 19005-1 §6.4 禁止透明度;PDF/A-2 與 PDF/A-3 設定檔則接受
var
Half: AnsiString;
begin
// 每份文件註冊一次,傳回的名稱在任何頁面都能重用
Half := Pdf.RegisterExtGState(0.5, 1.0); // 填滿 50%,描邊不透明
Pdf.CurrentPage.GStateSave; // q
Pdf.CurrentPage.SetDash([3, 3], 0);
Pdf.CurrentPage.SetLineWidth(0.5);
Pdf.CurrentPage.SetRGBStrokeColor(clGray);
Pdf.CurrentPage.MoveTo(72, 400);
Pdf.CurrentPage.LineTo(540, 400);
Pdf.CurrentPage.Stroke;
Pdf.CurrentPage.SetGraphicsState(Half);
Pdf.CurrentPage.SetRGBFillColor(clYellow);
Pdf.CurrentPage.Rectangle(72, 380, 200, 16); // 蓋在文字列上的螢光標記
Pdf.CurrentPage.Fill;
Pdf.CurrentPage.GStateRestore; // Q:實線筆,恢復完全不透明
end;
向量呼叫適合由程式計算出來的幾何:表格格線、圖表軸與長條、標註、由十幾條路徑組成的商標。照片或螢幕截圖屬於 AddImage 與 ShowImage;用路徑描摹像素只會把檔案撐大。檢查輸出時,請在粗描邊的轉角與任何虛線上放大到 1600%:未閉合路徑留下的缺口、以及洩出到自己段落之外的虛線,在這個倍率下都很醒目,在 100% 時卻容易錯過
本文的每個呼叫都位於 HotPDF Delphi Component 的頁面物件上,它可用於 Delphi 與 C++Builder VCL 專案,並把結果寫成純粹的 PDF 路徑運算子