技術文章

在 Delphi 中使用 PDFium 元件分割 PDF 文件

一本 40 頁的使用者手冊,第一個頂層書籤指向第 5 頁,一個「從這個書籤切到下一個」的分割器寫出的章節檔案加起來只有 36 頁。封面、版權頁與兩頁目錄排在每個大綱條目之前,所以永遠落不進任何檔案。大綱只標示章節從哪裡開始;它不涵蓋第一個條目之前的東西,而且格式裡沒有任何規定要求條目按頁序排列,所以可靠的分割器把書籤頁當切割點收集、排序,並補上一段明確的前置內容範圍

每種分割模式背後都是同一個迴圈

PDFium Component 在第二個 TPdf 上用同樣四步分割文件,無論邊界由什麼決定:CreateDocument 在記憶體裡起一份空 PDF、ImportPages 從來源把一段頁面複製進去、SaveAs 寫檔、Active := False 再次關閉輸出。逐頁分割、固定大小分塊與書籤章節,差別只在交給 ImportPages 的範圍字串

每輪結尾的重置不是可省的。CreateDocument 拒絕在仍活躍的元件上執行,拋出訊息以「Cannot perform this operation on an active」開頭的 EPdfError,所以忘記 Active := False 的迴圈在剛好寫出一個檔案之後,第二輪就停在那個例外上。把重置放進 finally 區塊,失敗的匯入或儲存就不會把開著的輸出留給下一輪。一個 TPdf 實例服務整個流程,記憶體裡任何時刻只掛一份輸出文件

Delphi 中 PDFium Component 拆分迴圈的圖解:CreateDocument、從唯讀來源 ImportPages、帶檢查的 SaveAs,以及每次新迭代前的 Active 重設
無論群組怎麼定,迴圈都一樣:匯入頁面、儲存並檢查結果,再重設 Active,讓下一次 CreateDocument 從乾淨狀態起步

開啟來源值得一個自己的檢查。指派 Active := True 從不拋例外:檔案缺失、損壞或需要未供應的密碼時,TPdf 接住載入錯誤、讓 Active 停在 False。ImportPages 也會自己嘗試啟用來源,這代表沒開的來源只會以第一頁的 False 回傳值現形,原因毫無提示。明確開啟並測試 Active,在有任何輸出之前就能變成一個點名檔案的錯誤:

procedure SplitIntoPages(const SourceFile, OutputDir: string);
var
  Source, PdfOut: TPdf;
  I: Integer;
  OutFile: string;
begin
  Source := TPdf.Create(nil);
  PdfOut := TPdf.Create(nil);
  try
    Source.FileName := SourceFile;
    Source.Active := True;               // 載入失敗會讓 Active 停在 False
    if not Source.Active then
      raise Exception.Create('Cannot open ' + SourceFile);

    for I := 1 to Source.PageCount do
    begin
      PdfOut.CreateDocument;             // PdfOut 仍在活躍狀態時會丟例外
      try
        // 範圍以 1 起算;位置 1 是空文件的開頭
        if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
          raise Exception.CreateFmt('Cannot import page %d', [I]);
        OutFile := IncludeTrailingPathDelimiter(OutputDir) +
          Format('page_%.4d.pdf', [I]);
        if not PdfOut.SaveAs(OutFile) then
          raise Exception.Create('Cannot save ' + OutFile);
      finally
        PdfOut.Active := False;          // 下一輪之前先關閉輸出
      end;
    end;
  finally
    PdfOut.Free;
    Source.Free;
  end;
end;

ImportPages 的第三個參數是目的地裡第一頁匯入頁面落點的 1 起算位置。空文件裡 1 是唯一的位置。範圍語法本身(單頁、'5-10' 這類閉區間、逗號清單)與把合併包裹前後倒置的位置錯誤,在把多份 PDF 合併成一份文件一文中涵蓋——那是同一個 API 反過來用

固定大小分塊與呼叫端提供的範圍

每 N 頁切一份是個數數問題,唯一需要小心的是尾巴。230 頁的掃描按 25 頁一份切出九個完整份數,第十份只有五頁,所以每段結尾夾到 Source.PageCount:

procedure SplitEvery(Source: TPdf; ChunkSize: Integer; const OutputDir: string);
var
  PdfOut: TPdf;
  FirstPage, LastPage, Part: Integer;
  Range, OutFile: string;
begin
  if ChunkSize < 1 then
    raise Exception.Create('ChunkSize must be at least 1');
  PdfOut := TPdf.Create(nil);
  try
    Part := 0;
    FirstPage := 1;
    while FirstPage <= Source.PageCount do
    begin
      LastPage := FirstPage + ChunkSize - 1;
      if LastPage > Source.PageCount then
        LastPage := Source.PageCount;    // 最後一份通常較短
      if FirstPage = LastPage then
        Range := IntToStr(FirstPage)     // 單頁尾段直接用純數字
      else
        Range := Format('%d-%d', [FirstPage, LastPage]);
      Inc(Part);

      PdfOut.CreateDocument;
      try
        if not PdfOut.ImportPages(Source, Range, 1) then
          raise Exception.Create('Cannot import pages ' + Range);
        OutFile := IncludeTrailingPathDelimiter(OutputDir) +
          Format('part_%.3d.pdf', [Part]);
        if not PdfOut.SaveAs(OutFile) then
          raise Exception.Create('Cannot save ' + OutFile);
      finally
        PdfOut.Active := False;
      end;
      FirstPage := LastPage + 1;
    end;
  finally
    PdfOut.Free;
  end;
end;

使用者敲進來的範圍是另一回事。ImportPages 透過回傳值回報壞範圍:超過 Source.PageCount 的頁碼、零、反轉的區間或解析不了的文字都讓呼叫回傳 False,字串裡的頁面一頁也不會複製。在迴圈裡,這個失敗出現在第四項——磁碟上已經躺著三個章節檔之後。在第一個 CreateDocument 之前對照 Source.PageCount 檢查每一項,花幾行解析,換來第四項的錯字什麼都不寫——重試工作的人期待的正是這個行為

選擇已經在整數陣列裡時,例如縮圖檢視裡挑的頁面,ImportPagesByIndex(Source, PageIndices, InsertAt) 跳過字串這步。那裡索引與 InsertAt 都從 0 起算,所以讀作 '1,3-5' 的選擇變成 [0, 2, 3, 4]

書籤章節為什麼會變短或消失?

書籤章節出錯,是程式碼相信了大綱會很整齊。TPdf.Bookmarks 按作者排列的順序回傳頂層條目,每個是帶 Title 與 1 起算 PageNumber 的 TBookmark 記錄。真實檔案裡有四件事打破天真的「這條到下一條」規則:

  • 大綱項目的目標可以是 /Dest 項,也可以是 /A 裡的 GoTo 動作(ISO 32000-1 §12.3.3 與 §12.6.4.2)。PageNumber 從 /Dest 填入;動作型條目停在 0,頁面改在 ActionPageNumber 裡
  • 大綱順序是作者眼中的閱讀順序。排在第二位的「索引」條目、列在章節之前的附錄,會讓「下一書籤減一」小於目前起點,該章節就此消失
  • 兩個頂層條目可以指向同一頁,典型是部標題與它的第一章
  • 當頁面被某個不動大綱的工具刪掉後,條目可以指向超過最後一頁
以 PDFium Component 在 Delphi 拆分時,把 PDF 頂層書籤映射到計算出的頁面範圍與輸出檔案的圖解,包括被略過的超出範圍書籤
每個區段從頂層書籤的頁面跑到下一個書籤的前一頁;指到結尾之後的條目直接跳過,不會產出空檔案

下面的版本把大綱當切割點來源。它解析每個條目的頁面、丟掉指不到地方或超過結尾的條目、同頁保留第一個標題、按頁碼排序,並在第一個切割點晚於第 1 頁時補一個「Front matter」章節。前置內容在這裡有自己的檔案,而不是黏在第 1 章前面,因為收到第 1 章的人預期它開在書籤指的那一頁,而不是目錄頁

procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
  Bm: TBookmarks;
  Starts: array of Integer;
  Titles: array of string;
  I, J, K, N, P, EndPage: Integer;
  Known: Boolean;
  PdfOut: TPdf;
  OutFile: string;
begin
  Bm := Source.Bookmarks;                // 只有頂層條目
  if Length(Bm) = 0 then
    Exit;                                // 沒有可切的依據
  SetLength(Starts, Length(Bm) + 1);     // +1 給前置內容留位
  SetLength(Titles, Length(Bm) + 1);
  N := 0;
  for I := 0 to High(Bm) do
  begin
    P := Bm[I].PageNumber;               // 由 /Dest 解析
    if P < 1 then
      P := Bm[I].ActionPageNumber;       // 由 /A 裡的 GoTo 動作解析
    if (P < 1) or (P > Source.PageCount) then
      Continue;                          // 未解析,或超過最後一頁
    Known := False;
    for J := 0 to N - 1 do
      if Starts[J] = P then
        Known := True;                   // 同頁第一個標題勝出
    if Known then
      Continue;
    J := N;                              // 按頁碼插入排序
    while (J > 0) and (Starts[J - 1] > P) do
    begin
      Starts[J] := Starts[J - 1];
      Titles[J] := Titles[J - 1];
      Dec(J);
    end;
    Starts[J] := P;
    Titles[J] := Bm[I].Title;
    Inc(N);
  end;
  if N = 0 then
    Exit;
  if Starts[0] > 1 then                  // 封面、目錄與它們之前的所有內容
  begin
    for J := N downto 1 do
    begin
      Starts[J] := Starts[J - 1];
      Titles[J] := Titles[J - 1];
    end;
    Starts[0] := 1;
    Titles[0] := 'Front matter';
    Inc(N);
  end;

  PdfOut := TPdf.Create(nil);
  try
    for K := 0 to N - 1 do
    begin
      if K < N - 1 then
        EndPage := Starts[K + 1] - 1
      else
        EndPage := Source.PageCount;
      PdfOut.CreateDocument;
      try
        if not PdfOut.ImportPages(Source,
          Format('%d-%d', [Starts[K], EndPage]), 1) then
          raise Exception.CreateFmt('Cannot import pages %d-%d',
            [Starts[K], EndPage]);
        // 在此常式之前先宣告 SafeFileName(見後文)
        OutFile := IncludeTrailingPathDelimiter(OutputDir) +
          Format('%.2d_%s.pdf', [K + 1, SafeFileName(Titles[K])]);
        if not PdfOut.SaveAs(OutFile) then
          raise Exception.Create('Cannot save ' + OutFile);
      finally
        PdfOut.Active := False;
      end;
    end;
  finally
    PdfOut.Free;
  end;
end;

排序之後起點嚴格遞增,每個算出的區間都有效,ImportPages 的防護在可讀的來源上永遠不該觸發。沒有書籤的文件在這個模式下乾脆不產檔;要不要退回分塊,由呼叫端決定。要往深一層切——例如部裡面的章——BookmarkChildren[Bm[I]] 以同樣的 TBookmarks 形式回傳頂層條目的子節,同一套切割點邏輯套在合併後的清單上即可

每份輸出檔案帶走什麼

每份輸出檔案帶走頁面本身與其頁面字典引用的一切:內容串流、字型、影像與每頁的附註清單。表單元件以附註身分跟著走,但新檔案沒有 AcroForm,所以不再是可用的欄位。來源只被讀取。ImportPages 把物件複製進新文件、開著的來源原封不動,所以同一個 Source 實例餵每一輪迭代

大綱留在了原地。書籤住在文件 catalog 的 /Outlines 下(ISO 32000-1 §7.7.2),不在任何頁面裡,所以由匯入頁面建出的檔案一開始完全沒有書籤。對章節檔案來說這常常無妨。當每個部分需要自己的導覽,大綱得為新的頁碼重建,那是編輯 PDF 大綱並重映射其頁面的主題。內部連結同理:第 12 頁上「見第 7 章」的連結指向去了另一個檔案的頁面,在這份檔案裡無處可落

加密來源需要在迴圈開始前給密碼。元件未活躍時指派 Source.Password,再設 Active := True;密碼錯了元件停在未活躍狀態,SplitIntoPages 裡的檢查把它變成錯誤。SaveAs 寫出的輸出是沒有加密字典的新文件。這正合要發給不同收件人的分割檔;而每個部分需要重新保護時,SaveAsEncrypted(FileName, Options) 吃一個帶自有使用者與擁有者密碼的 TPdfEncryptOptions 記錄

檔名、失敗的儲存與重跑

書籤標題是 PDF 文字字串,可以含任何字元,包括 Windows 在檔名裡拒收的字元。規則來自檔案系統、不是 PDF:< > : " / \ | ? * 與控制字元無效,結尾空格與句點被 Win32 檔案 API 剝掉,CON、PRN、COM1 這類名稱是保留裝置名。SplitByBookmarks 裡的編號前綴已經顧及裝置名、讓檔案在目錄清單裡保持閱讀順序,也讓兩個都叫「Summary」的章節不會互相覆蓋。剩下的交給您自己寫的一個小函式:

// 閱讀端輔助函式,不是 PDFium Component 的一部分
function SafeFileName(const Title: string): string;
const
  Forbidden = '<>:"/\|?*';
var
  I: Integer;
begin
  Result := Title;
  if Length(Result) > 80 then
    SetLength(Result, 80);               // 讓完整路徑遠低於 MAX_PATH
  for I := 1 to Length(Result) do
    if (Ord(Result[I]) < 32) or (Pos(Result[I], Forbidden) > 0) then
      Result[I] := '_';
  while (Result <> '') and
    ((Result[Length(Result)] = '.') or (Result[Length(Result)] = ' ')) do
    SetLength(Result, Length(Result) - 1);
  if Result = '' then
    Result := 'section';
end;

帶檔名的 SaveAs 先寫到目標資料夾裡的暫存檔、儲存成功後才換到定點。目標因此不是舊檔就是完整的新檔,絕不會寫到一半,所以在已有的輸出資料夾上重跑分割是無害的。失敗有兩條路回來。文件無法序列化時 SaveAs 回傳 False、暫存檔被移除。檔案系統層的問題——輸出資料夾不存在、作業系統拒收的路徑、被檢視器鎖住的目標——從檔案層拋例外

批次對這些失敗如何反應是一種選擇,而且分割與合併的答案不同。缺一個部分的合併包裹是一份錯的文件,那裡停下是對的。分割輸出彼此獨立:section_147.pdf 因為同事開著它而寫不出去,其他章節照樣是好的。分割作業的正確做法是在迴圈裡逐檔接例外、記下檔名與訊息、繼續跑,迴圈結束時把寫出的檔案數與計畫的範圍數比對。上面的範例為了簡短才在第一次失敗就拋例外。一份寫著「200 個檔案寫出了 199 個、並點名被鎖的那個」的日誌,重跑一次就修好;在第 147 個檔案中斷的執行,留給別人去算到底少了哪 53 個

這裡用到的分割迴圈、書籤記錄與加密儲存,都隨 PDFium Component 提供——一套給 Delphi 與 C++Builder、以 PDFium 為基礎的 PDF 程式庫