技术文章

删除带标签 PDF 注释:OBJR 与 ParentTree 清理

用只管页面的编辑器从带标签的 PDF 里删掉一条 Link 注释,页面渲染一切正常,结构树里却还留着一个 OBJR、一条 ParentTree 条目和一个 Link 元素,全都指向一条早已消失的注释。PDFium Component 的 RemovePdfPageAnnotations 把注释从页面上撤下,并可在要求之下把这些结构残件一并移除

带标签的注释接在结构树的哪些位置

带标签的注释坐落在四个对象的交汇点上,删干净必须把这四个都走到。ISO 32000-1 §14.7.4.3 规定:当结构元素的内容是整个 PDF 对象(比如一条注释)时,元素用对象引用字典(表 325)在 /K 数组里指名它:/Type /OBJR、一个必填的间接引用 /Obj 指向该注释,以及一个可选的 /Pg 覆盖元素自身的页面。这说的是正向,从树到页面

Delphi 版 PDFium Component 中带标签 Link 注释如何接上 PDF 页面 Annots 数组、OBJR 对象引用、StructParent 键与 ParentTree 条目
带标签注释接进四个对象,删干净要走页面引用、OBJR、StructParent 键与 ParentTree 键值对

反向路径是 §14.7.4.4。父树是一种数字树(§7.9.7),每个身为内容项的对象在其中各有一条条目,键就是该对象携带的那个整数。注释携带 /StructParent,其父树值是单个指向所属元素的间接引用;内容流带标记内容的页面则携带 /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 中留下幽灵链接的两条路径,以及 Delphi 中 PDFium Component 如何清理
无论注释对象被释放还是留在原地,Link 元素都保有文字,屏幕阅读器仍可能播报一条哪儿也到不了的链接

删内容另有这个问题的一个变种。把 /P <</MCID 4>> BDC … EMC 里面的文本对象删掉而保留操作符,页面上就剩一个空的标记内容序列,其 MCR 还挂在一个 <P> 元素里;读者拿到的是一段没有字的段落。删掉 BDC 却留下 EMC,内容流就直接坏了。§14.6 要求每个标记内容序列在单一内容流里由 BDC 或 BMC 与配对的 EMC 括起来,一个孤儿 EMC 会让页面上其后的每个序列都配错开括号

用 RemovePdfPageAnnotations 移除注释

RemovePdfPageAnnotations(在 FPdfDocumentStructure 单元里)接收一个源流、一个按页面树顺序从零起算的页索引、待移除注释的对象号、一个 TPdfAnnotationRemovalOptions 记录,外加两个 out 参数:新文件与一份 TPdfAnnotationRemovalReport。使用 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 键并让 /ParentTreeNextKey 前进,规则与在父树中登记新 MCR 与 OBJR 条目相同。移除再重建该元素会丢掉这些值,还会给它一个新的对象号

删除带标签内容:归属从 BDC 算到配对的 EMC

注释不是页面对象,所以文本、路径和图像要走另一套 API:批对象编辑器,即 PlanStyledPdfObjectEdits 与 ExecuteStyledPdfObjectEdits(都在 FPdfStyledEdit 里),其 TPdfObjectEditOptions 携带含义相同的 RemoveOwnedStructurePeers 与 RemoveEmptiedStructureNodes。带标签的标记内容序列只有在其 BDC 与配对 EMC 之间的每个对象都待删时,才连着结构条目一起消失。那种情况下两个操作符都离开内容流,MCR 离开其元素,页面 ParentTree 数组中该 MCID 的槽位被置为 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 写回的文件里,页面、结构树和父树描述的就是同一组注释