O HotPDF Delphi Component apaga uma página de um PDF carregado através do THotPDF.DeletePage, e desde a versão 2.751.0 essa chamada poda também todas as referências ao nível do documento que ainda apontam para a página: destinos com nome na árvore /Names /Dests, o dicionário /Dests legado do catálogo, ações /GoTo de marcadores, elementos de estrutura sob /StructTreeRoot, o ParentTree, entradas OBJR de anotações, e anotações de link nas páginas sobreviventes. A árvore de páginas é reconstruída em último lugar, depois de nada mais poder alcançar o objeto apagado
A falha que isto evita é fácil de reproduzir e difícil de diagnosticar. Apague a página de capa de um relatório com tags, grave, e abra o resultado: o Acrobat mostra a contagem de páginas certa, mas o marcador «Contents» já não aterra em lado 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 árvore de páginas está errado. O problema é que uma página de PDF não é apenas uma folha de /Pages; é um alvo para o qual metade do catálogo aponta, e remover a folha deixa cada um desses ponteiros órfão
Porque é que remover uma página de /Kids não chega?
Porque a ISO 32000-1 deixa pelo menos sete estruturas independentes guardar uma referência a um objeto de página, e só uma delas é a árvore de páginas. Tirar a página de /Kids e decrementar /Count satisfaz a §7.7.3, e todas as outras referências passam a ser um ponteiro para um objeto que ou é libertado no xref ou está simplesmente ausente do ficheiro reescrito. Um visualizador que siga um desses ponteiros recebe null, e o que faz com esse null é decisão do visualizador
- A árvore de nomes 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
/Destsanterior à 1.2, diretamente no catálogo, guarda o mesmo tipo de arrays com chave de nome - Os itens de outline (§12.3.3) alcançam uma página através de um
/Destinline ou de uma ação/Acom/S /GoToe um array/D - Os elementos de estrutura (§14.7.2) transportam uma chave
/Pgque nomeia a página onde vive o seu conteúdo marcado, e os seus filhos/Kpodem ser referências de conteúdo marcado e referências de objeto (§14.7.4.3) ligadas a essa página - O
ParentTree(§14.7.4.4) mapeia os números/StructParentsde páginas e anotações de volta para elementos de estrutura, e um elemento pode viver lá sem aparecer de todo na cadeia/Ka partir da raiz - As anotações de link noutras páginas (§12.5.6.5) transportam um
/Destou uma ação/GoToque aponta à página, e o/OpenActiondo catálogo pode fazer o mesmo
O que limpa o THotPDF.DeletePage antes de tocar na árvore de páginas?
O THotPDF.DeletePage(PageIndex) num documento carregado corre primeiro toda a limpeza de referências, depois marca o objeto de página como apagado com DeleteObj, desliga quaisquer anotações widget 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 limpeza visita o catálogo numa ordem fixa: a árvore de nomes /Names /Dests, o dicionário /Dests à moda antiga, o /OpenAction, a árvore de outline, o /StructTreeRoot com o seu ParentTree, e por último os arrays /Annots de todas as páginas que ficam. Cada passo decide se uma referência é removida, reapontada ou deixada em paz de acordo com o que a especificação permite que essa estrutura faça sem a página. Duas proteções aplicam-se antes de tudo isto correr: o DeletePage levanta Invalid page number para um índice fora do intervalo e recusa 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,3-5,7-" baseada em um que as outras operações de página em documentos carregados usam e itera do índice selecionado mais alto para baixo, para que os índices que escreveu continuem válidos enquanto trabalha
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
begin
// Base zero: descartar a página de capa. Os destinos com nome,
// os marcadores, a árvore de estrutura, o ParentTree e as
// 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 base um para lotes, com o í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 são tratados de forma diferente os destinos com nome e os marcadores?
Os destinos com nome são removidos e os marcadores são reapontados, porque um nome que já não existe é um resultado aceitável enquanto um marcador sem destino é um defeito visível. Na árvore /Names /Dests o HotPDF percorre todos os nós, testa cada destino, tanto na forma de array simples como na forma de dicionário com uma chave /D, contra a página apagada, e remove o par nome/valor quando o primeiro elemento do array é essa página. Um nó cujos /Names e /Kids acabem ambos vazios é marcado como apagado e desligado do seu pai, para que a árvore nunca guarde folhas ocas. O mesmo teste corre sobre o dicionário /Dests do catálogo à moda antiga, e o /OpenAction do catálogo é simplesmente descartado se abria na página apagada. Uma fronteira aqui: quando um nó da árvore de nomes perde entradas, o HotPDF apaga o par /Limits desse nó em vez de recalcular as novas chaves mais baixa e mais alta, e embora os visualizadores resolvam os nomes bem sem isso, um verificador de conformidade estrito que leia a ISO 32000-1 §7.9.6 pode assinalar um nó não raiz sem /Limits
Os 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 possa pendurar a chamada, e para cada array /Dest ou array /D de ação /GoTo apontado à página substitui o primeiro elemento por NearestRetainedPage: a página que se seguiu à apagada, ou a página anterior quando a apagada era a última. Os parâmetros de vista a seguir à referência de página ficam como estavam. Um marcador que apontava a um início de capítulo apagado aterra portanto na primeira página do que resta em vez de desaparecer da barra lateral, que é o comportamento que os revisores esperam de um documento cortado. O teste de destino, no entanto, só corresponde a arrays explícitos: um item de outline cujo /Dest é uma string de nome que antes resolvia para a página apagada não é reapontado, porque a entrada da árvore de nomes desapareceu e a referência passa agora a resolver para nada em vez de para um objeto libertado, pelo que o visualizador a trata como um marcador morto. A mecânica da própria árvore de outline, /First, /Next e a semântica nada óbvia do /Count, é abordada em o guia para acrescentar marcadores e destinos com nome a um PDF carregado
// Verificar a limpeza em vez de confiar nela.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
ShowMessage('Named destination "cover" was pruned');
// Um marcador que apontava para a capa resolve agora para a
// página que se lhe seguiu (índice base zero 0 após a eliminação).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
ShowMessage('Bookmark retargeted to the nearest retained page');
O que acontece à árvore de estrutura e ao ParentTree?
Os elementos de estrutura que só existem por causa da página apagada são removidos, e os elementos que abrangem várias páginas perdem a sua 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 como a forma de dicionário único de /K que a §14.7.2 permite. Para cada elemento poda primeiro os filhos e depois avalia o próprio elemento: se a poda esvaziou o seu /K, o elemento é marcado como apagado e o seu pai descarta-o. Se o /Pg do próprio elemento nomeia a página apagada e o elemento ainda tem filhos mais um pai /P, só o /Pg é removido, porque um /Pg num elemento é a página predefinida para os seus filhos de conteúdo marcado e esses filhos podem referenciar outras páginas de forma explícita. Só um elemento cujo /Pg é a página apagada e que não tem nada por baixo é removido de vez
O ParentTree recebe o mesmo tratamento, e a razão é a que mordeu durante o desenvolvimento: um elemento de estrutura pode ser alcançável a partir do ParentTree e de mais nenhum sítio. A árvore de números mapeia inteiros /StructParents para um único elemento ou para um array de elementos, e o PruneParentTreeNode corre o PruneStructureElement sobre cada valor que encontra, remove os valores que foram podados, apaga um par /Nums quando o seu array de valores fica vazio, e desliga um nó cujos /Nums e /Kids desapareceram ambos. Podar apenas os descendentes de /K teria deixado esses elementos órfãos a apontar para uma página libertada através do /Pg e para referências de conteúdo marcado libertadas através dos seus filhos /MCR. Se extrai texto pela ordem da estrutura, isso importa diretamente: a extração de texto pela ordem da estrutura percorre exatamente estas árvores, e um elemento com um /Pg nulo é um parágrafo que cai silenciosamente fora da ordem de leitura
Que anotações de link em páginas sobreviventes são removidas?
Qualquer anotação de link numa página retida cujo array /Dest ou ação /GoTo aponte à página apagada é removida juntamente com a sua posse na árvore de estrutura. O RemoveRetainedPageDestinationAnnotations percorre o array /Annots de todas as páginas excepto a alvo, aplica o mesmo teste de destino usado para os outlines, marca uma anotação correspondente como apagada, tira-a do array, e depois chama PruneAnnotationReferencesInStructureTree para que o dicionário OBJR cujo /Obj nomeava essa anotação seja removido do seu elemento de estrutura, com o próprio elemento removido se o OBJR era o seu único filho. Deixar o OBJR no lugar violaria a §14.7.4.3, que exige que o /Obj referencie um objeto existente, e apareceria numa verificação PDF/UA como um link com tag sem anotação por trás. Note a assimetria com os marcadores: os links são removidos, não reapontados. Uma referência cruzada no corpo do texto que dizia «ver página 3» fica errada quando a página 3 desaparece, e apontá-la à página 4 seria uma mentira de uma forma que um marcador a aterrar no capítulo mais próximo não é, por isso, se o seu fluxo de trabalho precisar desses links preservados, reaponte-os você mesmo antes de chamar o DeletePage
Porque é que um /MCR ou /OBJR removido nunca pode ser registado como livre?
Porque as referências de conteúdo marcado e as referências de objeto são habitualmente dicionários diretos dentro do array /K do elemento pai, e o registo incremental de alterações 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, liberta o objeto em memória apenas se ele era um THPDFLink ou um valor não indireto, e o MarkRemovedObject regista um objeto na lista de livres apenas quando o seu número de objeto é maior que zero. A primeira versão desta limpeza não fazia essa distinção, e o efeito numa gravação incremental foi exatamente o que o registo está desenhado 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 detinha, e escrevia esse elemento como null. Um documento que perdia 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 o seu contentor como sujo através do TouchContainer, para que o contentor seja reescrito, e deixar a lista de livres em paz
// Atualização incremental: só os contentores tocados e o
// objeto de página libertado entram na secção acrescentada.
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('tagged-report.pdf');
Pdf.DeletePage(0);
// Os elementos de estrutura retidos cujo /K perdeu um /MCR
// direto são reescritos no local, nunca escritos como null.
Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
Pdf.Free;
end;
A mesma cautela molda o que o DeletePage deliberadamente não liberta num documento carregado. Os content streams, os XObjects e as anotações não-widget da página apagada ficam como objetos, porque um ficheiro carregado pode partilhar qualquer um deles com uma página que fica e não há forma barata de provar o contrário no momento da eliminação. Remover a referência da árvore de páginas chega para a correção; os bytes que esses objetos ainda ocupam são uma questão separada, e o grafo de dependências de objetos e a análise de bytes retidos é a ferramenta para medir o que um documento cortado ainda transporta
DeletePage ou DeleteLoadedPage: qual deve chamar?
Chame DeletePage para qualquer remoção de página visível ao utilizador, e reserve o DeleteLoadedPage para o caso em que o documento inteiro está a ser recomposto e nenhuma referência ao nível do documento vale a pena guardar. O THotPDF.DeleteLoadedPage(PageIndex), acrescentado na versão 2.508.0, é a variante leve: desloca o array interno de páginas, chama RebuildLoadedKidsArray para reescrever /Kids e /Count, invalida a cache de páginas desenhadas, e dispara o OnLoadedDocumentModified. Não percorre a árvore de nomes, os outlines, a árvore de estrutura nem as anotações das outras páginas, e não marca o objeto de página como apagado. Essa é a ferramenta certa dentro da imposição N-up, onde o HotPDF acrescenta folhas acabadas de compor e depois descarta todas as páginas originais com DeleteLoadedPage(0): as páginas de origem estão a ser substituídas em bloco, e o conteúdo das folhas refere os seus recursos, não os objetos de página. Para o trabalho vulgar de «remover a página 7 deste contrato», o DeletePage é a única chamada que deixa um documento com tags, marcadores e referências cruzadas suficientemente consistente para passar um validador, tanto numa reescrita completa através do SaveLoadedDocument como numa atualização incremental através do SaveIncrementalUpdate. Ambos os métodos saem no HotPDF Delphi Component para Delphi e C++Builder, sem exigir qualquer runtime ou dependência de visualizador externo