技術文章

PDFium Component 中的 PDF 名稱樹與編號樹編輯

手動在葉陣列上補一個名字,該項目讀起來仍可能是缺失的。PDFium Component 用 TPdfTreeEditor 回答這個問題——一個通用的 ISO 32000 名稱樹與編號樹編輯器:以嚴格驗證載入樹狀結構、在記憶體中編輯項目、以正確的 /Limits 重建平衡節點集,然後寫出驗證過的增量修訂

促成這個單元的失敗案例,是那種只能靠讀位元組才能診斷的問題。某個工具在葉節點的 /Names 陣列後方附加一組鍵值、寫出一個新修訂,每個 hex dump 都證明鍵確實存在檔案裡,Acrobat 卻仍說附件不存在。以天真的標準看,沒有什麼東西是壞的,樹只是不再是一棵查詢演算法能下降搜尋的樹

為什麼補丁過的葉陣列會破壞查詢?

因為查詢是沿 /Limits 做的二元下降,不是掃描葉節點。ISO 32000-1 §7.9.6 把名稱樹定義成:一個根,/Kids 往下通往持有 /Names 的葉節點,/Names 是鍵與值交錯排列的陣列,整棵樹的鍵依位元組比較排序,每個非根節點都用 /Limits 宣告自己的最小與最大鍵。符合規範的閱讀器拿要找的鍵去比對每個子節點範圍,只會打開恰好一個子節點。往葉節點追加鍵卻不動舊的 /Limits,下降搜尋就會直接略過真正持有它的節點

PDFium Component 的 PDF 名稱樹查詢示意圖:閱讀器依位元組比較在子節點 /Limits 範圍間二元下降,因此像 invoice-2026-09 這種追加到葉節點、而葉節點過期的 /Limits 仍止於 (f) 的鍵永遠不會被打開,下降會改走 (g) (z) 子節點,附件明明在檔案裡卻讀成缺失
查詢拿要找的鍵比對每個子節點範圍,只打開一個子節點,因此葉節點加了鍵卻沒更新 /Limits,下降搜尋會直接略過

編號樹(§7.9.7)形狀相同,只是用 /Nums 與整數鍵,而且承載的是實際流量:§12.4.2 的 /PageLabels 就是一棵編號樹,§7.7.4 的目錄 /Names 分類則給你 /Dests、/EmbeddedFiles 與 /JavaScript 這幾棵名稱樹。所以四個不變量必須同時成立,不是逐一成立。鍵在樹序上必須嚴格遞增,每一層發布的 limits 必須與實際鍵範圍一致,兄弟節點範圍不能重疊,根物件的編號與世代必須原封不動——因為文件目錄和任何其他參照方都是靠間接參照指向那個根。我們關於在 Delphi 中處理 PDF 附件的文章展示了同一結構的消費端

TPdfTreeEditor 拒絕載入什麼?

拒絕的比您想像的多,而且是故意的。TPdfTreeEditor.TryLoad 透過 TPdfSparseDictionaryReader 走訪節點圖,用 ReadDictionary 依參照所指定的精確世代解析每個子節點,而不是接受當前恰好啟用的任何修訂。無法證明結構良好的樹,在允許任何編輯之前就被拒絕,這讓壞的輸入不會變成看起來合理的壞輸出

  • 迴圈與重複參照,兩者都回報為 ptviCycle,所以指回鏈路上方的 /Kids 項目會終止而不會遞迴
  • 混合節點形狀:一個字典同時帶 /Kids 與 /Names 或 /Nums 就是 ptviInvalidNodeShape,空的非根葉節點也一樣
  • 未排序或重複的鍵,回報為 ptviUnsortedKey 與 ptviDuplicateKey,檢查範圍是整個遍歷而非單一葉節點內部
  • 與實際存在的鍵不符的 limits,回報為 ptviInvalidLimits
  • 不是恰好一個完整 PDF 物件的原始值,回報為 ptviInvalidValue
  • 預算超限:ptviDepthLimit、ptviNodeLimit、ptviEntryLimit 與 ptviDeadlineExceeded,同時受 TPdfTreeEditOptions 與中央剖析器預算限制

TPdfTreeEditOptions.Default 把這些樹內上限設為 1,000,000 個項目、100,000 個節點、深度 64、每葉 64 個項目、每節點 32 個子節點。葉與子節點的數字不只是防護,它們也是日後重建樹時使用的 fan-out,這就是為什麼這個 record 會驗證兩者至少為 2

uses
  FPdfCompress;

var
  Options: TPdfTreeEditOptions;
  RootRef: TPdfTreeObjectReference;
  Editor: TPdfTreeEditor;
  Report: TPdfTreeValidationReport;
begin
  Options := TPdfTreeEditOptions.Default;
  RootRef.ObjectNumber := EmbeddedFilesRootObject;   { 文件目錄的 /Names /EmbeddedFiles }
  RootRef.Generation := EmbeddedFilesRootGeneration;
  if not TPdfTreeEditor.TryLoad(Source, ptkName, RootRef, Options,
    Editor, Report) then
  begin
    Writeln('tree rejected: ', Report.ErrorMessage);
    Exit;
  end;
  try
    { 值是完整一個 PDF 物件的原始位元組 }
    Editor.UpsertName('invoice-2026-09',
      '<< /Type /Filespec /F (invoice.pdf) /EF << /F 42 0 R >> >>');
    Editor.RemoveName('draft-2025-11');
  finally
    { 下面的儲存完成後再 Editor.Free }
  end;
end;

項目值保持為原始 PDF 物件位元組

項目值以原樣儲存,而這正是讓編輯器可以安全指向您沒寫過的檔案的唯一設計決策。TPdfTreeEntry 持有 NameKey、NumberKey 與 RawValue,RawValue 是完整一個 PDF 物件的原始位元組串:字典、陣列、字串、串流參照或一個裸的間接參照。沒有任何東西被解碼成 Delphi variant、TStrings 或型別化 record。回報是單元完全不認識的值也能無損來回往返。帶廠商擴充的 /Filespec、fit 型別不尋常的目的地陣列、帶私有鍵的 /JavaScript action 字典,全部逐位元組通過重建存活,因為編輯器對它們的內容從未有過意見。它也把修改 API 縮到可以證明的地步:UpsertName 與 UpsertNumber 只需要驗證提供的值能被剖析成恰好一個完整物件,RemoveName、RemoveNumber 與 Clear 只碰項目清單

不重新編號根物件,平衡樹怎麼重建?

兩趟掃描,一個固定點。BuildBalancedObjects 先把排序後的項目清單切成最多 MaxLeafEntries 的葉節點,再把每一層折疊成最多 MaxKidsPerNode 的父節點,重複直到只剩單一節點。Limits 由下往上算:葉節點取自己的第一個與最後一個鍵,內部節點取第一個子節點的下限與最後一個子節點的上限,因此兄弟節點範圍重疊在構造上就不可能發生

固定點是身分。最終的根永遠拿到樹載入時的物件編號與世代,文件目錄的參照因此保持有效,文件裡其他東西都不需要重寫。其他每個節點只有在節點種類相符、完整鍵範圍也相符時,才重複使用先前載入節點的參照,否則就從「目前最大物件編號加一」分配一個新編號。這個組合讓沒變動的子樹繼續指向既有的物件,同時保證內容移動過的節點會被寫成新物件,而不是被默默重新詮釋。同一套參照重用紀律,也驅動了姊妹單元裡的大綱編輯器及其頁面重新對應

PDFium Component 中 TPdfTreeEditor 重建的示意圖:BuildBalancedObjects 把排序後的項目清單切成最多 MaxLeafEntries 個鍵的葉節點,每一層折疊成最多 MaxKidsPerNode 的父節點,由第一個與最後一個鍵由下往上計算 /Limits,成品根拿到樹載入時精確的物件編號與世代
葉節點取第一個與最後一個鍵做 limits,內部節點取最外側的子節點,最終根維持樹載入時的物件編號與世代
var
  Serialized: TPdfTreeSerializedObjects;
  I: Integer;
begin
  { FirstNewObjectNumber 來自來源修訂 }
  if Editor.BuildBalancedObjects(MaximumObjectNumber + 1,
    Serialized, Report) then
  begin
    Writeln('nodes ', Report.NodeCount, ' depth ', Report.MaximumDepth);
    for I := 0 to High(Serialized) do
      Writeln(Serialized[I].Reference.ObjectNumber, ' ',
        Serialized[I].Reference.Generation, ' obj');
  end
  else
    for I := 0 to High(Report.Issues) do
      Writeln('issue ', Ord(Report.Issues[I].Kind), ' at obj ',
        Report.Issues[I].ObjectNumber, ': ', Report.Issues[I].Message);
end;

寫入修訂後,再把結果讀回來

輸出重新打開並比對過,儲存才算完成。SaveIncremental 讀取來源修訂資訊,拒絕以文件目錄本身當根,序列化平衡節點集與目錄物件,把所有東西交給 AppendPdfSparseUpdate,它依 §7.5.6 追加一個新修訂,同時保留既有的 /Info 參照與文件 /ID 陣列

接下來這部分值得抄進您自己的寫入端。編輯器再次呼叫 TPdfTreeEditor.TryLoad,這次對的是目的端串流,用同樣的根參照與同樣的選項,把重新載入的項目數、每個鍵與 RawValue 跟原本打算寫入的內容比對。重新載入失敗或任何比對不一致時,目的端串流會被重設而不會被回傳,因此半寫的修訂永遠不會被當成成功交還。驗證是透過同一條有界稀疏路徑讀取輸出,也就是我們在稀疏延遲載入的 PDF 物件索引文章裡描述的機制,這讓大型檔案上的檢查依然負擔得起

PDFium Component 驗證式增量儲存的示意圖:SaveIncremental 序列化平衡節點集,AppendPdfSparseUpdate 追加新修訂並保留 /Info 與文件 /ID 陣列,然後 TPdfTreeEditor.TryLoad 重新讀取目的端串流,比對項目數與每個鍵和 RawValue,不一致就重設串流,而不是回傳半寫的修訂
輸出透過同一條有界路徑重新打開、逐項比對後,儲存才算完成;比對失敗就重設目的端串流
var
  Dest: TFileStream;
begin
  Dest := TFileStream.Create(OutputName, fmCreate);
  try
    if Editor.SaveIncremental(Source, Dest, Report) then
      Writeln('wrote ', Report.NodeCount, ' nodes, ',
        Report.EntryCount, ' entries')
    else
      { 目的端已重設,半寫的內容不會被保留 }
      Writeln('save refused: ', Report.ErrorMessage);
  finally
    Dest.Free;
    Editor.Free;
  end;
end;

加密來源直接拒絕,這才是誠實的做法

目前實作會直接拒絕加密來源。理由是具體的,不是防衛性的:重建樹會引入新的字串物件,也可能引入新的串流參照,而 §7.6 要求這些物件用文件金鑰與所屬物件編號、世代逐一加密。把 /Encrypt trailer 照抄到前面、卻追加明文字串,會產生一份「打開沒問題、讀到您的新鍵處變成垃圾」的檔案,這比明確拒絕更糟。還有兩個邊界值得直說。編輯器是在來源串流的快照上運作,所以編輯器存活期間來源不得被修改;它也不把文件目錄當樹根處理,因為目錄只是碰巧含有樹的字典,本身不是樹節點。如果您維護的 Delphi、C++Builder 或 Lazarus 程式碼會寫 /EmbeddedFiles、/Dests、/JavaScript 或 /PageLabels,而且您一直在手動補丁葉陣列,這棵樹編輯器與它的驗證式增量儲存就包含在 PDFiumPas Delphi PDFium 元件裡