Artigo Técnico

Excluindo Anotações de PDF Marcado: OBJR e ParentTree

Remova uma anotação Link de um PDF marcado com um editor que só mexe na página, e a página renderiza bem enquanto a árvore de estrutura ainda guarda um OBJR, uma entrada ParentTree e um elemento Link para uma anotação que já não existe. O RemovePdfPageAnnotations do PDFium Component retira a anotação da página e, se você pedir, remove junto essas peças de estrutura

Onde uma anotação marcada se conecta à árvore de estrutura

Uma anotação marcada fica na junção de quatro objetos, e uma exclusão limpa precisa passar por todos eles. A ISO 32000-1 §14.7.4.3 diz que, quando o conteúdo de um elemento de estrutura é um objeto PDF inteiro, como uma anotação, o array /K do elemento o nomeia com um dicionário de referência de objeto (Tabela 325): /Type /OBJR, um /Obj indireto obrigatório apontando para a anotação e um /Pg opcional que sobrepõe a própria página do elemento. Isso cobre a direção para frente, da árvore para a página

Como uma anotação Link marcada se conecta ao array Annots da página PDF, à referência de objeto OBJR, à chave StructParent e à entrada ParentTree no PDFium Component para Delphi
uma anotação marcada se conecta a quatro objetos, e uma exclusão limpa passa pela referência da página, pelo OBJR, pela chave StructParent e pelo par ParentTree

O caminho de volta é o §14.7.4.4. A parent tree é uma number tree (§7.9.7) com uma entrada para todo objeto que é um item de conteúdo, chaveada pelo inteiro que o objeto carrega. Uma anotação carrega /StructParent, e o valor dela na parent tree é uma única referência indireta ao elemento dono; uma página cujo content stream tem conteúdo marcado carrega /StructParents em vez disso, e o valor dela é um array indexado por MCID. A Tabela 326 permite no máximo uma das duas chaves em qualquer objeto, porque um objeto ou é um item de conteúdo por inteiro ou é um contêiner de conteúdo marcado. Para hyperlinks, o §14.8.4.4.2 deixa um elemento Link guardar tanto as palavras visíveis, como referências de conteúdo marcado (MCRs), quanto um OBJR para a anotação de link. Remova a anotação e este é o inventário:

LocalO que guardaO que uma remoção limpa faz
Página /AnnotsReferência indireta à anotaçãoDescarta a referência; as sobreviventes mantêm a ordem
Elemento Link /KOBJR com /Obj e /PgRemove o item de conteúdo OBJR
Dicionário da anotaçãochave inteira /StructParentRemove a chave, já que o objeto deixa de ser um item de conteúdo
Folha /Nums da ParentTreeChave pareada com uma referência ao elemento LinkRemove o par chave e valor
Raiz da árvore de estrutura/ParentTreeNextKeyNão mexe

Como um link fantasma sobrevive à própria anotação?

Um editor de nível de página apaga uma anotação tirando-a do /Annots, e a partir daí o destino é um de dois. Se a ferramenta também libera o objeto da anotação, o /Obj do OBJR agora aponta para um objeto indefinido, que o §7.3.10 manda o leitor tratar como null: o elemento Link fica dono de um item de conteúdo que não resolve para nada. Se a ferramenta deixa o objeto no arquivo, o OBJR ainda resolve, mas o /Pg dele nomeia uma página cujo /Annots não o lista mais. Nos dois casos o elemento Link mantém os filhos de conteúdo marcado, então as palavras "Download the datasheet" seguem marcadas como link, e um leitor de tela que percorre a árvore na ordem de leitura pode anunciar um link que nada na página vai ativar. Verificadores PDF/UA como PAC e veraPDF, que validam contra a ISO 14289-1, comparam o que a página lista com o que a árvore afirma, então uma divergência entre os dois pode aparecer ali mesmo quando a página renderizada parece perfeita

Os dois caminhos que deixam um link fantasma em um PDF marcado depois de uma exclusão só de página, e como o PDFium Component os limpa em Delphi
se o objeto da anotação é liberado ou deixado para trás, o elemento Link mantém as palavras e leitores de tela podem anunciar um link que não leva a nada

Apagar conteúdo tem a própria versão desse problema. Remova os objetos de texto dentro de /P <</MCID 4>> BDC … EMC e mantenha os operadores, e a página fica com uma sequência de conteúdo marcado vazia cujo MCR ainda está em um elemento <P>; o leitor recebe um parágrafo sem palavras. Remova o BDC mas não o EMC dele e o content stream quebra de vez. O §14.6 exige que toda sequência de conteúdo marcado venha delimitada por um BDC ou um BMC e um EMC correspondente dentro de um único content stream, e um EMC órfão deixa cada sequência posterior da página pareada com o abridor errado

Removendo anotações com RemovePdfPageAnnotations

O RemovePdfPageAnnotations na FPdfDocumentStructure recebe um stream de origem, um índice de página zero-based na ordem da page tree, os números de objeto das anotações a remover, um record TPdfAnnotationRemovalOptions e dois parâmetros out para o novo arquivo e um TPdfAnnotationRemovalReport. Com TPdfAnnotationRemovalOptions.Default ele mexe só na página: uma única revisão incremental leva o dicionário da página com o array /Annots encurtado (ou sem chave /Annots quando a última anotação sai) mais o catálogo intocado, então números de objeto, o document ID e todas as outras chaves da página sobrevivem. O objeto da anotação mantém o corpo, e um chamador que ainda guarda o número dele pode lê-lo. A árvore de estrutura fica como estava, o que mantém a marcação intacta para o caso de você estar prestes a apontar esse elemento Link para uma anotação substituta. A entrada é checada antes de qualquer coisa ser escrita:

  • Cada número precisa aparecer no /Annots da página selecionada e nomear um objeto /Type /Annot; um número listado duas vezes na mesma chamada é recusado
  • Anotações Widget são recusadas, porque um widget pertence ao seu campo de formulário e tirá-lo da página deixaria um campo cujo widget nenhuma página exibe
  • Uma origem criptografada é rejeitada, já que a edição de estrutura precisa de bytes descriptografados; descriptografe o arquivo antes
  • MaxOutputBytes, MaxWork e CancellationToken limitam o trabalho, e qualquer falha retorna False sem saída e com um ErrorMessage nomeando a fase em que parou

Em um documento marcado a árvore de estrutura já diz a você quais números de anotação importam. Todo item OBJR em um snapshot do ReadPdfDocumentStructure carrega o número do objeto referenciado, e o número de página dele vem do /Pg do próprio OBJR ou, na falta desse, do elemento. A rotina abaixo coleta cada anotação a que um elemento Link de uma página se refere e as remove com as duas opções de limpeza ligadas. Mantenha o filtro de página: um número tirado de outra página recusa o lote inteiro

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);

    // Cada anotação a que um elemento Link desta página aponta
    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;    // itens OBJR e entradas ParentTree
    Options.RemoveEmptiedStructureNodes := True;  // elementos que ficaram sem nada dentro
    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;

O que acontece com a chave da ParentTree

Com o RemoveOwnedStructurePeers ligado, cada OBJR cujo /Obj é uma anotação removida daquela página sai do seu elemento pelo editor de estrutura, o que mantém o índice reverso em dia. O par chave e valor guardado sob o /StructParent da anotação sai da folha /Nums dela, e a própria chave /StructParent é removida do dicionário da anotação, já que a Tabela 326 pede a chave só em objetos que são itens de conteúdo estrutural. O /ParentTreeNextKey não é rebaixado. O §14.7.4.4 só exige que ele seja maior que toda chave em uso, o que uma remoção não consegue quebrar, e as chaves de uma number tree não precisam ser contíguas, então um writer que segue o /ParentTreeNextKey nunca entrega a chave liberada de novo. Toda anotação sobrevivente mantém o número de objeto, a posição no /Annots e a chave na ParentTree; a função relê a página que escreveu e recusa o resultado se uma sobrevivente sumiu ou trocou de lugar. Esperado: em uma página com um link marcado, Report.RemovedAnnotations e Report.RemovedObjectReferences valem ambos 1, e Report.SourceSha256 e Report.OutputSha256 registram quais bytes entraram e quais saíram

Quando o elemento esvaziado também deve sair?

O RemoveEmptiedStructureNodes remove os elementos que a remoção do OBJR deixou sem itens de conteúdo e sem elementos filhos, do mais profundo para cima. Ele roda em rodadas: cada rodada relê a estrutura, remove os elementos que agora estão vazios e adiciona os pais deles aos candidatos da rodada seguinte, então uma cadeia de wrappers desmorona de baixo para cima enquanto a raiz da árvore de estrutura sempre fica. Um elemento Annot que marca uma nota adesiva normalmente não guarda nada além do OBJR (o §14.8.4.4.3 permite isso), então esvazia e sai. Um elemento Link é diferente. Ele ainda é dono das referências de conteúdo marcado das palavras visíveis, então não está vazio e sobrevive, como um Link sem anotação. O RemovePdfPageAnnotations não o renomeia, porque se essas palavras devem continuar sendo um link é uma pergunta editorial, não estrutural. Para uma exclusão pura, ligue as duas opções e depois rebaixe para Span qualquer elemento Link que tenha ficado sem OBJR: a tecnologia assistiva então lê essas palavras como texto simples em vez de oferecer um link que não leva a nada

Fluxo de decisão para elementos de estrutura esvaziados: um elemento Annot desmorona rodada a rodada, um elemento Link sobrevive, e o PDFium Component pode rebaixá-lo a Span
wrappers esvaziados desmoronam do mais profundo primeiro enquanto a raiz fica, e um Link que ficou sem o seu OBJR pode ser rebaixado a Span ou guardado para uma anotação substituta
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;
    // O digest do snapshot protege contra os bytes mudarem no meio do caminho
    if not EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops,
      Options, Fixed, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Result := Length(Ops);
  finally
    Source.Free;
  end;
end;

Mantenha os padrões quando a exclusão é metade de uma substituição. Um elemento esvaziado deixado no lugar ainda carrega o role, o título, o texto alternativo e o element ID dele, e se um passo posterior anexa uma nova anotação a ele com uma operação psekAddObjectReference, que dá à anotação uma chave /StructParent nova e avança o /ParentTreeNextKey pelas mesmas regras que registram itens MCR e OBJR novos na parent tree, o elemento mantém o seu lugar na ordem de leitura. Remover e recriar esse elemento descartaria esses valores e daria a ele um número de objeto novo

Apagando conteúdo marcado: a propriedade vai do BDC até o seu EMC

Anotações não são objetos de página, então texto, paths e imagens passam por outra API: o editor de objetos em lote, PlanStyledPdfObjectEdits e ExecuteStyledPdfObjectEdits na FPdfStyledEdit, cujo TPdfObjectEditOptions carrega RemoveOwnedStructurePeers e RemoveEmptiedStructureNodes com o mesmo significado. Uma sequência de conteúdo marcado só vai embora junto com a entrada de estrutura dela quando todo objeto entre o BDC e o EMC correspondente está sendo apagado. Nesse caso os dois operadores saem do content stream, o MCR sai do seu elemento, e o slot desse MCID no array ParentTree da página recebe null em vez de ser fechado, porque todos os outros MCIDs da página indexam o mesmo array. Uma sequência que também envolve conteúdo que sobrevive fica, junto com o MCR dela. Uma página que reusa o MCID removido para conteúdo sobrevivente é recusada de cara, já que MCIDs têm escopo no content stream da página e derrubar a entrada de estrutura deixaria o sobrevivente órfão. Com a opção deixada em False, o lote mantém os pares BDC e EMC na fronteira da remoção em vez disso, então a árvore de estrutura continua resolvendo e nada nela muda

A propriedade precisa ser medida do BDC até o fim do EMC correspondente. Nossa primeira versão media isso pelo byte range do operador BDC sozinho, que é onde uma marca parseada registra a posição, e não encontrou nenhum objeto delimitado. Um erro aparentado é marcar o BDC para remoção e perder de vista o parceiro, o que escreve exatamente o stream desbalanceado descrito acima, então o editor sempre remove as duas metades de um par juntas. A rotina abaixo apaga todo objeto de nível superior que carrega um MCID. O ExecuteStyledPdfObjectEdits recarrega o Pdf com o resultado a menos que EditOptions.PreviewOnly esteja definido, e Report.RemovedStructurePeers e Report.RemovedStructureNodes contam o que saiu da árvore; a função retorna esse report

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 começa em 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;

Uma anotação removida ainda ocupa o seu objeto no arquivo depois de qualquer uma das chamadas, porque só a referência da página é retirada. Uma vez que o OBJR se foi, nada no grafo do catálogo alcança o objeto, então o TPdf.SaveAsReachable pode varrer esse objeto órfão para fora de uma cópia reescrita; com as opções padrão o OBJR mantém a anotação alcançável e a varredura a mantém também. Rode a remoção com as duas opções de estrutura ligadas sempre que a exclusão for definitiva, e a página, a árvore de estrutura e a parent tree no arquivo que o PDFium Component para Delphi escreve de volta descrevem todas o mesmo conjunto de anotações