技術文章

用 HotPDF 做 Delphi PDF 報表:TextOut、字型、影像

產生一份報表,歸結起來就是把三樣東西放到頁面上,並讓它們對彼此的位置達成共識:位在已知座標上的文字、在伺服器上與在您桌機上算繪得一模一樣的字型,以及調整到剛好合身的影像。報表函式庫所做的其餘一切,都是圍繞著這三樣東西安排的。HotPDF 是 losLab 給 Delphi 與 C++Builder 的 PDF 產生函式庫,它把這三樣各自做成頁面物件上的一個直接呼叫,而唯一真正的摩擦來自底下那套座標系統,它跑的方向跟您習慣的 VCL 畫布相反。先把那個方向擺平,其餘的版面工作就不再跟您作對

文字擺放與左下角原點

幾乎每個人的第一份報表都是上下顛倒的。標題落在靠近底緣的地方,而它下面的每一行都往頂端爬。什麼東西都沒故障。ISO 32000-1 §8.3 所定義的 PDF 使用者空間,把原點放在左下角、Y 往上增長,那是 GDI 畫布的鏡像,後者的 Y 從左上角往下增長。花五分鐘跟這件事和解,就省下一份您本來會在數字開始說不通之後重寫的版面

HotPDF 圖解對比 VCL 的左上角座標原點與 PDF 的左下角原點,其中 TextOut 把一個標題放在 Letter 頁面頂端下方 50 點處,也就是 Y 為 792 減 50
PDF 使用者空間是 VCL 畫布的鏡像,所以距 Letter 頁面頂端 50pt 的標題寫成 TextOut(50, 792 - 50, 0, 'INVOICE'),同一套換算讓每個報表座標都保持直覺

頁面物件的核心呼叫是 TextOut(X, Y, Angle, Text)。X 與 Y 以點為單位、從左下角定位文字,而 Angle 以度數旋轉它,一個斜的 DRAFT 或 COPY 印記就是這樣畫出來的,不需要任何特別支援。讓 VCL 訓練出來的直覺繼續管用的訣竅,是把 Y 表示成頁面高度減去您想距頂端的那段距離:

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice-0001.pdf';
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [fsBold], 16);
    Pdf.CurrentPage.TextOut(50, 792 - 50, 0, 'INVOICE');       // 距 Letter 頂端 50pt
    Pdf.CurrentPage.SetFont('Arial', [], 10);
    Pdf.CurrentPage.TextOut(50, 792 - 70, 0, 'Date: 2026-06-11');
    Pdf.CurrentPage.TextOut(300, 400, 45, 'COPY');              // 旋轉的印記
    Pdf.AddPage;                                                // CurrentPage 現在指到這裡
    Pdf.CurrentPage.SetFont('Arial', [], 10);                   // 字型狀態不會延續過來
    Pdf.CurrentPage.TextOut(50, 742, 0, 'Page 2 detail rows');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

那段列表裡的兩項具狀態行為,要為大多數只在第二頁才現形的臭蟲負責。AddPage 會把 CurrentPage 重新指向它剛建立的那一頁,所以您先前快取起來的頁面參照不再畫在您預期的地方。字型選擇也是逐頁而非逐文件的。如果您在 AddPage 之後略過 SetFont,新頁面上的第一次 TextOut 會退回該頁起始時的任何預設值,而不是您三頁之前設定的那個粗體標題字型。安全的習慣,是把「開新頁」與「重新建立文字狀態」當成報表迴圈裡不可分割的同一個步驟

存在於伺服器上、而不只存在於您桌機上的字型

多數字型問題其實是披了偽裝的部署問題。您的開發機裝了企業字型,所以報表在您螢幕上看起來正確,然後就出貨了。生產主機以一個從來沒裝過那套字型的服務帳戶跑這項工作,算繪器悄悄替換成一套它找得到的字型,而任何人第一次聽說這件事,是客戶問為什麼信頭變了。出路是不再信任作業系統的字型目錄,改從您的安裝程式放到磁碟上的檔案載入字型。HotPDF 的 Unicode 註冊呼叫收一個路徑,做的正是這件事:

Delphi PDF 字型部署問題圖解:生產伺服器悄悄替換掉缺少的字型,而 RegisterUnicodeTTF 從已部署的檔案載入 TTF 並把它嵌進 PDF
倚賴作業系統字型目錄,在生產服務帳戶缺少該字型時就會壞掉,而從已部署的檔案載入 TTF 會把字形嵌進去,於是每一台主機都算繪得一模一樣
Pdf.RegisterUnicodeTTF('C:\ProgramData\MyApp\Fonts\NotoSans.ttf');
Pdf.CurrentPage.SetFont('NotoSans', [], 12);
Pdf.CurrentPage.TextOut(50, 700, 0, WideString('Łódź - Ünïcode test ✓'));

TextOut 直接接受 WideString,而這件事比乍看之下更要緊。一個帶重音的客戶姓名、一條德國街道、一座波蘭城市:這些不是邊緣案例,它們是客戶資料表的正常內容,而且只要註冊的字型真的含有那些字形,它們就跟您硬寫的 ASCII 標籤走同一個呼叫。內嵌字型還連帶一項版本限制:文件必須是 PDF 1.5 或更新,所以如果某項無關的需求把您釘在較舊的版本上,那就是會悄悄壞掉的東西。阿拉伯文與希伯來文之類的由右至左文字系統,需要的是真正的塑形而不是直接查字形,那有它自己的管線;請見 我們談 HotPDF 複雜文字系統塑形的文章

當沒有任何已安裝的字型表達得出您需要的東西時,想想支票上的 MICR 字元或某套專有符號集,Type 3 字型就來補這個缺口。您透過 RegisterType3FontAddType3Glyph 把每個字形定義成一小段內容串流。這是 API 裡一個專門的角落,您很少會伸手去拿它,但它比在一頁上撒下幾百張小小的符號點陣圖乾淨得多

影像:中間那兩個引數是寬與高,不是一個角落

影像處理分成兩個步驟,而把它們分開正是重點所在。AddImage 收一個 TBitmapTJPEGImage,把它嵌入一次,並交回一個索引。PNG 圖稿在抵達那裡之前必須先解碼成點陣圖。接著 ShowImage 就把那個索引畫在您想要的任何地方、想要幾次都行。ShowImage 的引數順序,是唯一值得放慢腳步讀一讀的地方:

HotPDF 影像管線圖解:AddImage 把點陣圖嵌入一次並傳回索引,ShowImage 依寬與高擺放它,而引數順序不是一對角落座標
AddImage 把像素嵌入一次,而每一次 ShowImage 呼叫都重複使用那個索引,中間那兩個引數是寬與高,不是對角那個角落的座標
var
  Png: TPngImage;
  Logo: TBitmap;
  LogoIdx: Integer;
begin
  Png := TPngImage.Create;
  Logo := TBitmap.Create;
  try
    Png.LoadFromFile('brand-logo.png');
    Logo.Assign(Png);                       // 把 PNG 解碼成點陣圖
    LogoIdx := Pdf.AddImage(Logo, icFlate); // 平色圖稿用無損
  finally
    Logo.Free;
    Png.Free;
  end;
  // (Index, X, Y, Width, Height, Angle):不是 (X1, Y1, X2, Y2)
  Pdf.CurrentPage.ShowImage(LogoIdx, 50, 700, 120, 40, 0);
end;

位置後面那兩個數字是寬與高。它們不是對角那個角落的座標,而最後那個引數是以度數計的旋轉角度。把這個簽章讀成一個 X1/Y1/X2/Y2 的方框,一張放在 (50, 700) 的 120 乘 40 標誌,就會從那裡一路撐到 (120, 40),攤滿大半個頁面。輸出讓錯誤一目了然,而原始碼看起來完全合情合理,這就是它會浪費掉一個下午的原因。KeepImageAspectRatio 預設為 True,所以一個比例不對的方框會替影像加上留白,而不是把它扭曲;只有在您真的想拉伸時才把它翻成 False

註冊與擺放分家,在長批次上獲得回報。因為 AddImage 把像素嵌入一次,而每一次帶著那個索引的 ShowImage 都回指同一個內嵌物件,所以您在哪裡呼叫 AddImage 就決定了檔案大小。在一份 500 頁對帳單的頁面迴圈裡呼叫它,同一個標誌就會被嵌入 500 次。在迴圈之前呼叫一次、留著那個索引,標誌就只儲存一次。一個以資產路徑為鍵的小字典,就足以確保每一張不同的影像都恰好只註冊一次

編解碼器的選擇是另一根尺寸槓桿。照片式內容,掃描附件之類的,屬於 JPEG:把 icJpeg 傳給 AddImage,並把 JpegQuality 降到 85 左右,因為這個屬性起始於 100,而在 85 的差別在印出來的頁面上看不出來。標誌、圖表與線條圖之類的平色圖稿屬於 icFlate,那裡無損壓縮本來就很精簡,而 JPEG 會在硬邊緣周圍抹出看得見的振鈴。一批在每一頁上都塞一張全品質照片的對帳單,可能膨脹到好幾 GB;同樣的內容以 JPEG 85 大約落在十分之一的大小,而沒有任何讀者分辨得出來

用路徑基本元素畫線條、方框與底色

表格標頭底下那條水平線,以及總計數字背後那個灰色方框,都不必是影像。把它們畫成向量,它們在任何縮放下都保持銳利、印出來也俐落,而且幾乎不為檔案增加什麼。HotPDF 遵循原始 PDF 內容串流所用的同一套模型:先建出一條路徑,再呼叫一個把它畫出來的運算子

// 表格標頭底下的水平線
Pdf.CurrentPage.SetLineWidth(0.75);
Pdf.CurrentPage.MoveTo(50, 660);
Pdf.CurrentPage.LineTo(545, 660);
Pdf.CurrentPage.Stroke;

// 有底色的總計方框:X、Y、寬、高
Pdf.CurrentPage.SetRGBFillColor(RGB(235, 235, 235));
Pdf.CurrentPage.Rectangle(395, 120, 150, 40);
Pdf.CurrentPage.Fill;

順序不是可選的:先設定繪製狀態,再構造路徑,然後呼叫 StrokeFill。一條您建了卻從未畫出來的路徑,對頁面毫無貢獻,而當一條線「沒有顯示出來」時,那幾乎總是答案。SetRGBFillColor 收一個 TColor,所以 clNavyclBlack 這類熟悉的 VCL 常數可以直接放進去,而 Rectangle 跟影像擺放一樣用寬與高的引數,不是兩個角落。關於細線有一項提醒:任何低於大約半點的線在螢幕上看起來很雅致,然後在一台 600 dpi 的辦公室印表機上消失無蹤,所以對任何必須撐過列印的線條,0.75pt 是個合理的下限

拿真實資料而不是樣本資料做分頁

有一個細節要在版面定案之前弄對:數值欄位應該靠右邊緣對齊,而做法是量出每個值算繪後的寬度,再從欄位邊界往回定位它,不是用前導空白把字串補齊。空白補齊只有在等寬字型裡才對得整齊,而沒有人拿等寬字型排財務報表。請先把那些值送過 Delphi 具地區感知的常式,例如 FormatFloat,這樣您量寬度的那個千位分隔符號,才會跟客戶的地區設定實際顯示的那個相同

分頁的危險在於,您是照著展示用資料集寫它的,那裡十列短短的資料剛好放得下一頁,迴圈從來不必換頁。生產環境交給您的是一位公司名稱長達 140 個字元的客戶,和一份有 4,000 個明細項目的對帳單,而現在這個迴圈每一次都必須正確換頁。撐得住的模式是一個單一的 Y 游標,隨著您逐列減去它的高度而往下移動,再加上一項檢查:一旦游標會越過底部邊界就開新頁。這裡的「往下」意思是 Y 遞減,那是左下角原點唯一仍然反直覺的地方。把這一切連同在新頁面上重新發出 SetFont 與重畫頁首的動作,全都放在同一個常式裡,差一頁的臭蟲就永遠站不住腳。當同一批報表還必須符合封存或無障礙規則時,您在這裡所做的選擇,也就是您嵌入哪些字型、輸出有沒有加標籤、您用哪些色彩空間,正是那些標準所管的東西;在範本定型之前,HotPDF 的 PDF/A、PDF/X 與 PDF/UA 指南值得一讀

這裡展示的每一個呼叫,文字定位、字型註冊、影像嵌入與路徑繪製,都隨給 Delphi 與 C++Builder 的 HotPDF Delphi Component 一同出貨,其參考文件記載了完整的輸出 API,以及與它並列的表單、加密與簽署功能