一本 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 實例服務整個流程,記憶體裡任何時刻只掛一份輸出文件
開啟來源值得一個自己的檢查。指派 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裡 - 大綱順序是作者眼中的閱讀順序。排在第二位的「索引」條目、列在章節之前的附錄,會讓「下一書籤減一」小於目前起點,該章節就此消失
- 兩個頂層條目可以指向同一頁,典型是部標題與它的第一章
- 當頁面被某個不動大綱的工具刪掉後,條目可以指向超過最後一頁
下面的版本把大綱當切割點來源。它解析每個條目的頁面、丟掉指不到地方或超過結尾的條目、同頁保留第一個標題、按頁碼排序,並在第一個切割點晚於第 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 程式庫