技術文章

在 Delphi 中使用 PDFium 元件從頭建立 PDF 文件

PDFium 作為檢視器引擎,即 Chrome 的 PDF 分頁背後的轉譯器而聞名,因此首先需要說明的是,PDFium 元件也可以建置以前從未存在過的文件;創作端封裝了 PDFium 的頁面物件 API:您建立一個空文件,新增具有明確尺寸的頁面,然後在您選擇的座標上將文字、向量路徑和影像放到每個頁面上;沒有需要學習的頁面描述語言,也沒有列印驅動程式參與;您呼叫方法,函式庫組合 PDF物件,最後 SaveAs 會將結果序列化

您所無法獲得的是排版引擎;這點非常重要需要先說明,因為它決定了以下每個範例的實作方式;PDFium 元件會將內容放置在您指示的位置,使用絕對座標,而不會放置在意想不到的角落;它不會將段落換行、不會使文字跨頁面中斷流動,也不會根據列與行計算出表格;這些都是您的工作;如果您期望能像文書處理軟體那樣自動重排散文,請現在調整您的預期:這是一個精確的低階放置 API,更接近在畫布上繪製圖形,而不是對文件進行排版;對於已產生且您已經知道每個元素所屬位置的發票、憑證、標籤和報告頁面,這種精確度正是您所需要的

產生檔案的最低要求

在空的 TPdf 和已儲存的 PDF 之間只需三個呼叫:建立文件、新增頁面、將其寫出;其他所有內容都是您在這之間分層加入的內容

uses
  Vcl.Graphics,   // for clBlack and TColor
  PDFium;         // TPdf lives here

procedure CreateBlankPdf(const FileName: string);
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;                 // empty in-memory document
    Pdf.AddPage(0, 595, 842);           // A4 portrait, in points
    Pdf.AddText('First page', 'Arial', 18, 50, 780);
    Pdf.SaveAs(FileName);               // serialize to disk
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

看過舊程式碼片段的人容易混淆一個細節:在 CreateDocument 之後,您不需要指定 Pdf.Active := TrueActive 屬性會回報文件控制代碼是否存在,而 CreateDocument 已經建立了一個,因此在呼叫傳回時該屬性即為 True;再次設定它充其量是不起作用,最壞的情況是會誤導下一位讀者;Active 在結束時發揮了作用:指定 False 會在 Free 之前釋放底層文件,這是乾淨的終止順序;請將 CreateDocument 與檔案載入的開啟視為互斥;該函式庫拒絕在已經開啟一個文件的 TPdf 上建立新文件,因此重複使用意味著必須先關閉當前文件

座標從左下角開始

AddText 的第二個參數組,以及每個放置呼叫的參數,都是 PDF 使用者空間中的一個點;原點位於頁面的左下角,X 軸向右延伸,Y 軸向延伸;一個單位是一個點,即 1/72 英吋,因此 A4 頁面為 595 x 842 單位,而 US Letter 為 612 x 792;向上延伸的 Y 軸是「我的文字超出頁面」混淆最常見的來源,因為螢幕和點陣圖座標將原點設在頂部,Y 軸向下增長;在一個 842 點高的頁面上,靠近頂部的標題位於 Y 780 左右,而不是 Y 60;當文字落在意想不到的地方時,頁面高度減去您的 Y 軸值幾乎總是您實際想要的數值

AddPage 接受一個插入位置作為其第一個參數,以 1 為基準,並以 0 作為方便的「文件開始」簡寫;為第一頁傳遞 0 或 1,該頁面會插入到最前面;傳遞與您要附加的計數相符的值,以便在末尾新增頁面;新新增的頁面也會成為當前頁面,即後續繪製呼叫所針對的頁面,因此在新增頁面後沒有單獨的「選取此頁面」步驟;如果您新增了多個頁面,且稍後需要重新在較早的頁面上進行繪製,請設定 PageNumber 以移動游標;當您在建立頁面時依序填滿頁面時,可以不用去管它

寫入文字,以及會悄悄產生影響的字型規則

AddText 的簽章包含了單次寫入所需的一切:字串、字型名稱、以點為單位的大小、X 和 Y 錨點,以及選用的顏色、透明度 Alpha 位元組和以度為單位的旋轉角度

procedure WriteHeader(Pdf: TPdf; const Title, Author: string);
begin
  // Title in black, default opacity, no rotation
  Pdf.AddText(Title, 'Arial', 20, 50, 780);
  // A lighter byline 24 points below it
  Pdf.AddText('By ' + Author, 'Arial', 11, 50, 756, clGray);
  // A faint diagonal draft stamp across the page
  Pdf.AddText('DRAFT', 'Arial', 64, 180, 380, clGray, $30, 45.0);
end;

Alpha 位元組從 $00(不可見)到 $FF(不透明),這使得草稿印記呈現浮水印的效果,而不是一個實心的區塊:$30 大約是百分之十九的不透明度,足以便於閱讀;角度會將文字圍繞其錨點逆時針旋轉,因此 45 度會呈現經典的對角線印記;這一切都不需要獨立的浮水印功能;浮水印只是一個大型的、半透明的、旋轉的 AddText呼叫,而在主體之前或之後繪製決定了它是在內容的後方還是在上方

字型非常需要注意,因為它的失敗模式是無聲的;當您傳遞一個字型名稱時,PDFium 元件會向作業系統要求該字型的 TrueType 資料,並將其內嵌在文件中,這就是為什麼在您的電腦上建置的檔案,在從未安裝過該字型的電腦上也能以完全相同的方式轉譯;問題在於當名稱無法解析時會發生什麼事:例如拼寫錯誤,或者建置電腦上根本不存在該字型;這並不會引發任何異常;函式庫會退而求其次地建立一個僅將名稱作為標籤的文字物件,而不內嵌任何內容,並讓檢視器自行替換它認為接近的字型;文字在您的測試中會出現且看起來合理,但在安裝了不同字型的其他地方開啟檔案的瞬間,其度量或字形就會發生偏移;請使用您知道在產生檔案的電腦上存在的字型名稱,將字型清單視為部署相依性,並在乾淨的系統上使用檢視器開啟樣本進行檢查,然後再信任輸出結果

向量圖形:建置路徑,然後提交

線條、矩形和填滿區域都透過路徑進行處理;您可以使用 CreatePath 開啟路徑,這會一次設定起始點和所有樣式、填滿模式、具有各自 Alpha 位元組的填滿和筆劃顏色、筆劃寬度、線條端點和連接方式;然後您可以使用 LineToBezierToClosePath 來延伸它,最後 AddPath 會將完成的路徑提交到頁面上;提交步驟很容易被忘記,如果您跳過它,將不會產生任何內容

procedure DrawDivider(Pdf: TPdf; X, Y, Width: Single);
begin
  // A thin horizontal rule. The rectangle overload sets a box directly:
  // X, Y, Width, Height, then fill mode and colors.
  Pdf.CreatePath(X, Y, Width, 0.5, fmNone, clBlack, $FF,
    True, clBlack, $FF, 1.0);
  Pdf.AddPath;
end;

procedure DrawTriangle(Pdf: TPdf);
begin
  // Point overload: start at the first vertex, line to the rest, close.
  Pdf.CreatePath(200, 300, fmWinding, clBlue, $80, True, clNavy, $FF, 2.0);
  Pdf.LineTo(300, 300);
  Pdf.LineTo(250, 400);
  Pdf.ClosePath;
  Pdf.AddPath;          // nothing is drawn until this runs
end;

兩個多載涵蓋了常見的情況;四座標形式接受 X、Y、寬度和高度,並在一次呼叫中提供軸對齊的矩形,這正是您用來繪製水平線、儲存格邊框或填滿的背景面板所需要的;雙座標形式僅設定一個起點,您需要自己使用 LineToBezierTo 來描繪其餘的輪廓;填滿模式控制著重疊區域的繪製方式:fmWinding(非零環繞)適用於大多數實心形狀,fmAlternate(奇偶)處理挖空和自我相交的輪廓,而 fmNone 則保留無填滿的僅描邊路徑,這正是上述分割線所使用的模式

表格:手動組合的路徑與文字

因為表格無現成元件可用,所以表格是一個迴圈;您決定資料欄的 X 位移和資料列高度,使用 AddText 寫入每個儲存格,並使用矩形路徑繪製格線;計算是由您負責,但這很簡單,而且一旦寫好,就可以推廣到您需要的任何格線上

procedure DrawTable(Pdf: TPdf; Left, Top: Double);
const
  ColX: array[0..2] of Double = (0, 110, 210);  // column offsets
  RowH = 20;
var
  Y: Double;
  Row: Integer;
begin
  // Header row
  Pdf.AddText('Item', 'Arial', 10, Left + ColX[0], Top);
  Pdf.AddText('Qty', 'Arial', 10, Left + ColX[1], Top);
  Pdf.AddText('Price', 'Arial', 10, Left + ColX[2], Top);

  // Rule under the header
  Pdf.CreatePath(Left, Top - 5, 260, 0.5, fmNone, clBlack, $FF);
  Pdf.AddPath;

  // Data rows, stepping Y downward each iteration
  Y := Top;
  for Row := 1 to 3 do
  begin
    Y := Y - RowH;
    Pdf.AddText('Item ' + IntToStr(Row), 'Arial', 9, Left + ColX[0], Y);
    Pdf.AddText(IntToStr(Row * 2), 'Arial', 9, Left + ColX[1], Y);
    Pdf.AddText('$' + IntToStr(Row * 10) + '.00', 'Arial', 9, Left + ColX[2], Y);
  end;
end;

注意 Y 軸在每次傳遞時都會依據資料列高度向下遞減,這同樣是因為向上為正;這也是缺乏文字測量所帶來的影響:沒有什麼能阻止較長的項目名稱溢出到下一欄,因為函式庫不知道您的字串轉譯出來有多寬;對於您控制資料的固定格式輸出,您可以寬裕地調整資料欄的大小然後繼續;對於真正多變的內容,您要麼限制輸入,要麼在放置字元之前自己測量其寬度,此時專用的排版函式庫就開始展現其價值了

影像與多個頁面

點陣內容是透過影像輔助程式匯入的;AddPicture 接受已載入的 TPicture 並將其放置在一個點上,並可選擇寬度和高度來對其進行縮放;AddImage 直接接受檔案路徑或 TBitmap,而 AddJpegImage 則直接以資料流傳輸 JPEG 位元組,而無需透過點陣圖進行多餘的轉換;與其他所有內容一樣,放置座標是影像在使用者空間中的左下角,而寬度和高度是頁面上的點(points)大小,而不是來源的像素尺寸

procedure CreateMultiPageReport(const FileName: string; PageCount: Integer);
var
  Pdf: TPdf;
  P: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    for P := 1 to PageCount do
    begin
      Pdf.AddPage(P, 595, 842);     // append; the new page becomes current
      Pdf.AddText('Page ' + IntToStr(P) + ' of ' + IntToStr(PageCount),
        'Arial', 10, 50, 30);       // footer near the bottom edge
      // ... draw this page's body here ...
    end;
    Pdf.SaveAs(FileName);
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

多頁文件就是迴圈中的單頁模式;每個 AddPage 都會附加一個頁面並將其設為當前頁面,因此您接下來繪製的主體和頁尾就會落在您剛剛新增的頁面上;您不需要在該迴圈中重新指定 PageNumber,因為新增頁面已經將游標移到了該處;只有當您回到不按建立順序的頁面時,才需要 PageNumber;在最後一頁填滿後,在結尾呼叫一次 SaveAs 即可;如果您需要封存描述檔而不是單純的檔案,同一個文件物件會公開 SaveAsPdfA 和其他符合性變體,因此輸出標準的選擇是不同的儲存呼叫,而不是不同的建置路徑

適用場景

客觀的定位是,PDFium 元件的創作 API 是對 PDFium 頁面物件模型的一個忠實且輕量化的封裝:實現真實的文件建立、真實的內嵌字型、真實的向量和點陣內容,並序列化為符合標準的檔案;它不是、也不假裝是一個會自動重排的文件排版引擎;分界線在於文字的配置;如果您的輸出是樣板式的發票、憑證、標籤、在固定網格上呈現的儀表板,那麼絕對座標模型非常直接且快速,程式碼也能保持可讀性;如果您的輸出是必須自己換行和分頁的長篇散文,您將需要在這些呼叫之上面臨重建排版引擎的處境,這顯然不適合該項工作;了解您處於該分界線的哪一側,是決定採用它的關鍵

此處描述的建立方法是適用於 Delphi 的 PDFium 元件 的一部分,它將此創作路徑與 PDFium 更廣為人知的轉譯和文字擷取功能結合在一起