Eliminar uma página de um PDF não elimina os respetivos tipos de letra, imagens ou content streams. A losLab PDF Library recupera-os com um coletor mark-sweep que percorre o grafo de objetos a partir das raízes do trailer e remove todos os objetos indiretos que já não são alcançados por nada. Corre numa gravação completa, vem desativado por predefinição, e devolve o número de objetos que eliminou
Porque é que eliminar páginas de um PDF não reduz o ficheiro?
Porque a eliminação de páginas é uma edição de referências, não uma operação de armazenamento. DeletePages(StartPage, PageCount) desliga os objetos de página da árvore de páginas e repara as entradas do outline que lhes apontavam. O que não consegue fazer é decidir que o programa de tipo de letra, o content stream e o XObject de imagem que essas páginas usavam estão agora mortos, porque no momento da eliminação nada no ficheiro regista quem mais lhes possa ainda estar a apontar. Esses objetos permanecem na lista de objetos do documento, e uma gravação completa volta a escrever cada um deles. O resultado é a queixa com que começam a maioria destes pedidos de suporte: um cliente elimina noventa por cento das páginas, grava, e o ficheiro encolhe dois por cento. Pior ainda, a fuga acumula-se. Carregar, eliminar, gravar, carregar de novo, eliminar de novo, gravar de novo, e o ficheiro cresce de forma monótona enquanto o número de páginas desce. Este é um problema diferente do que é resolvido pelo subsetting de tipos de letra e o downsampling de imagens, que tornam mais pequenos objetos que continuam vivos. Aqui os objetos não são demasiado grandes. Simplesmente já não fazem parte do documento
O conjunto raiz é o trailer, não a árvore de páginas
O grafo de objetos do PDF não tem campo de referência inversa. O formato não define contagem de referências nem lista de apontadores inversos, e as chaves /Parent que existem pertencem a estruturas específicas como a árvore de páginas, não ao grafo de objetos como um todo. Nada num objeto indireto diz quem lhe aponta, pelo que a pergunta "ainda há alguém a usar o objeto 47" tem exatamente uma resposta: percorrer para a frente a partir de uma raiz conhecida e ver se lá se chega. É por isso que o coletor na losLab PDF Library é um coletor mark-sweep e não um esquema de contagem de referências
As raízes vêm do trailer do ficheiro (ISO 32000-1 §7.5.5). Três chaves transportam-nas: /Root, o catálogo do documento do §7.7.2 do qual pendem a árvore de páginas, os nomes, os outlines, o AcroForm e os metadados; /Info, o dicionário de informação do documento; e /Encrypt, o dicionário de encriptação. As duas restantes chaves do trailer são iscas. /ID é um array de duas byte strings, e /Prev é um offset inteiro em bytes para a secção de cross-reference anterior. Nenhuma delas é uma referência indireta, pelo que nenhuma contribui como raiz. A losLab PDF Library enfileira o dicionário do trailer inteiro em vez de três chaves nomeadas, o que não custa nada e mantém viva qualquer extensão privada do trailer
O próprio percurso é iterativo e não recursivo. Quando a travessia encontra uma referência indireta, regista apenas o número do objeto e a geração, marca a posição correspondente e coloca-a numa fila FIFO em vez de a desreferenciar de imediato, o que mantém árvores de páginas profundas e cadeias longas de outline fora da call stack e evita que o mesmo objeto seja descodificado duas vezes. Dicionários diretos, arrays e dicionários de stream vão para uma segunda fila protegida por um conjunto de visitados, porque documentos reais contêm ciclos genuínos: o /Parent de uma página aponta de volta para o seu nó na árvore de páginas, e os itens do outline encadeiam-se através de /Prev e /Next em ambas as direções. Os números de geração fazem parte da correspondência, não são decoração. Uma referência só se resolve quando o número do objeto e a geração coincidem ambos; uma referência a um número que existe numa geração diferente é tratada como o objeto nulo que a especificação exige, nunca como uma aresta viva
Como se ativa o garbage collection numa gravação?
O garbage collection é opt-in e pertence ao registo de opções de gravação. Vem predefinido como False porque o coletor é uma passagem destrutiva sobre o grafo de objetos e nenhuma biblioteca deve eliminar objetos silenciosamente sem que o chamador tenha pedido para os examinar
var
Pdf: TPDFlib;
Opt: TPDFlibSaveOptions;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('report-500pages.pdf', '') <> 1 then
Exit;
Pdf.DeletePages(11, 490); // keep the first ten pages
FillChar(Opt, SizeOf(Opt), 0);
Opt.CompressContent := True;
Opt.CompressFonts := True;
Opt.OptimizeContentStreams := True;
Opt.PackObjectStreams := True;
Opt.GarbageCollect := True; // drop everything the pages left behind
Pdf.SaveToFileOptions('report-10pages.pdf', Opt);
finally
Pdf.Free;
end;
end;
Dois outros pontos de entrada chegam ao mesmo coletor. SetGarbageCollect(1) define a flag no documento selecionado para que um SaveToFile normal a respeite, e GarbageCollectObjects executa a passagem de imediato e devolve o número de objetos indiretos órfãos removidos. A forma imediata é a que se deve usar quando se quer um número para registar ou verificar por assert, e vale a pena confirmá-lo, porque um retorno negativo não é uma contagem
var
Removed: Integer;
begin
Pdf.DeletePages(11, 490);
Removed := Pdf.GarbageCollectObjects;
if Removed < 0 then
// The graph could not be fully decoded. Nothing was swept and the
// document is unchanged; save it without GC or reject the input.
LogWarning('object graph incomplete, GC skipped')
else
LogInfo(Format('reclaimed %d orphaned objects', [Removed]));
end;
Este caminho de falha importa mais do que parece. Os objetos são descodificados de forma lazy, e um objeto que nunca foi descodificado não expõe quaisquer referências. Se o coletor tratasse um objeto não descodificável como um nó vazio, varreria tudo o que só era alcançável através dele. Por isso a travessia força a descodificação à medida que toca em cada objeto, e um único erro de descodificação aborta toda a passagem com um resultado negativo e deixa o documento byte a byte idêntico. Varrer um grafo que só se compreende parcialmente é a forma de um coletor transformar um ficheiro danificado num ficheiro destruído
O que faz falhar um coletor de PDF ingénuo?
Dois pormenores, e ambos falham em silêncio em vez de ruidosamente. O primeiro são os object streams. Desde o PDF 1.5 um objeto não-stream pode viver comprimido dentro de um contentor /ObjStm (§7.5.7), e a sua entrada de cross-reference é uma entrada de tipo 2 que nomeia o contentor mais um índice dentro dele. Um objeto comprimido só é, portanto, alcançável através do seu contentor. Marque o membro, varra o contentor porque nada lhe apontava como objeto de documento, e obteve um ficheiro cujo xref aponta para um objeto que já não existe. O contentor é armazenamento estrutural, não dados do documento, pelo que nunca aparece como aresta no grafo de objetos que se está a percorrer. A losLab PDF Library trata isto separando cada membro comprimido sobrevivente do seu contentor de origem antes de os contentores desaparecerem, após o que a gravação reempacota os sobreviventes em novos object streams. O segundo pormenor é o que um objeto stream de facto referencia. Os bytes não fazem parte do grafo. Um content stream que desenha texto com /F1 12 Tf nomeia um tipo de letra pelo nome de recurso, e esse nome é resolvido através do dicionário /Resources da página, pelo que a aresta de alcançabilidade percorre página → /Resources → /Font → objeto de tipo de letra, nunca através do conteúdo do stream. As únicas referências que um stream contribui vêm do seu dicionário, onde /Length, /Filter e /DecodeParms podem todos ser indiretos. Um coletor que analisa os bytes do stream à procura de referências está a fazer trabalho caro para nada; um coletor que ignora os dicionários de stream perde o objeto de comprimento e corrompe o ficheiro
O que acontece aos números de objeto libertados
Tornam-se entradas livres, e não são reutilizados na mesma gravação. A varredura percorre a lista de objetos em ordem descendente para que as eliminações mantenham os índices estáveis, reconstrói o índice de pesquisa uma única vez no final em vez de após cada remoção, e para cada objeto removido regista o número na lista de livres com a sua geração incrementada em um, exatamente como o §7.5.4 especifica para uma entrada que possa vir a ser reutilizada. Uma geração já em 65535 permanece lá, marcando esse número como definitivamente retirado. Os números de objeto não são deliberadamente compactados. Após uma coleção o ficheiro mantém buracos: o objeto 12 pode estar livre enquanto 13 e 14 estão em uso, e o /Size do trailer continua a reportar o número mais alto mais um, e não a contagem de sobreviventes. Isto é legal e normal. Renumerar pouparia uma dúzia de bytes na tabela de cross-reference e exigiria reescrever cada referência no documento, o tipo de alteração que invalida silenciosamente qualquer coisa que guarde números de objeto vindos de fora. O tamanho que se recupera vem dos corpos dos objetos, não da tabela de xref
Quando não se deve executar o coletor
Nunca numa atualização incremental. O coletor está limitado a gravações completas e a flag simplesmente não é lida quando o documento está a ser acrescentado, e essa limitação não é algo a contornar. Uma atualização incremental (§7.5.6) deixa os bytes originais intocados e acrescenta uma nova secção de cross-reference encadeada à anterior através de /Prev. Cada revisão anterior continua a apontar para os objetos a que sempre apontou, pelo que um objeto inalcançável na revisão atual é perfeitamente alcançável numa revisão mais antiga. Eliminá-lo quebraria todas as revisões menos a última, e os detalhes disso estão cobertos no artigo sobre atualizações incrementais e gravações em modo de acrescento. O mesmo raciocínio exclui o garbage collection num documento assinado, porque a reescrita completa que torna a coleção possível é precisamente o que invalida a assinatura
Também vale a pena esclarecer o que a coleção não é. Não é um sanitizador. O coletor remove objetos que nada referencia; não tem opinião sobre se o seu conteúdo era sensível, e um objeto ainda referenciado permanece como estava. Se o objetivo é tornar a informação irrecuperável em vez de tornar o ficheiro mais pequeno, o grafo de objetos é a camada errada e a redação ao nível das instruções e a sanitização de documentos é a certa. Os dois compõem-se bem por esta ordem: redigir e sanitizar primeiro, depois recolher, para que os objetos que a redação destacou de facto saiam do ficheiro. O mesmo emparelhamento existe na API de purga de recursos, onde passar a opção de garbage-collect faz a purga executar uma coleção a seguir e reportar os órfãos removidos em OrphanObjectsRemoved
Um último hábito que vale a pena adotar. Registe o valor devolvido por GarbageCollectObjects no trabalho em lote que faz as suas eliminações de páginas, e observe-o ao longo de algumas semanas de documentos reais. Um zero num ficheiro que acabou de cortar a metade significa que algo a montante ainda mantém uma referência que não esperava, normalmente uma entrada de name tree, um destino de outline ou um campo de AcroForm que sobreviveu à página a que estava ligado. O coletor é o depurador de alcançabilidade mais barato que alguma vez vai ter, porque responde à pergunta que o próprio formato PDF se recusa a responder
O garbage collector, o registo de opções de gravação e a API de purga de recursos aqui descritos fazem parte da losLab PDF Library para Delphi e C++Builder, cuja página de produto contém a referência completa do pipeline de gravação, incluindo a interação entre a coleção, o empacotamento de object streams e a linearização