手動在葉陣列上補一個名字,該項目讀起來仍可能是缺失的。PDFium Component 用 TPdfTreeEditor 回答這個問題——一個通用的 ISO 32000 名稱樹與編號樹編輯器:以嚴格驗證載入樹狀結構、在記憶體中編輯項目、以正確的 /Limits 重建平衡節點集,然後寫出驗證過的增量修訂
促成這個單元的失敗案例,是那種只能靠讀位元組才能診斷的問題。某個工具在葉節點的 /Names 陣列後方附加一組鍵值、寫出一個新修訂,每個 hex dump 都證明鍵確實存在檔案裡,Acrobat 卻仍說附件不存在。以天真的標準看,沒有什麼東西是壞的,樹只是不再是一棵查詢演算法能下降搜尋的樹
為什麼補丁過的葉陣列會破壞查詢?
因為查詢是沿 /Limits 做的二元下降,不是掃描葉節點。ISO 32000-1 §7.9.6 把名稱樹定義成:一個根,/Kids 往下通往持有 /Names 的葉節點,/Names 是鍵與值交錯排列的陣列,整棵樹的鍵依位元組比較排序,每個非根節點都用 /Limits 宣告自己的最小與最大鍵。符合規範的閱讀器拿要找的鍵去比對每個子節點範圍,只會打開恰好一個子節點。往葉節點追加鍵卻不動舊的 /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 由下往上算:葉節點取自己的第一個與最後一個鍵,內部節點取第一個子節點的下限與最後一個子節點的上限,因此兄弟節點範圍重疊在構造上就不可能發生
固定點是身分。最終的根永遠拿到樹載入時的物件編號與世代,文件目錄的參照因此保持有效,文件裡其他東西都不需要重寫。其他每個節點只有在節點種類相符、完整鍵範圍也相符時,才重複使用先前載入節點的參照,否則就從「目前最大物件編號加一」分配一個新編號。這個組合讓沒變動的子樹繼續指向既有的物件,同時保證內容移動過的節點會被寫成新物件,而不是被默默重新詮釋。同一套參照重用紀律,也驅動了姊妹單元裡的大綱編輯器及其頁面重新對應
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 物件索引文章裡描述的機制,這讓大型檔案上的檢查依然負擔得起
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 元件裡