技術文章

刪除 PDF 註解時一併清理 OBJR 結構項目

用只碰頁面層級的編輯器從有標籤的 PDF 裡刪掉一個 Link 註解,頁面照樣渲染得好好的,結構樹裡卻還留著一個 OBJR、一個 ParentTree 項目和一個 Link 元素,指向早已消失的註解。PDFium Component 的 RemovePdfPageAnnotations 會把註解從頁面上撤下,並可在要求之下連同這些結構片段一併移除

有標籤的註解接在結構樹的哪些位置

有標籤的註解坐落在四個物件的交會點上,乾淨的刪除必須逐一拜訪它們。ISO 32000-1 §14.7.4.3 規定:當結構元素的內容是一整個 PDF 物件(例如註解)時,元素用物件參照字典(表 325)在 /K 陣列中指名它:/Type /OBJR、一個指向該註解的必要間接參照 /Obj,以及一個會覆寫元素自身頁面的選用 /Pg。這涵蓋了從樹到頁面的正向方向

有標籤的 Link 註解如何連接到 PDF 頁面 Annots 陣列、OBJR 物件參照、StructParent 鍵與 ParentTree 項目(Delphi 版 PDFium Component)
有標籤的註解接進四個物件,乾淨的刪除要拜訪頁面參照、OBJR、StructParent 鍵與 ParentTree 配對

反向路徑是 §14.7.4.4。ParentTree 是一種數字樹(§7.9.7),每一個身為內容項目的物件都在其中有一個項目,以該物件攜帶的整數為鍵。註解攜帶 /StructParent,其 ParentTree 值是指向擁有它的元素的單一間接參照;內容串流含標記內容的頁面則改攜帶 /StructParents,其值是以 MCID 為索引的陣列。表 326 允許任一物件至多擁有這兩個鍵之一,因為物件要嘛整個就是內容項目,要嘛是標記內容的容器。對超連結而言,§14.8.4.4.2 讓 Link 元素得以同時擁有可見文字(以標記內容參照 MCR 的形式)與指向連結註解的 OBJR。把註解刪掉,待清理的清單如下:

位置內容物乾淨的移除會做什麼
頁面的 /Annots指向註解的間接參照移除參照;其餘項目保持原順序
Link 元素的 /K帶 /Obj 與 /Pg 的 OBJR移除 OBJR 內容項目
註解字典/StructParent 整數鍵移除該鍵,因為物件不再是內容項目
ParentTree 的 /Nums 葉節點鍵與指向 Link 元素的參照成對移除這一組鍵值
結構樹根/ParentTreeNextKey不予更動

幽靈連結如何比它的註解活得更久?

頁面層級的編輯器刪除註解的方式是把它從 /Annots 裡拿出來,接下來有兩種走向。如果工具連註解物件也一併釋放,OBJR 的 /Obj 從此指向一個未定義物件,而 §7.3.10 告訴閱讀器把它當成 null:Link 元素擁有一個解析不到任何東西的內容項目。如果工具把物件留在檔案裡,OBJR 仍能解析,但它的 /Pg 所指名的那一頁,其 /Annots 已不再列出它。兩種情況下 Link 元素都保留著標記內容子項,所以「Download the datasheet」這幾個字仍被標記為連結,沿閱讀順序走訪樹的螢幕閱讀器可能報讀出一個頁面上什麼都啟動不了的連結。PAC、veraPDF 這類依 ISO 14289-1 驗證的 PDF/UA 檢查器會比較頁面所列與樹所宣稱的內容,即使渲染出來的頁面看起來完美無瑕,兩者的不一致還是可能在那裡浮上檯面

只刪頁面之後,在有標籤 PDF 裡留下幽靈連結的兩條路徑,以及 PDFium Component 在 Delphi 裡如何清理
無論註解物件被釋放還是留下,link 元素都保有自己的文字,螢幕閱讀器可能報讀出一條哪裡也去不了的連結

刪除內容也有這個問題的自己的版本。把 /P <</MCID 4>> BDC … EMC 裡的文字物件移除而保留運算子,頁面上就留下一個空的標記內容序列,其 MCR 仍坐在 <P> 元素裡;閱讀器拿到的是一段沒有文字的段落。移除 BDC 卻留下它的 EMC,內容串流就此整個壞掉。§14.6 要求每一個標記內容序列都由 BDC 或 BMC 與配對的 EMC 在單一內容串流內括起來,一個孤兒 EMC 會讓頁面上每個後續序列都配錯開頭

用 RemovePdfPageAnnotations 移除註解

FPdfDocumentStructure 裡的 RemovePdfPageAnnotations 接受一個來源串流、一個依頁面樹順序、以 0 為起點的頁面索引、待移除註解的物件編號、一個 TPdfAnnotationRemovalOptions record,以及兩個分別對應新檔案與 TPdfAnnotationRemovalReport 的 out 參數。使用 TPdfAnnotationRemovalOptions.Default 時它只動頁面:一個增量修訂版承載頁面字典與縮短後的 /Annots 陣列(最後一個註解消失後連 /Annots 鍵都不留),加上原封不動的 catalog,物件編號、文件 ID 與其餘每個頁面鍵全部保住。註解物件保留自身內容,手上還握著它的編號的呼叫端仍然讀得到它。結構樹維持原樣,這讓標記保持完整,適合您正準備把那個 Link 元素指向替代註解的情況。任何東西寫入之前,輸入會先經過檢查:

  • 每個編號都必須出現在所選頁面的 /Annots 裡,且指名一個 /Type /Annot 物件;同一呼叫中列出兩次的編號會被拒絕
  • widget 註解會被拒絕,因為 widget 屬於它的表單欄位,把它從頁面上拔走會留下一個其 widget 無頁顯示的欄位
  • 加密的來源會被退回,因為結構編輯需要解密後的位元組;請預先解密檔案
  • MaxOutputBytes、MaxWork 與 CancellationToken 為工作設下界線,任何失敗都傳回 False、不產生輸出,並以 ErrorMessage 指名中斷的階段

對有標籤的文件而言,結構樹早就告訴您哪些註解編號要緊。ReadPdfDocumentStructure 快照裡的每個 OBJR 項目都帶著被參照的物件編號,其頁面編號取自 OBJR 自己的 /Pg,取不到時退而取元素自己的。下面的常式收集某一頁上 Link 元素參照到的每個註解,並在兩個清理選項全開的情況下移除它們。頁面過濾要保留:取自其他頁面的編號會讓整批被拒

uses
  System.SysUtils, System.Classes, System.IOUtils,
  FPdfCompress, FPdfDocumentStructure;

function RemoveTaggedLinks(const InName, OutName: string;
  PageIndex: Integer): TPdfAnnotationRemovalReport;
var
  Source: TMemoryStream;
  Pages: TPdfSparsePageDictionaries;
  ReadOptions: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  ReadReport: TPdfStructureEditReport;
  Item: TPdfDocumentStructureContentItem;
  Targets: array of Integer;
  Options: TPdfAnnotationRemovalOptions;
  Report: TPdfAnnotationRemovalReport;
  Output: TBytes;
  N, I, J: Integer;
  Known: Boolean;
begin
  Report := Default(TPdfAnnotationRemovalReport);
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(InName);
    if not ReadPdfSparsePageDictionaries(Source, Pages) or
       (PageIndex < 0) or (PageIndex > High(Pages)) then
      raise Exception.Create('Page index is outside the page tree');
    ReadOptions := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, ReadOptions, Snapshot, ReadReport) then
      raise Exception.Create(ReadReport.ErrorMessage);

    // 本頁上 Link 元素指向的每一個註解
    for N := 0 to High(Snapshot.Nodes) do
      if Snapshot.Nodes[N].RoleName = 'Link' then
        for I := 0 to High(Snapshot.Nodes[N].ContentItems) do
        begin
          Item := Snapshot.Nodes[N].ContentItems[I];
          if (Item.Kind <> psckObjectReference) or
             (Item.PageObjectNumber <> Pages[PageIndex].ObjectNumber) then
            Continue;
          Known := False;
          for J := 0 to High(Targets) do
            if Targets[J] = Item.ReferencedObjectNumber then Known := True;
          if not Known then
          begin
            SetLength(Targets, Length(Targets) + 1);
            Targets[High(Targets)] := Item.ReferencedObjectNumber;
          end;
        end;
    if Length(Targets) = 0 then
      Exit(Report);

    Options := TPdfAnnotationRemovalOptions.Default;
    Options.RemoveOwnedStructurePeers := True;    // OBJR 項目與 ParentTree 項目
    Options.RemoveEmptiedStructureNodes := True;  // 內部已空無一物的元素
    if not RemovePdfPageAnnotations(Source, PageIndex, Targets, Options,
      Output, Report) then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
    Result := Report;
  finally
    Source.Free;
  end;
end;

ParentTree 的鍵會怎樣

設下 RemoveOwnedStructurePeers 之後,凡是 /Obj 指向該頁被移除註解的 OBJR,都會透過結構編輯器離開其元素,反向索引因此保持同步。存在註解 /StructParent 之下的鍵值對會從所屬的 /Nums 葉節點中取出,而 /StructParent 鍵本身也從註解字典裡移除,因為表 326 只要求身為結構內容項目的物件帶著它。/ParentTreeNextKey 不會下調。§14.7.4.4 只要求它大於每一個使用中的鍵,移除動作破壞不了這一點,而且數字樹的鍵不必連續,所以遵循 /ParentTreeNextKey 的寫入端永遠不會把釋出的鍵再發出去。每個存活的註解都保有自己的物件編號、在 /Annots 裡的位置與它的 ParentTree 鍵;函式會重讀它寫出的頁面,只要有一個倖存者消失或換位就拒絕結果。預期結果:在一個只有一條有標籤連結的頁面上,Report.RemovedAnnotations 與 Report.RemovedObjectReferences 都是 1,Report.SourceSha256 與 Report.OutputSha256 則記下哪些位元組進去、哪些出來

清空的元素何時也該走?

RemoveEmptiedStructureNodes 移除的是 OBJR 移除之後不再擁有任何內容項目與子元素的元素,由最深處先走。它分輪執行:每一輪重讀結構、移除此刻已空的元素,並把它們的父元素加入下一輪候選,於是一整串包裝元素由下而上塌縮,結構樹根則始終留存。為便籤做標記的 Annot 元素通常除了 OBJR 什麼都不裝(§14.8.4.4.3 允許這樣),所以它會清空並離場。Link 元素不同。它仍擁有可見文字的標記內容參照,所以不算空、也會存活,成為一個沒有註解的 Link。RemovePdfPageAnnotations 不會重新命名它,因為那些字該不該繼續當連結,是編輯取捨問題而非結構問題。純粹的刪除場景,把兩個選項都打開,再把剩下的無 OBJR Link 元素降級為 Span:輔助技術從此把那些字當純文字讀出,不再端出一條哪裡也去不了的連結

清空結構元素的決策流程:Annot 元素逐輪塌縮,Link 元素存活,PDFium Component 可將它降級為 Span
清空的包裝元素由最深處先塌縮而樹根留存,失去 OBJR 的 Link 可降級為 Span,或留給替代註解
function DemoteUnlinkedLinks(const Data: TBytes; out Fixed: TBytes): Integer;
var
  Source: TBytesStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  N: Integer;
begin
  Result := 0;
  Fixed := Data;
  Source := TBytesStream.Create(Data);
  try
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);
    for N := 0 to High(Snapshot.Nodes) do
      if (Snapshot.Nodes[N].RoleName = 'Link') and
         (Snapshot.Nodes[N].ObjectReferenceCount = 0) and
         (Length(Snapshot.Nodes[N].ContentItems) > 0) then
      begin
        SetLength(Ops, Length(Ops) + 1);
        Ops[High(Ops)] := Default(TPdfStructureEditOperation);
        Ops[High(Ops)].Kind := psekSetRole;
        Ops[High(Ops)].NodeID := Snapshot.Nodes[N].NodeID;
        Ops[High(Ops)].Value := 'Span';
      end;
    if Length(Ops) = 0 then Exit;
    // 快照摘要防範位元組在中途被改動
    if not EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops,
      Options, Fixed, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Result := Length(Ops);
  finally
    Source.Free;
  end;
end;

當刪除只是替代方案的前半段時,請保持預設值。留在原地的清空元素仍帶著它的角色、標題、替代文字與元素 ID;若後續步驟用 psekAddObjectReference 操作把新註解接到它身上——該操作會給註解一個新的 /StructParent 鍵,並依在 ParentTree 註冊新 MCR 與 OBJR 項目的同一套規則推進 /ParentTreeNextKey——元素便保住它在閱讀順序中的位置。移除再重建那個元素會丟掉這些值,還會給它一個新的物件編號

刪除有標籤的內容:歸屬範圍從 BDC 延伸到它的 EMC

註解不是頁面物件,所以文字、路徑與影像要走另一個 API:批次物件編輯器,也就是 FPdfStyledEdit 裡的 PlanStyledPdfObjectEdits 與 ExecuteStyledPdfObjectEdits,其 TPdfObjectEditOptions 帶著意義相同的 RemoveOwnedStructurePeers 與 RemoveEmptiedStructureNodes。有標籤的標記內容序列只有在其 BDC 與配對 EMC 之間的每個物件都被刪除時,才會連同它的結構項目一起消失。那種情況下兩個運算子離開內容串流、MCR 離開它的元素,而該 MCID 在頁面 ParentTree 陣列中的槽位被設為 null 而不是收攏,因為頁面上其他每個 MCID 都索引進同一個陣列。同時包著存活內容的序列則留下,連同它的 MCR。把被移除的 MCID 重複用在存活內容上的頁面會被直接拒絕,因為 MCID 的範圍限於頁面的內容串流,丟掉結構項目會讓存活者變孤兒。選項維持 False 時,批次改為在移除邊界保留 BDC 與 EMC 配對,結構樹照常解析,樹裡什麼都不變

歸屬範圍必須從 BDC 一路量到配對 EMC 的結尾。我們的第一個版本只按 BDC 運算子自身的位元組範圍來量——那正是解析後的標記記錄位置的地方——結果一個被包住的物件都沒找到。相關的另一個錯誤是把 BDC 標記為待移除卻弄丟了它的夥伴,寫出來的正是前述那種失衡串流,所以編輯器總是把配對的兩半一起移除。下面的常式刪除帶著單一 MCID 的每個頂層物件。ExecuteStyledPdfObjectEdits 會用結果重載 Pdf,除非設了 EditOptions.PreviewOnly;Report.RemovedStructurePeers 與 Report.RemovedStructureNodes 則計數離開樹的東西;函式傳回該報告

uses
  System.SysUtils, PDFium, FPdfStyledEdit;

function DeleteTaggedRun(Pdf: TPdf; PageIndex, Mcid: Integer): TPdfObjectEditReport;
var
  Ops: TPdfObjectEditOperations;
  NoSemanticEdits: TPdfObjectSemanticEdits;
  Options: TPdfObjectEditOptions;
  Plan: TPdfObjectEditPlan;
  Preview: TBytes;
  Report: TPdfObjectEditReport;
  I: Integer;
begin
  Pdf.PageNumber := PageIndex + 1;          // PageNumber 從 1 起算
  for I := 0 to Pdf.ObjectCount - 1 do
    if Pdf.PageObjectMarkedContentID(I) = Mcid then
    begin
      SetLength(Ops, Length(Ops) + 1);
      Ops[High(Ops)] := Default(TPdfObjectEditOperation);
      Ops[High(Ops)].Kind := poekRemove;
      Ops[High(Ops)].ObjectIndex := I;
    end;
  if Length(Ops) = 0 then
    raise Exception.CreateFmt('No object on page %d carries MCID %d',
      [PageIndex + 1, Mcid]);
  NoSemanticEdits := nil;
  Options := TPdfObjectEditOptions.Default;
  Options.RemoveOwnedStructurePeers := True;
  Options.RemoveEmptiedStructureNodes := True;
  Plan := PlanStyledPdfObjectEdits(Pdf, PageIndex, Ops, NoSemanticEdits, Options);
  if not ExecuteStyledPdfObjectEdits(Pdf, Plan, Options, Preview, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Result := Report;
end;

兩種呼叫之後,被移除的註解仍在檔案裡佔著它的物件,因為被撤回的只是頁面參照。OBJR 一旦離場,catalog 圖裡就沒有任何東西可達它,所以 TPdf.SaveAsReachable 能把那個孤立物件從重寫的副本中掃出去;用預設選項時,OBJR 讓註解保持可達,清掃也會留下它。只要刪除是最終決定,就讓兩個結構選項都開著執行移除,PDFium Component for Delphi 寫回的檔案裡,頁面、結構樹與 ParentTree 描述的便是同一組註解