從兩百頁手冊中抽掉七頁,每個書籤就落到錯誤的地方。解法不是從一份平坦的標題清單重建大綱。PDFiumPas 提供 TPdfOutlineEditor:它載入真正的大綱樹,讓您移動項目、重新指定目標,然後執行 ApplyPageMap,把每個顯式目的地按您的頁面計畫移位
為什麼刪頁會弄壞每個書籤?
因為大綱項目不儲存頁碼。它儲存的是對頁面物件的參照;當頁面物件改變,該參照不是指向一個移走的頁面,就是什麼也沒指。ISO 32000-1 §12.3.2.2 把顯式目的地定義為一個陣列:第一個元素是對頁面字典的間接參照,之後跟著一個適配名稱,例如 /Fit 或 /XYZ。刪掉頁面,您只剩一個懸空參照;重新排序頁面,參照仍有效,卻描述了不同的章節。PDFiumPas 在載入時把該陣列解析回頁碼,所以 TPdfOutlineItem.PageNumber 給您的是從一開始的頁面索引,與公開的 TPdf API 一致,而不是物件編號。這正是此抽象的全部意義:您的重新對應邏輯,與您在分割、重排或拼版文件時早已建好的頁面計畫,運作在同一座標系中。如果您正在建構那份計畫,同一套從一開始的慣例貫穿把 PDF 文件分割成多個檔案與n-up 拼版與頁面重排
大綱是雙重連結的樹,不是清單
您不能簡單地把平坦的標題陣列序列化,原因在於 ISO 32000-1 §12.3.3 把每個大綱項目接進五條各自獨立的連結:/Parent、/Prev、/Next、/First 與 /Last。因此移動單一子樹,會重寫舊父節點、新父節點、切口兩側與插入點兩旁的鄰近兄弟,以及被移動節點自身的父指標。其中一個弄錯,合規檢視器就會顯示截斷的樹,或陷入迴圈。PDFiumPas 把編輯狀態保存為 TPdfOutlineItem 記錄的深度優先陣列,帶著穩定的整數 Id,所以子樹是一段連續切片,兄弟鏈結是導出的,絕不手工維護。TPdfOutlineEditor.Move 抬起該切片,在新父節點下、依要求的兄弟索引重新插入,並只重新指派該區塊的根。它也拒絕兩種會毀損圖譜的移動:把項目移進它自己的子樹,以及指定一個不存在的父節點
為什麼 /Count 帶正負號?
因為正負號攜帶的是展開狀態,不是大小。正的 /Count 代表項目已開啟,數字是目前可見的後代數;負的 /Count 代表項目已摺疊。PDFiumPas 為每個有子女的項目寫入後代數,並在 IsOpen 為 False 時將其取負;載入時則把狀態讀回為 IsOpen := HasCount and (CountValue > 0)。這是手刻大綱寫入器最常見的單一錯誤:發出不帶正負號的計數,悄悄迫使整棵樹展開
var
Source, Dest: TMemoryStream;
Editor: TPdfOutlineEditor;
Options: TPdfOutlineEditOptions;
Report: TPdfOutlineValidationReport;
RootId, ChapterId: Integer;
begin
Source := TMemoryStream.Create;
Dest := TMemoryStream.Create;
Editor := nil;
try
Source.LoadFromFile('handbook.pdf');
Options := TPdfOutlineEditOptions.Default; // MaxItems 100000、MaxDepth 64
if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
raise Exception.Create(Report.ErrorMessage);
RootId := Editor[0].Id;
ChapterId := Editor[2].Id;
Editor.Move(ChapterId, RootId, 1); // 成為根的第二個子項目
Editor.SetTitle(ChapterId, 'Appendix B');
Editor.SetStyle(ChapterId, [posBold, posItalic]);
Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
Editor.SetExpanded(RootId, False); // 寫入負的 /Count
Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');
if not Editor.SaveIncremental(Source, Dest, Report) then
raise Exception.Create(Report.ErrorMessage);
Dest.SaveToFile('handbook-edited.pdf');
finally
Editor.Free;
Dest.Free;
Source.Free;
end;
end;
Retarget 處理規格允許的兩種形狀。把 DestinationInAction 傳為 False,PDFiumPas 會寫入直接的 /Dest 陣列;傳為 True,它會依 ISO 32000-1 §12.6.4.2 寫入一個 Go-To 動作:/A << /S /GoTo /D [ page ref suffix ] >>。不論哪種方式,它都會先從項目剝除任何既有的 /Dest 與 /A,使兩者無法共存並互相矛盾。suffix 預設為 /Fit,且必須以 PDF 名稱開頭;這正是空的或畸形的 suffix 會立刻引發例外,而不是產生一個沒有檢視器能解析的目的地陣列的原因
ApplyPageMap 如何使用頁面計畫?
ApplyPageMap 接受的正是您的頁面計畫早已驗證過的陣列:NewPageNumbers,以舊頁碼減一為索引,持有新的從一開始頁碼;若該頁未存活則為零。它反向走訪項目陣列,使刪除子樹絕不會使它尚未拜訪的索引失效,並透過 RemappedDestinationCount 與 RemovedDanglingItemCount 回報它做了什麼
var
NewPageNumbers: array of Integer;
Report: TPdfOutlineValidationReport;
I: Integer;
begin
// 原始文件每一頁一個條目
SetLength(NewPageNumbers, OriginalPageCount);
for I := 0 to OriginalPageCount - 1 do
NewPageNumbers[I] := 0; // 0 == 此頁被丟棄
NewPageNumbers[0] := 1; // 舊第 1 頁 -> 新第 1 頁
NewPageNumbers[1] := 2;
NewPageNumbers[9] := 3; // 舊第 10 頁 -> 新第 3 頁
// True:刪除整個懸空子樹。False:保留項目,剝除其目標
if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
raise Exception.Create(Report.ErrorMessage);
WriteLn(Format('%d remapped, %d dangling items removed',
[Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;
DeleteDangling 旗標決定「對應到零的目的地」的政策,兩個分支都是刻意的。設為 True 時,PDFiumPas 刪除該項目及其整個子樹,因為目標消失的大綱節點,通常領著一個隨它一起消失的章節。設為 False 時,項目帶著完好的標題與階層存活,但 /Dest 與 /A 被移除——這正是您想要的結果:當有人將在審查中重新指定目標。真正畸形的輸入仍會大聲失敗,而不是被包紮起來:負數條目,或指向所給對映終點之外的目的地,會傳回 False,並把 IssueKind 設為 poviInvalidPageMap
不透明條目,以及誠實的取捨
不是每個大綱項目都有 PDFiumPas 能推理的頁碼。有三類會原樣帶過:具名目的地、不是 /S /GoTo 的動作,以及產生該檔案的東西加上的未知字典鍵。這些項目載入時 PageNumber 等於零,在項目中保留原始位元組,並逐字寫回,除非您明確對它們呼叫 Retarget
- 具名目的地是進入文件名稱樹的一把鑰匙,所以正確重新對應它意味著解析該樹並重寫目標條目,而不是在大綱層級猜測
/URI、/Launch或 JavaScript 動作完全沒有頁面語意,絕不能被悄悄轉換成 Go-To- 廠商專屬鍵與結構目的地會被保留,因為丟掉您不理解的東西,正是反覆存取遺失資料的方式
代價是真實的,值得直白說明:ApplyPageMap 會完全跳過那些項目,所以書籤全部使用具名目的地的文件,在經歷刪頁後,其大綱結構上有效、語意上過時。這是刻意的選擇——審查者抓得到的過時連結,勝過沒人注意、自信滿滿的錯誤連結。如果您在編輯前先對送來的檔案做分診,PDF 收件審查工作台中的清點步驟會告訴您哪些文件落進那個桶子
儲存:增量修訂,然後獨立重載
TPdfOutlineEditor.SaveIncremental 會追加一筆稀疏的增量修訂,而不是重寫檔案。載入的項目保留其原始間接物件參照,包括確切的世代,所以既有交叉參照維持有效;只有您新增的項目會抽取新編號,從該修訂最大物件編號再加一開始配置。目錄在同一修訂中更新;當來源完全沒有大綱時,會把缺失的 /Outlines 條目加進去
寫入之後發生的事,是值得抄走的部分。PDFiumPas 用一個完全獨立的編輯器重新開啟目的地串流,並把重載的樹與記憶體中的樹比對——項目數、標題、頁碼、目的地 suffix、動作式與直接式目的地的形態、樣式、展開狀態,以及父階關係。任何不一致,或任何載入失敗,都會清空目的地串流並傳回 poviVerificationFailure,而不是交給您一份看起來合理的檔案。加密來源會被當場拒絕,代碼為 poviEncryptedInput,因為新標題與目的地會建立字串內容,無法靠把 /Encrypt 尾頁物件向前抄錄而產生
if not Editor.SaveIncremental(Source, Dest, Report) then
case Report.IssueKind of
poviEncryptedInput:
Log('Source is encrypted; outline editing needs an unprotected copy');
poviInvalidDestination:
Log(Format('Item %d %d targets a missing page',
[Report.ObjectNumber, Report.Generation]));
poviVerificationFailure:
Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
else
Log(Report.ErrorMessage);
end;
把大綱當成它本來的樣子——一個帶著自身不變量的連結物件圖——刪頁就不再是書籤災難,而變成您交給一次方法呼叫的頁面對映。TPdfOutlineEditor、ApplyPageMap 與經驗證的增量寫入器,從 v3.98.0 起隨 PDFiumPas 出貨,適用於 Delphi、C++Builder 與 Lazarus;您可以在 PDFium Delphi 元件產品頁檢視完整 API 並下載試用版