PDFium Component 透過 FPdfDocumentStructure.pas 裡的 EditPdfDocumentStructure,從 Delphi 標記既有 PDF 內容。psekAddMarkedContent 與 psekAddObjectReference 兩個操作不只是往結構元素的 /K 追加 MCR 或 OBJR:它們還分配 /StructParents 或 /StructParent 鍵、寫出對應的 /ParentTree 條目——ISO 32000-1 §14.7.4.4 要求的反向索引
改裝標籤通常栽在後面這一半。加一個 BDC 包裝與一條 MCR 很容易,自頂向下走結構樹的檢視器也樂於找到新內容。但輔助技術常常反著走:從頁面上某塊內容出發,問哪個結構元素擁有它。頁面沒有 /StructParents 鍵,或 /ParentTree 裡沒有新 MCID 的條目,那次查找就失敗,檢查器回報缺失或不一致的父樹。檔案看上去有標籤,照樣不工作
/ParentTree 到底映射什麼?
/ParentTree 是一棵把整數鍵映射到結構元素的數字樹,鍵來自內容,不來自樹。ISO 32000-1 §14.7.4.4 給每個含標記內容的頁面或 Form XObject 一個 /StructParents 整數,該鍵底下的父樹值是一個按 MCID 索引的陣列:陣列第 3 項就是擁有 <</MCID 3>> 的結構元素。整體作為內容項使用的註解或 XObject 則拿到單個 /StructParent 鍵,其父樹值是結構元素本身,不是陣列
容器格式是 §7.9.7 的通用數字樹。葉節點放 /Nums,一個按鍵升冪排序的鍵值對平面陣列。根之下的中間與葉節點帶 /Limits,標出覆蓋的最小與最大鍵,根本身不得有 /Limits。在此之上,結構樹根可以帶 /ParentTreeNextKey(§14.7.2),即寫入器下一個該發出的鍵。因此,每個標記新內容的編輯最多要動四個必須彼此一致的物件:內容流、頁面或表單字典、父樹葉節點,以及結構樹根
正向方向有自己的規則,見 §14.7.4.2 與 §14.7.4.3。頁面自身內容流裡內容的標記內容參照(MCR)只需要 /Pg 與 /MCID。標記內容住在 Form XObject 裡時,MCR 還需要 /Stm 指名那條流,因為 MCID 按內容流各自編號。物件參照(OBJR)以 /Obj 指名註解或 XObject,以 /Pg 指名其頁面
用 EditPdfDocumentStructure 標記內容流
EditPdfDocumentStructure 是帶快照防護的批次編輯器。先呼叫 ReadPdfDocumentStructure 拿到 TPdfDocumentStructureSnapshot,再把它的 SourceSha256 連同一個 TPdfStructureEditOperation 記錄陣列傳回。來源位元組在中途變了,編輯被拒、不產輸出。既有元素以 NodeID 裡的物件編號定址,0 表示結構樹根
uses
System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentStructure;
function TagContentStream(const FileName, OutName: string;
ElementID, StreamObj, StreamGen: Integer): Boolean;
var
Source: TMemoryStream;
Options: TPdfStructureEditOptions;
Snapshot: TPdfDocumentStructureSnapshot;
Ops: TPdfStructureEditOperations;
Report: TPdfStructureEditReport;
Output: TBytes;
begin
Source := TMemoryStream.Create;
try
Source.LoadFromFile(FileName);
Options := TPdfStructureEditOptions.Default;
if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
raise Exception.Create(Report.ErrorMessage);
SetLength(Ops, 1);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekAddMarkedContent;
Ops[0].NodeID := ElementID; // 既有元素,按物件編號
Ops[0].TargetObjectNumber := StreamObj; // 頁面內容流或頁面 Form XObject
Ops[0].TargetGeneration := StreamGen;
Result := EditPdfDocumentStructure(Source, Snapshot.SourceSha256,
Ops, Options, Output, Report);
if not Result then
raise Exception.Create(Report.ErrorMessage);
TFile.WriteAllBytes(OutName, Output);
finally
Source.Free;
end;
end;
就這一個操作而言,EditPdfDocumentStructure 把目標流包進 /P <</MCID n>> BDC ... EMC,把 MCR 追加到元素的 /K,頁面(或表單)沒有 /StructParents 鍵就給它一個,並把元素 n 寫進該鍵的父樹陣列。結果以增量更新的形式寫在原始位元組之後,Report.CreatedMarkedContents 告訴您建了幾條 MCR。流裡已有平衡的 BDC/EMC 對時,只包裝最後一個頂層 EMC 之後未標記的尾段;否則整條流都包
新 MCID 與父樹鍵怎麼選?
新 MCID 是當前最大值加一,新父樹鍵是 Max(MaxKey + 1, ParentTreeNextKey)。MCID 方面,EditPdfDocumentStructure 取該頁(或該 Form XObject)已記錄 MCR 中的最大值,加上它在流自身屬性清單裡找到的每個 /MCID。兩邊都要數:結構樹可能丟了一條 MCR 而內容仍帶著標記,重用那個編號會讓兩個元素認領同一塊內容
鍵的邏輯遵循同樣的防禦思路。擁有者已有 /StructParents 值時,既有陣列在 MCID 索引處擴展,空缺以 null 補位,不分配新鍵。沒有時,元件掃描每個 /Nums 葉找最高鍵,與 /ParentTreeNextKey 比較,取較大者。低於已用鍵的過期 /ParentTreeNextKey、以及把鍵保留到最後條目之後的,都有處理,因為兩值中較大者必定空閒。分配之後,/ParentTreeNextKey 推進到新鍵加一
新鍵值對按升冪插進 /Nums,不是追加,因為數字樹查找依賴這個順序。葉帶 /Limits 時,邊界從改寫後的陣列重算。結構樹根根本沒有 /ParentTree 時——只寫正向樹的工具產出的檔案裡很常見——EditPdfDocumentStructure 會合成一個新的間接 << /Nums [] >> 物件,從 /StructTreeRoot 連上它,再插入第一個條目
Form XObjects、/StmOwn 與物件參照
Form XObject 內部的標記內容只在該表單恰好被一個頁面使用時受支援。EditPdfDocumentStructure 為表單寫的 MCR 帶 /Pg、指名表單流的 /Stm 與指名頁面的 /StmOwn,/StructParents 鍵放在表單字典上而不是頁面上。被三個頁面繪製的表單有三個都說得通的擁有者,任何單一 /StmOwn 對其中兩個都是錯的。元件以錯誤拒絕編輯,而不是猜
同一條唯一性規則也適用於 psekAddObjectReference。目標必須是恰好出現在一頁 /Annots 裡的註解,或恰好出現在一頁資源裡的 Form 或 Image XObject;其他一律拒收。OBJR 帶 /Obj 與 /Pg,目標拿到 /StructParent 鍵(已有則重用),父樹值是對元素的直接參照。這正是那個被視覺方式標記、卻從未接上樹的連結註解或插圖影像的常規修法
// 把未標記的影像包進新的 Figure 元素,然後以兩種方式綁定:
// 作為影像 XObject 的 OBJR,以及作為頁面上的標記內容
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1; // 臨時 ID,必須為負且唯一
Ops[0].ParentNodeID := SectID; // 既有 Sect 元素
Ops[0].Value := 'Figure'; // 標準角色或 RoleMap 條目
Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1; // 指向上面建立的容器
Ops[1].TargetObjectNumber := ImageObj;
Ops[1].TargetGeneration := 0;
Ops[2] := Default(TPdfStructureEditOperation);
Ops[2].Kind := psekAddMarkedContent;
Ops[2].NodeID := -1;
Ops[2].TargetObjectNumber := ContentObj;
Ops[2].TargetGeneration := 0;
if EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops, Options,
Output, Report) then
Writeln(Format('Figure is object %d, %d OBJR, %d MCR', [
Report.InsertedNodes[0].ObjectNumber,
Report.CreatedObjectReferences, Report.CreatedMarkedContents]));
負的 NodeID 值指名同一批次裡較早建立的容器,Report.InsertedNodes 把每個要求的 ID 映射到它拿到的物件編號。psekInsertContainer 接受標準的 PDF 1.7、PDF 2.0 與 PDF/UA 角色,如 P、H1 到 H6、Figure、Table 或 LI,外加已在 /RoleMap 裡映射的自訂角色。在兩個內容操作上設 BeforeNodeID,新的 MCR 或 OBJR 會插在某個既有子項之前而不是末尾
為什麼同一批次裡兩次編輯同一條流需要小心?
批次可能反覆撞上同一個物件,而每個後續操作都必須看到先前操作的結果。對同一頁的兩個 psekAddMarkedContent 操作都會改寫它的內容流,也都更新同一片父樹葉。把每次編輯暫存成獨立追加物件的實作,會在更新裡寫出物件 12 的兩個版本。讀取器留了哪個,您就丟了第一個 BDC 包裝或第一個 /Nums 鍵值對,而且第二個 MCID 可能與第一個撞號,因為它是按過期位元組算的
EditPdfDocumentStructure 以 (ObjectNumber, Generation) 為暫存更新作鍵、原地覆寫既有條目,同時重新整理該物件的記憶體副本。第二個操作因此解析的是已包裝的流,看到 MCID 0、分配 MCID 1、擴展同一個父樹陣列。一個物件,一個最終版本。返回任何東西之前,輸出會被讀回並與記憶體模型比對:元素數、內容項數、每條保留的 MCR 或 OBJR 都必須相符,否則呼叫失敗、不產輸出
壓縮流走同一條路。帶 /Filter /FlateDecode(或單元素 [/FlateDecode] 陣列)且無 /DecodeParms 預測器的內容流,會以有界解碼器解壓——上限 32 MiB 與 MaxOutputBytes 中較小者——包裝後重新壓回 /FlateDecode 並更新 /Length。會解壓超過上限的流被拒收而不是截斷,這對來路不明的檔案很重要
跑 PDF/UA 修復批次前該知道的限制
EditPdfDocumentStructure 的設計是保守的,多數限制以清楚的 Report.ErrorMessage 現身,而不是一份悄悄壞掉的檔案。規劃批次修復時記住這些:
- 只有未過濾與純
FlateDecode流能被包裝;其他 filter,或帶預測器的 Flate,一律拒收 - 內容流必須恰好屬於一頁的
/Contents,Form XObjects、註解與 XObjects 必須恰好被一頁使用 - 包裝一律用
/P標籤;語意角色來自結構元素的/S,不是BDC標籤名 - 父樹同步讀取平面
/Nums根或一層/Kids葉;更深的中間節點不會下行,這類檔案編輯後請重新驗證 - 含簽章或簽章欄位的檔案直接拒收,所以先修結構再簽章,順序別反
修復管線裡驗證一步不可省。批次前後各跑一次PDFium Component 的 PDF/UA 結構樹驗證,需要確認哪些頁面物件落進新標記時,用讀取 BDC 標籤與 MCID 的頁面物件標記內容 API。乾淨的父樹告訴您查找解析得通;它不保證閱讀順序與替代文字正確
FAQ
EditPdfDocumentStructure 會重建整棵 /ParentTree 嗎?
不會。EditPdfDocumentStructure 只更新本批次標記的內容對應的條目,重用既有 /StructParents 鍵、必要時分配新鍵,既有父樹條目保持不動
PDF 有結構樹卻沒有 /ParentTree 會怎樣?
元件建立一棵間接的 << /Nums [] >> 數字樹,從 /StructTreeRoot 連上,並為被標記的內容插入第一個鍵
可以標記被多頁共用的 Form XObject 裡的內容嗎?
這個操作不行。共用表單讓 /StmOwn 有歧義,所以 psekAddMarkedContent 要求表單恰好被一頁參照,否則回報錯誤
結構樹讀取與編輯、PDF/UA 驗證與標記內容存取都是PDFium Component for Delphi and C++Builder 的一部分,它為 Delphi、C++Builder 與 Lazarus 專案包裝 PDFium