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
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:
| Local | O que guarda | O que uma remoção limpa faz |
|---|---|---|
Página /Annots | Referência indireta à anotação | Descarta a referência; as sobreviventes mantêm a ordem |
Elemento Link /K | OBJR com /Obj e /Pg | Remove o item de conteúdo OBJR |
| Dicionário da anotação | chave inteira /StructParent | Remove a chave, já que o objeto deixa de ser um item de conteúdo |
Folha /Nums da ParentTree | Chave pareada com uma referência ao elemento Link | Remove o par chave e valor |
| Raiz da árvore de estrutura | /ParentTreeNextKey | Nã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
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
/Annotsda 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,MaxWorkeCancellationTokenlimitam o trabalho, e qualquer falha retornaFalsesem saída e com umErrorMessagenomeando 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
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