技術文章

使用 PDFium 元件將多個 PDF 檔案合併為單一文件

PDFium 元件透過單一方法公開了 PDF 合併功能:ImportPages;該模式始終相同:建立一個空的目標文件、依序開啟每個來源檔案、呼叫 ImportPages 複製頁面、關閉來源檔案,然後重複該步驟;當迴圈結束時,SaveAs 會將結果寫入磁碟;沒有特殊的合併模式,也沒有需要切換的設定;複雜性存在於邊緣情況中,且有幾個情況會毫無預警地產生影響

核心迴圈

您只需要兩個 TPdf 執行個體;一個持有使用 CreateDocument 建立的空目標文件;另一個則依序開啟每個來源檔案;以下是接受檔案路徑清單並將合併後的輸出寫入單一路徑的程序:

procedure MergeFiles(const FileList: TStrings; const OutputPath: string);
var
  PdfDest, PdfSrc: TPdf;
  InsertAt, I: Integer;
begin
  PdfDest := TPdf.Create(nil);
  PdfSrc  := TPdf.Create(nil);
  try
    PdfDest.CreateDocument;
    InsertAt := 1;  // ImportPages uses 1-based destination position

    for I := 0 to FileList.Count - 1 do
    begin
      PdfSrc.FileName := FileList[I];
      PdfSrc.Active   := True;

      if not PdfSrc.Active then
        raise Exception.CreateFmt('Cannot open: %s', [FileList[I]]);

      PdfDest.ImportPages(
        PdfSrc,
        '1-' + IntToStr(PdfSrc.PageCount),  // full document range
        InsertAt);

      Inc(InsertAt, PdfSrc.PageCount);
      PdfSrc.Active := False;
    end;

    PdfDest.SaveAs(OutputPath);
  finally
    PdfSrc.Free;
    PdfDest.Free;
  end;
end;

首次閱讀該程式碼時,有兩件事很容易被忽略;第一是 PDFium 如何回報載入失敗;Active := True 絕不會引發異常:如果檔案遺失、損毀或受密碼保護,PDFium 會在內部處理該錯誤,並讓 Active 保持為 False;如果沒有在第 10 行進行明確檢查,損壞的檔案將會靜默地從合併中卸除,且在輸出中沒有任何提示;最終的 PDF 頁數會少於預期,且您無法得知是哪個檔案出錯

第二個是 InsertAt 計數器;ImportPages 的張數是目標文件中放置第一個匯入頁面的起始位置(以 1 為基準);從 1 開始會將第一個來源文件放在原本為空的文件開頭;在處理完每個來源檔案後,計數器會加上 PdfSrc.PageCount,因此下一批頁面會附加在上一個頁面之後;如果忘記將其遞增,後續的每個來源檔案都會覆寫位置 1 的頁面,導致您最終只得到清單中的最後一份文件,而沒有其他內容

選擇性頁面範圍

您不需要取得來源檔案的每一頁;作為第二個參數傳遞的範圍字串遵循簡單的逗號和連字號格式:"1-3" 取得第 1 頁到第 3 頁,"2,4,6" 選取三個特定的頁面,而 "1-" 代表從第 1 頁到文件的結尾;範圍可以在單一字串中結合,例如 "1-3,5,7-" 會跳過第 4 頁和第 6 頁;這裡需要注意一個細微之處:這些數字始終是指來源文件中的頁面,從 1 開始,不論這些頁面最終在目標文件中的哪個位置;如果您想從 200 頁的目錄中取出第 40 頁到第 50 頁,範圍字串應該是 "40-50",而不是相對於目標文件中已有內容的位置

// Extract cover plus a three-page executive summary from a long report
PdfSrc.FileName := 'annual-report.pdf';
PdfSrc.Active   := True;
if PdfSrc.Active then
begin
  // Page 1 is the cover; pages 3-5 are the summary
  PdfDest.ImportPages(PdfSrc, '1,3-5', InsertAt);
  Inc(InsertAt, 4);  // 1 cover + 3 summary pages = 4 pages added
  PdfSrc.Active := False;
end;

在計算 InsertAt 的增量時,請計算您實際匯入的頁數,而不是來源檔案的總頁數;如果您傳遞 '1,3-5',則代表匯入了 4 頁,因此請加上 4;若加上 PdfSrc.PageCount 將會在目標文件中留下空白位置的間隙,並將下一個來源文件放在比預期更後面的位置

ImportPages 保留與不保留的內容

透過 ImportPages 複製的頁面會完整保留其可見內容;文字、向量圖形、點陣影像、內嵌字型和表單 XObject 都會作為頁面內容串流的一部分進行傳輸;頁面層級的註記(包括註解、高亮度標記和筆跡)也會一併帶過來,因為它們儲存在頁面字典中,而不是在文件層級中

文件層級的元資料(Metadata)則不同;來源檔案 Info 字典中的主旨、作者、主題和關鍵字字串會被留下;在 CreateDocument 之後,目標文件會以空的元資料開始,因此如果合併後的輸出需要填入這些欄位,您必須在呼叫 SaveAs 之前直接將它們指派給 PdfDestTPdf 上的 TitleAuthorSubjectKeywordsCreator 屬性接受一般字串,並在儲存時寫入 Info 字典中

互動式表單欄位更為複雜;AcroForm 欄位定義存在於文件層級的字典中,而不是在個別的頁面串流中;當 ImportPages 複製包含表單欄位的頁面時,這些欄位的視覺外觀會隨之傳輸,因為它已被轉譯到頁面內容串流中,但是使其具有互動性的欄位小工具是 AcroForm 結構的一部分,不會跟著傳輸;在典型的合併中,來自來源文件的文字欄位將會顯示它在匯入時的值,但在合併後的檔案中將無法編輯;如果您需要欄位保持可填寫狀態,請在匯入前在每個來源文件中將它們扁平化(flatten):這會將當前的值合併到內容串流中,並移除互動式重疊,從而在輸出中提供乾淨的視覺效果,而不會出現損壞的小工具

加密的來源檔案

受密碼保護的來源文件與未加密的文件開啟方式相同,但首先需要設定一個額外的屬性;在切換 Active := True 之前,將密碼指派給 PdfSrc.Password,PDFium 將會在開啟時使用它:

PdfSrc.Password := 'user-password';
PdfSrc.FileName := 'protected.pdf';
PdfSrc.Active   := True;
if not PdfSrc.Active then
  raise Exception.Create('Wrong password or file cannot be opened');

PdfDest.ImportPages(PdfSrc, '1-' + IntToStr(PdfSrc.PageCount), InsertAt);
Inc(InsertAt, PdfSrc.PageCount);
PdfSrc.Active := False;

錯誤的密碼會導致與檔案遺失相同的靜默 Active = False 結果,因此在此處明確的檢查同樣不可或缺;加密不會傳輸到目標文件中:從受保護的來源檔案匯入的頁面在目標文件中會作為未受保護的內容存在;如果合併後的輸出也需要加密,請在呼叫 SaveAs 之前在 PdfDest 上進行設定

儲存結果

TPdf 上的 SaveAs 接受檔案路徑或 TStream;對於大多數的合併,檔案多載正是您所需要的:

PdfDest.SaveAs('merged-output.pdf');

選用的第二個參數是控制儲存模式的 TSaveOption;預設值 saNone 會在文件從檔案載入時寫入增量更新,或者在文件是重新建立時寫入完整重寫;由於使用 CreateDocument 建置的目標文件始終是全新的,因此輸出將是精簡的單一修訂檔案;第三個參數 TPdfVersion 讓您可以在下游使用者需要特定版本時,固定 PDF 版本標頭,將其保持為 pvUnknown 則可讓 PDFium 根據內容自行選擇

此處顯示的 ImportPagesSaveAs 方法是適用於 Delphi 和 C++Builder 的 PDFium 元件 的一部分