技術文章

在 Delphi 中使用 PDFium 元件合併 PDF 檔案

一個每次呼叫都把插入位置傳 1 的合併迴圈,會產出頁數正確、文件順序卻完全顛倒的檔案。清單裡最後一份文件落在第 1 頁,因為位置 1 的意思是「插在目前第一頁之前」,每個新來源都把先前的往後推。什麼都沒少——這恰恰是這個錯誤能挺過頁數抽查、直到有人真的翻閱成品才現身的原因

用 ImportPages 逐頁搬的迴圈

PDFium 元件在 Delphi 裡用兩個 TPdf 實體合出一份 PDF:一個用 CreateDocument 建立的空目標文件,和一個依序開啟每個輸入檔的來源。TPdf.ImportPages(Pdf, Range, PageNumber) 把 Range 指名的頁面從 Pdf 複製進目標文件,成功回傳 True。PageNumber 以 1 起算,預設 1;它指出第一個匯入頁面落點在目標文件的位置,所以 1 是插在最前面,PageCount + 1 是附加在後

要讓文件照清單順序排好,最乾淨的做法是乾脆不追蹤游標,直接問目標文件自己的結尾在哪。用一個遞增的 InsertAt 計數器也行,但它必須按實際匯入的頁數推進,而一旦頁面範圍摻進來,這個數字很容易算錯。PdfDest.PageCount + 1 永遠是對的:

procedure MergeFileList(const FileList: TStrings; const OutputPath: string);
var
  PdfDest, PdfSrc: TPdf;
  I: Integer;
begin
  PdfDest := TPdf.Create(nil);
  PdfSrc  := TPdf.Create(nil);
  try
    PdfDest.CreateDocument;
    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]]);

      // 1 起算的位置:PageCount + 1 附加在最後一頁之後
      if not PdfDest.ImportPages(PdfSrc,
        '1-' + IntToStr(PdfSrc.PageCount), PdfDest.PageCount + 1) then
        raise Exception.CreateFmt('Page import failed for %s', [FileList[I]]);

      PdfSrc.Active := False;             // FileName 只有在非作用中時才能改
    end;

    if not PdfDest.SaveAs(OutputPath) then
      raise Exception.Create('Cannot write ' + OutputPath);
  finally
    PdfSrc.Free;
    PdfDest.Free;
  end;
end;

那個迴圈裡有兩個檢查真正扛著重量。Active := True 永不拋例外:檔案失踪、損壞或需要密碼時,TPdf 把載入錯誤吃下來,Active 停在 False。元件保持在一致的關閉狀態,表單遇上壞檔也不會崩潰。但跳過檢查的合併迴圈,只會安靜地產出一份更短的輸出。第二個檢查是 ImportPages 的布林結果,範圍字串解析不動或位置超過 PageCount + 1 時它回傳 False。另外注意 FileName 與 Password 只能在元件非作用中時指派,這就是每一輪結尾要把來源關掉的原因

Delphi 搭配 PDFium Component 的合併迴圈:開啟每個來源檔、經 ImportPages 複製到 InsertAt 位置,最後以 SaveAs 寫出目的文件一次
ImportPages 把每個來源落到 InsertAt 位置;檔案缺失或損壞時默默失敗,除非你檢查了 Active

範圍字串:剖析器吃什麼

範圍引數是逗號分隔的清單,由單頁與閉區間組成,以來源文件的 1 起算編號。'1-3' 取前三頁,'2,4,6' 取三個特定頁,'1,3-5' 取封面加三頁摘要。數字永遠指來源;這些頁面落在目標文件何處,只由位置引數決定

開放式區間是大家摔跤的地方。'7-' 這種字串看起來是「第 7 頁到結尾」,但 PDFium 的範圍剖析器要求區間兩端俱全,整串拒收。反轉的區間('5-3')、頁碼 0、或任何超出來源頁數的頁碼也一樣。一個壞項目讓整個呼叫失敗:ImportPages 回傳 False,那個字串的頁面一頁都不會被複製。這正是上面的迴圈要拼出 '1-' + IntToStr(PdfSrc.PageCount)、而不直接寫 '1-' 的原因

手邊已經有頁面索引陣列時,ImportPagesByIndex(Source, PageIndices, InsertAt) 可以跳過字串這一步。不過要留意慣例切換:索引與 InsertAt 在那裡都以 0 起算,所以附加是 InsertAt = PdfDest.PageCount,「封面加摘要」的選取變成 [0, 2, 3, 4]。傳空陣列則匯入每一頁

一整串檔案有沒有單呼叫的合併?

有。TPdf.MergeFiles(Sources, Options, Report) 接受一個 TPdfBatchMergeSource 記錄陣列,每筆用 TPdfBatchMergeSource.FromFile(FileName, PageRange, Password) 建立;它逐一開啟來源,把發生的事填進 TPdfBatchMergeReport。空的 PageRange 代表每一頁。範圍語法是同一套逗號加區間的格式,格式錯誤的範圍會變成報告裡的逐檔錯誤,而不是無聲略過

動任何預設之前,先認得 TPdfBatchMergeOptions.Default 給了什麼。CreateDestination 為 True,非作用中的目標文件會替你建好。InsertAt 為 -1,代表附加;其他任何值都是 0 起算的索引,對齊的是 ImportPagesByIndex 而不是 ImportPages。ErrorPolicy 為 bmepStop、RollbackOnCancelOrFailure 為 True,一個讀不了的檔案就結束整趟執行,並移除這趟已加上的每一頁。CopyViewerPreferences 複製第一個成功匯入來源的檢視器偏好

procedure BuildBundle(const OutputPath: string; Log: TStrings);
var
  Dest: TPdf;
  Sources: TPdfBatchMergeSources;
  Options: TPdfBatchMergeOptions;
  Report: TPdfBatchMergeReport;
  I: Integer;
begin
  SetLength(Sources, 3);
  Sources[0] := TPdfBatchMergeSource.FromFile('cover.pdf');
  Sources[1] := TPdfBatchMergeSource.FromFile('annual-report.pdf', '1,3-5');
  Sources[2] := TPdfBatchMergeSource.FromFile('appendix.pdf', '', 'user-password');

  Options := TPdfBatchMergeOptions.Default;
  Options.ErrorPolicy := bmepSkip;        // 越過讀不了的檔案繼續

  Dest := TPdf.Create(nil);               // 非作用中:由 MergeFiles 建立
  try
    if not Dest.MergeFiles(Sources, Options, Report) then
      raise Exception.Create('Merge failed: ' + Report.ErrorMessage);

    for I := 0 to High(Report.Issues) do
      Log.Add(Format('%s: %s',
        [Report.Issues[I].FileName, Report.Issues[I].MessageText]));
    Log.Add(Format('%d pages from %d of %d files',
      [Report.ImportedPageCount, Report.ImportedSourceCount, Report.SourceCount]));

    Dest.SaveAs(OutputPath);
  finally
    Dest.Free;
  end;
end;

錯誤政策怎麼選,取決於「少一個檔案」對輸出意味著什麼。合約包或發票批,缺一件就是錯的文件:保留預設 bmepStop 加回滾。只有盡力而為的彙整——例如每週報表摘要——才換 bmepSkip,而且要把 Report.Status 裡的 bmrsCompletedWithErrors 當成必須有人看的訊號。不管選哪個,都要讀 Report.Issues:每筆記錄帶來源索引、檔名與訊息,直接回答「兇手是哪個檔案」——手寫迴圈得靠自己的例外來回答這一題

長時間執行還有兩個選項要認識。OnProgress 與 CancellationToken 讓 UI 顯示目前檔案並中止執行,被取消的執行像失敗一樣回滾。ResourcePolicy 預設 bmrpSourceScoped:每個來源選中的頁面一次原生呼叫匯入,這些頁面共用的字型與影像只複製一次。bmrpChunked 按 PageChunkSize 頁分批匯入,取消點更細,代價是共用資源每批都要重抄一遍

匯入的頁面帶走什麼,什麼留在來源

匯入的頁面帶走它的頁面字典可達的一切:內容串流、資源裡的字型、影像與表單 XObject,以及它的註記,包括留言、螢光標記與筆跡。住在來源文件層級的東西不跟著走,因為 ISO 32000-1 把它們放在頁面樹之外

  • 文件資訊字典(ISO 32000-1 §14.3.3)留在原地,所以合併檔沒有各來源的 Title、Author 或 Keywords。TPdf 上的 Title 與 Author 屬性是唯讀的,關掉來源前順手記錄一下它們各自宣告了什麼,倒是很好用
  • 預設 OutlinePolicy 為 bmopDiscard,大綱會被丟棄;bmopReject 則把帶大綱的來源變成逐檔錯誤。要保書籤,FPdfMerge 單元提供 MergeFilesWithNavigation:重建大綱、預設以 Imported_ 前綴為撞名的具名目的地改名、並重排頁面之間的連結;大綱編輯與頁面重排 一文講述這些目的地如何被改寫
  • 加密(§7.6)屬於來源檔案。受保護的來源帶著 Password 開啟,但 CreateDocument 造出的目標文件不加密,複製進去的頁面也一樣。合併檔需要保護的話,改用 SaveAsEncrypted 寫出,而不是 SaveAs
PDFium ImportPages 把頁面內容、字型與註解帶進合併 PDF;Info 中繼資料、AcroForm widget 與加密則留在各來源檔中
ImportPages 帶走與頁面一起存放的一切;文件層級中繼資料與 AcroForm 互動性則被留在原地

為什麼合併之後表單欄位就失效了?

表單欄位橫跨 PDF 的兩個層級。畫出文字框的 widget 註記坐在頁面上,而欄位本身——連同名稱與值——列在目錄互動式表單字典的 /Fields 陣列裡(ISO 32000-1 §12.7.2)。原生頁面匯入把 widget 連同其他頁面註記一起複製,卻沒有任何東西把它加進目標文件的欄位樹。方框照樣畫出最後的外觀。但從 /AcroForm 建立表單模型的檢視器,沒有欄位可掛這個 widget,於是值改不了,任何走訪欄位樹的表單操作都跳過它

TPdfBatchMergeOptions.FormPolicy 為普通合併提供三種答案。bmfpPreserveNative(預設)保留上述原生匯入結果。bmfpFlatten 在匯入之前,於記憶體中為每個帶表單的來源產生外觀並壓平所選頁面,值變成普通頁面內容;磁碟上的來源檔不受更動。bmfpReject 把任何帶 AcroForm 或 XFA 表單的來源變成逐檔錯誤

欄位必須保持可填時,用 FPdfFormMerge 單元裡的 MergeFilesWithForms。它匯入頁面內容、從每個來源的欄位樹重建 widget,並把那些欄位嫁接進目標表單。欄位名稱是難纏的部分。按 §12.7.3.2,完全限定名稱相同的兩個欄位是同一個欄位、共用一個值,所以發票裡的 Total 欄位與送貨單裡的 Total 欄位會互相覆蓋。預設 CollisionPolicy 為 pagcpReject,第一次撞名就中止合併:

uses
  PDFium, FPdfCompress, FPdfFormMerge;

procedure MergeFillableForms(Dest: TPdf;
  const Sources: TPdfBatchMergeSources; Log: TStrings);
var
  Options: TPdfFormMergeOptions;
  Report: TPdfFormMergeReport;
begin
  Options := TPdfFormMergeOptions.Default;
  // 撞名的欄位會加上 RenamePrefix(除非你改過,否則是 'Imported_')
  Options.GraftOptions.CollisionPolicy := pagcpRename;

  if Dest.MergeFilesWithForms(Sources, Options, Report) then
    Log.Add(Format('%d fields grafted, %d renamed',
      [Report.GraftedFieldCount, Report.RenamedFieldCount]))
  else
    Log.Add('Form merge failed: ' + Report.ErrorMessage);
end;

pagcpNamespace 改為把每個來源的欄位放進各自的前綴(Imported 加來源編號),pagcpMergeCompatible 則只在類型與選項一致時合併同名欄位。MergeFilesWithForms 適用於無簽章欄位的 AcroForm 來源與目標。XFA 表單與已簽署的檔案一律拒收,因為改寫已簽署的檔案會讓 ISO 32000-1 §12.8.1 描述的簽章位元組範圍失效。開啟中的目標文件還必須未加密:合進有密碼保護的檔案會失敗,事後用 SaveAsEncrypted 加保護即可。同一來源裡被選中兩次的頁面,例如 '1,1',需要明確的 RepeatedPagePolicy

目標文件只在暫存結果被重新開啟並檢查之後才被替換。欄位名稱、值與類型逐頁比對,失敗的表單合併讓 Dest 保持原樣。AcroForm 嫁接 一文對撞名處理講得更深

儲存合併後的文件

SaveAs 有檔案多載與 TStream 多載,都接受選用的 TSaveOption(預設 saNone)與 TPdfVersion(預設 pvUnknown,表頭交由程式庫決定)。兩者都回傳布林,批次作業裡這個結果值得獲得與 Active 同等的檢查。只有下游系統堅持特定表頭時,才用 pv17 之類把版本釘死

反過來的活兒——把一份檔案切成好幾份——用同一套範圍字串與同樣 1 起算的位置;拆分 PDF 文件 一文帶你走一遍

ImportPages、MergeFiles 與本文描述的表單感知合併,都隨 PDFium 元件 出貨,把這些頁面操作帶進 Delphi 與 C++Builder 應用程式