Artigo Técnico

Excluir páginas de PDF no Delphi sem referências órfãs

O HotPDF Delphi Component exclui uma página de um PDF carregado por meio do THotPDF.DeletePage, e desde a versão 2.751.0 essa chamada também poda toda referência em nível de documento que ainda aponte para a página: named destinations na árvore /Names /Dests, o dicionário /Dests legado do catalog, ações /GoTo de bookmark, elementos de estrutura sob /StructTreeRoot, o ParentTree, entradas OBJR de anotações e anotações de link em páginas que sobrevivem. A page tree é reconstruída por último, depois que nada mais consegue alcançar o objeto excluído

A falha que isso evita é fácil de reproduzir e difícil de diagnosticar. Exclua a capa de um relatório com tags, salve e abra o resultado: o Acrobat mostra a contagem de páginas certa, mas o bookmark "Contents" agora não leva a lugar nenhum, o verificador de acessibilidade reporta um elemento de estrutura sem página, e um validador estrito lista uma referência a um objeto livre. Nada na page tree está errado. O problema é que uma página de PDF não é só uma folha de /Pages; é um alvo para o qual metade do catalog aponta, e remover a folha deixa cada um desses ponteiros pendurado

Por que remover uma página de /Kids não basta?

Porque a ISO 32000-1 permite que pelo menos sete estruturas independentes guardem uma referência a um objeto de página, e só uma delas é a page tree. Tirar a página de /Kids e decrementar /Count satisfaz a §7.7.3, e toda outra referência vira um ponteiro para um objeto que ou foi liberado no xref ou simplesmente não existe no arquivo reescrito. Um viewer que siga um desses ponteiros recebe null, e o que ele faz com esse null é problema dele

  • A name tree sob /Names /Dests (§7.7.4, §12.3.2.3) mapeia nomes para arrays de destino cujo primeiro elemento é a página
  • O dicionário /Dests pré-1.2, direto no catalog, guarda o mesmo tipo de array indexado por nome
  • Itens de outline (§12.3.3) alcançam uma página ou por um /Dest inline ou por uma ação /A com /S /GoTo e um array /D
  • Elementos de estrutura (§14.7.2) carregam uma chave /Pg nomeando a página onde vive seu marked content, e os filhos /K deles podem ser marked-content references e object references (§14.7.4.3) amarrados a essa página
  • O ParentTree (§14.7.4.4) mapeia números /StructParents de páginas e anotações de volta para elementos de estrutura, e um elemento pode viver ali sem aparecer na cadeia /K a partir da raiz de jeito nenhum
  • Anotações de link em outras páginas (§12.5.6.5) carregam um /Dest ou uma ação /GoTo apontando para a página, e o /OpenAction do catalog pode fazer o mesmo
Por que remover uma página do HotPDF de /Kids não basta: a ISO 32000-1 permite que a name tree /Names /Dests, o dicionário /Dests legado do catalog, itens de outline, elementos de estrutura com /Pg, o ParentTree, anotações de link e o /OpenAction guardem todos uma referência ao mesmo objeto de página, e só a page tree é reconstruída
Uma página de PDF é um alvo para o qual metade do catalog aponta: largar a folha satisfaz a page tree enquanto todo outro ponteiro resolve para null, então um relatório enxugado perde o bookmark Contents e falha na checagem de acessibilidade

O que o THotPDF.DeletePage limpa antes de tocar na page tree?

O THotPDF.DeletePage(PageIndex) sobre um documento carregado roda a varredura de referências inteira primeiro, depois marca o objeto de página como excluído com DeleteObj, desliga quaisquer widget annotations da árvore de campos do AcroForm, desloca o array interno de páginas e por fim chama RebuildLoadedPageTree para reescrever /Kids, /Count e o /Parent de cada página sobrevivente. A varredura visita o catalog numa ordem fixa: a name tree /Names /Dests, o dicionário /Dests no formato antigo, o /OpenAction, a árvore de outline, o /StructTreeRoot com seu ParentTree e, por último, os arrays /Annots de toda página que fica. Cada passo decide se uma referência é removida, redirecionada ou deixada em paz conforme o que a especificação permite que aquela estrutura faça sem a página. Duas guardas valem antes de qualquer coisa rodar: o DeletePage levanta Invalid page number para índice fora de faixa e se recusa a remover a última página, porque um nó /Pages com zero filhos não é um PDF válido, enquanto o DeletePages aceita a mesma notação 1-based "1,3-5,7-" das outras operações de página em documento carregado e itera do índice selecionado mais alto para baixo, para que os índices que você escreveu continuem válidos enquanto ele trabalha

A varredura fixa de referências que o THotPDF.DeletePage roda antes de tocar na page tree: as guardas rejeitam índice fora de faixa ou a última página, depois /Names /Dests e o /Dests legado são podados, o /OpenAction é descartado, os outlines são redirecionados para NearestRetainedPage, StructTreeRoot e ParentTree são podados, links em páginas retidas são removidos, e o RebuildLoadedPageTree roda por último
Cada estrutura recebe o tratamento que a especificação permite: nomes desaparecem, bookmarks caem na página retida mais próxima, elementos de estrutura perdem o /Pg ou somem, e a reescrita de /Kids só acontece depois que nada mais consegue alcançar o objeto excluído
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // Zero-based: remove a capa. Named destinations,
      // bookmarks, structure tree, ParentTree e anotações
      // de link que apontavam para ela são podados antes de
      // a árvore /Pages ser reconstruída.
      Pdf.DeletePage(0);
      // Sintaxe de intervalo 1-based para lotes, índice mais alto primeiro
      // internamente para que os índices anteriores continuem válidos.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Como named destinations e bookmarks são tratados de forma diferente?

Named destinations são removidas e bookmarks são redirecionados, porque um nome que não existe mais é um resultado aceitável, enquanto um bookmark sem destino é um defeito visível. Na árvore /Names /Dests o HotPDF percorre todo nó, testa cada destino — tanto na forma de array puro quanto na forma de dicionário com uma chave /D — contra a página excluída, e remove o par nome/valor quando o primeiro elemento do array é essa página. Um nó cujos /Names e /Kids ficam ambos vazios é marcado como excluído e desligado do pai, então a árvore nunca mantém folhas ocas. O mesmo teste roda sobre o dicionário /Dests no formato antigo do catalog, e o /OpenAction do catalog é simplesmente descartado se ele abria na página excluída. Uma fronteira aqui: quando um nó da name tree perde entradas, o HotPDF apaga o par /Limits daquele nó em vez de recalcular as novas chaves mínima e máxima, e embora os viewers resolvam nomes sem problema sem ele, um verificador de conformidade estrito lendo a ISO 32000-1 §7.9.6 pode sinalizar um nó não raiz que não tem /Limits

Itens de outline vão pelo caminho oposto. O RetargetOutlineDestinations percorre /First e /Next a partir da raiz do outline, com uma lista de visitados e um limite de profundidade de 128 para que uma árvore cíclica corrompida não trave a chamada, e para todo array /Dest ou array /D de ação /GoTo apontado para a página ele substitui o primeiro elemento por NearestRetainedPage: a página que veio depois da excluída, ou a anterior a ela quando a página excluída era a última. Os parâmetros de view depois da referência de página são deixados como estavam. Um bookmark que apontava para um início de capítulo excluído portanto cai na primeira página do que restou em vez de sumir da barra lateral, que é o comportamento que revisores esperam de um documento enxugado. O teste de destino, porém, casa apenas arrays explícitos: um item de outline cujo /Dest é uma string de nome que antes resolvia para a página excluída não é redirecionado, porque a entrada da name tree já era e a referência agora resolve para nada em vez de para um objeto liberado, então o viewer a trata como bookmark morto. A mecânica da própria árvore de outline, /First, /Next e a semântica nada óbvia de /Count, está coberta no guia para adicionar bookmarks e named destinations num PDF carregado

// Verifique a varredura em vez de confiar nela.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// Um bookmark que mirava a capa agora resolve para a
// página que a seguia (índice zero-based 0 depois da exclusão).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

O que acontece com a structure tree e o ParentTree?

Elementos de estrutura que existem só por causa da página excluída são removidos, e elementos que abrangem várias páginas perdem a chave /Pg mas mantêm os filhos. O PruneStructureElement desce a cadeia /K a partir de /StructTreeRoot até uma profundidade de 128, tratando tanto a forma de array quanto a forma de dicionário único de /K que a §14.7.2 permite. Para cada elemento ele poda primeiro os filhos, depois avalia o próprio elemento: se a poda esvaziou seu /K, o elemento é marcado como excluído e o pai o descarta. Se o /Pg do próprio elemento nomeia a página excluída e o elemento ainda tem filhos mais um pai /P, só o /Pg é removido, porque um /Pg num elemento é a página padrão dos seus filhos de marked content e esses filhos podem referenciar outras páginas explicitamente. Só um elemento cujo /Pg é a página excluída e que não tem mais nada embaixo é removido de vez

O ParentTree recebe o mesmo tratamento, e o motivo é o que doeu durante o desenvolvimento: um elemento de estrutura pode ser alcançável pelo ParentTree e por mais nenhum lugar. A number tree mapeia inteiros /StructParents para um único elemento ou para um array de elementos, e o PruneParentTreeNode roda o PruneStructureElement sobre todo valor que encontra, remove valores que foram podados, apaga um par /Nums quando seu array de valores está vazio e desliga um nó cujos /Nums e /Kids sumiram os dois. Podar apenas os descendentes de /K teria deixado aqueles elementos órfãos apontando para uma página liberada via /Pg e para marked-content references liberadas via seus filhos /MCR. Se você extrai texto em ordem de estrutura, isso importa diretamente: a extração de texto em ordem de estrutura percorre exatamente essas árvores, e um elemento com /Pg nulo é um parágrafo que cai fora da ordem de leitura em silêncio

Quais anotações de link em páginas sobreviventes são removidas?

Toda anotação de link numa página retida cujo array /Dest ou ação /GoTo aponte para a página excluída é removida junto com sua propriedade na structure tree. O RemoveRetainedPageDestinationAnnotations percorre o array /Annots de toda página que não seja a alvo, aplica o mesmo teste de destino usado nos outlines, marca uma anotação correspondente como excluída, a tira do array e então chama PruneAnnotationReferencesInStructureTree, para que o dicionário OBJR cujo /Obj nomeava aquela anotação seja removido do seu elemento de estrutura, com o próprio elemento removido se o OBJR era seu único filho. Deixar o OBJR no lugar violaria a §14.7.4.3, que exige que /Obj referencie um objeto existente, e apareceria numa checagem de PDF/UA como um link com tags sem anotação por trás. Repare na assimetria com os bookmarks: links são removidos, não redirecionados. Uma referência cruzada no corpo do texto que dizia "veja a página 3" fica errada assim que a página 3 some, e apontá-la para a página 4 seria uma mentira de um jeito que um bookmark caindo no capítulo mais próximo não é, então se o seu fluxo precisa desses links preservados, redirecione-os você mesmo antes de chamar DeletePage

Por que um /MCR ou /OBJR removido nunca pode ser registrado como livre?

Porque marked-content references e object references normalmente são dicionários diretos dentro do array /K do seu elemento pai, e o registro de mudanças incrementais resolve um objeto direto para o objeto indireto mais próximo que o contém. Quando o RemoveArrayItem tira um filho de um array /K, ele libera o objeto em memória só se ele era um THPDFLink ou um valor não indireto, e o MarkRemovedObject registra um objeto na free list só quando o número de objeto dele é maior que zero. A primeira versão dessa varredura não fazia essa distinção, e o efeito num save incremental foi exatamente o que o registro é feito para fazer: o RegisterIncrementalChange subia do /MCR direto até sua raiz de transação no grafo, que era o elemento de estrutura retido que o possuía, e escrevia esse elemento como null. Um documento que perdeu uma página voltava com o conteúdo com tags das outras páginas silenciosamente sem tags. O único movimento correto para um filho direto é marcar seu container como sujo via TouchContainer, para que o container seja reescrito, e deixar a free list em paz

Por que um filho /MCR ou OBJR removido nunca pode ser registrado como livre no HotPDF: o registro de mudanças incrementais resolve um dicionário direto para o container indireto mais próximo, então a primeira versão escrevia o elemento de estrutura retido como null e deixava as páginas sobreviventes sem tags em silêncio, enquanto o TouchContainer agora reescreve o container e deixa a free list em paz
Liberar o filho em memória fica reservado a THPDFLink ou valores não indiretos e a números de objeto maiores que zero, então um save incremental anexa apenas os containers tocados e o objeto de página liberado
// Atualização incremental: só os containers tocados e o
// objeto de página liberado entram na seção anexada.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Elementos de estrutura retidos cujo /K perdeu um /MCR direto
  // são reescritos no lugar, nunca escritos como null.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

O mesmo cuidado molda o que o DeletePage deliberadamente não libera num documento carregado. Content streams, XObjects e as anotações que não são widgets da página excluída ficam como objetos, porque um arquivo carregado pode compartilhar qualquer um deles com uma página que permanece e não há jeito barato de provar o contrário no momento da exclusão. Remover a referência na page tree já basta para a correção; os bytes que esses objetos ainda ocupam são outra questão, e a análise de grafo de dependência de objetos e bytes retidos é a ferramenta para medir o que um documento enxugado ainda carrega

DeletePage versus DeleteLoadedPage: qual deles chamar?

Chame DeletePage para qualquer remoção de página voltada ao usuário, e reserve DeleteLoadedPage para o caso em que o documento inteiro está sendo reflowado e nenhuma referência em nível de documento vale a pena manter. O THotPDF.DeleteLoadedPage(PageIndex), adicionado na versão 2.508.0, é a variante leve: desloca o array interno de páginas, chama RebuildLoadedKidsArray para reescrever /Kids e /Count, invalida o cache de páginas renderizadas e dispara OnLoadedDocumentModified. Ele não percorre a name tree, os outlines, a structure tree nem as anotações de outras páginas, e não marca o objeto de página como excluído. Essa é a ferramenta certa dentro de imposição N-up, onde o HotPDF anexa folhas recém-montadas e então descarta toda página original com DeleteLoadedPage(0): as páginas de origem estão sendo substituídas por completo, e o conteúdo da folha se refere aos recursos delas em vez de aos objetos de página. Para o trabalho comum de "remover a página 7 deste contrato", o DeletePage é a única chamada que deixa um documento com tags, bookmarks e referências cruzadas consistente o bastante para passar num validador, tanto numa reescrita completa via SaveLoadedDocument quanto numa atualização incremental via SaveIncrementalUpdate. Os dois métodos vêm no HotPDF Delphi Component para Delphi e C++Builder, sem exigir nenhum runtime de viewer externo ou dependência