Artigo Técnico

Garbage Collection de PDF em Delphi: Mark and Sweep

Excluir uma página de um PDF não exclui suas fontes, imagens ou content streams. A losLab PDF Library recupera esses recursos com um coletor mark-sweep que percorre o grafo de objetos a partir das raízes do trailer e remove todo objeto indireto que nada mais alcança. Ele roda em um full save, vem desativado por padrão, e retorna o número de objetos descartados

Por que excluir páginas do PDF não reduz o arquivo?

Porque a exclusão de página é uma edição de referência, não uma operação de armazenamento. DeletePages(StartPage, PageCount) desvincula os objetos de página da árvore de páginas e corrige as entradas de outline que apontavam para eles. O que ele não consegue fazer é decidir que o font program, o content stream e a image XObject usados por essas páginas agora estão mortos, porque no momento da exclusão nada no arquivo registra quem mais poderia ainda estar apontando para eles. Esses objetos permanecem na lista de objetos do documento, e um full save grava todos eles de volta. O resultado é a reclamação que abre a maioria desses tickets de suporte: o cliente exclui noventa por cento das páginas, salva, e o arquivo encolhe dois por cento. Pior, o vazamento se acumula. Carregar, excluir, salvar, carregar de novo, excluir de novo, salvar de novo, e o arquivo cresce de forma monotônica enquanto a contagem de páginas cai. Esse é um problema diferente do resolvido por font subsetting e image downsampling, que tornam objetos vivos menores. Aqui os objetos não estão grandes demais. Eles simplesmente não fazem mais parte do documento

O root set é o trailer, não a árvore de páginas

O grafo de objetos do PDF não tem campo de referência reversa. O formato não define contagem de referências nem lista de back-pointers, 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 em um objeto indireto diz quem aponta para ele, então a pergunta "alguém ainda está usando o objeto 47" tem exatamente uma resposta: percorrer a partir de uma raiz conhecida e ver se você chega lá. É por isso que o coletor na losLab PDF Library é um coletor mark-sweep, e não um esquema de refcount

As raízes vêm do trailer do arquivo (ISO 32000-1 §7.5.5). Três chaves as carregam: /Root, o document catalog da §7.7.2 do qual pendem a árvore de páginas, names, outlines, AcroForm e metadados; /Info, o document information dictionary; e /Encrypt, o encryption dictionary. As duas chaves restantes do trailer são distrações. /ID é um array de duas byte strings, e /Prev é um deslocamento inteiro em bytes para a seção de cross-reference anterior. Nenhuma das duas é uma referência indireta, então nenhuma contribui como raiz. A losLab PDF Library enfileira o trailer dictionary 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 percurso em si é iterativo, não recursivo. Quando a travessia encontra uma referência indireta, ela registra apenas o número do objeto e a geração, marca o slot correspondente e o empurra para uma fila FIFO em vez de desreferenciar imediatamente, 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 decodificado duas vezes. Dictionaries, arrays e stream dictionaries diretos vão para uma segunda fila protegida por um conjunto de visitados, porque documentos reais contêm ciclos genuínos: uma página /Parent aponta de volta para seu nó na árvore de páginas, e itens de outline se encadeiam via /Prev e /Next nas duas direções. Números de geração fazem parte do match, não são decoração. Uma referência só se resolve quando número de objeto e geração concordam; uma referência a um número que existe em outra geração é tratada como o objeto nulo que a especificação exige, nunca como uma aresta viva

Como habilitar o garbage collection em um save?

Garbage collection é opt-in e pertence ao registro de opções de save. O padrão é False porque o coletor é uma passagem destrutiva sobre o grafo de objetos, e nenhuma biblioteca deveria excluir objetos silenciosamente sem que quem a chamou tenha pedido para examiná-los

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;

Outros dois pontos de entrada chegam ao mesmo coletor. SetGarbageCollect(1) define a flag no documento selecionado para que um SaveToFile comum a respeite, e GarbageCollectObjects roda a passagem imediatamente e retorna o número de objetos indiretos órfãos removidos. A forma imediata é a que se usa quando você quer um número para logar ou para um assert, e vale a pena checá-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;

Esse caminho de falha importa mais do que parece. Objetos são decodificados de forma preguiçosa, e um objeto que nunca foi decodificado não expõe nenhuma referência. Se o coletor tratasse um objeto não decodificável como um nó vazio, ele varreria tudo que só era alcançável através dele. Então a travessia força a decodificação à medida que toca cada objeto, e um único erro de decodificação aborta a passagem inteira com resultado negativo e deixa o documento byte a byte idêntico. Varrer um grafo que você só entende parcialmente é a maneira de um coletor transformar um arquivo danificado em um arquivo destruído

O que quebra um coletor de PDF ingênuo?

Dois detalhes, e ambos falham silenciosamente em vez de ruidosamente. O primeiro são os object streams. Desde o PDF 1.5, um objeto que não seja stream pode viver comprimido dentro de um container /ObjStm (§7.5.7), e sua entrada de cross-reference é uma entrada tipo 2 que nomeia o container mais um índice dentro dele. Um objeto comprimido, portanto, só é alcançável através do seu container. Marque o membro, varra o container porque nada o referenciou como objeto de documento, e você escreveu um arquivo cujo xref aponta para um objeto que não existe mais. O container é armazenamento estrutural, não dado de documento, então ele nunca aparece como uma aresta no grafo de objetos que você está percorrendo. A losLab PDF Library lida com isso desvinculando todo membro comprimido sobrevivente do seu container de origem antes que os containers desapareçam, e depois disso o save reempacota os sobreviventes em object streams novos. O segundo detalhe é o que um stream object realmente referencia. Os bytes não fazem parte do grafo. Um content stream que desenha texto com /F1 12 Tf nomeia uma fonte pelo nome de recurso, e esse nome é resolvido pelo dictionary /Resources da página, então a aresta de alcançabilidade é página → /Resources/Font → objeto de fonte, nunca pelo payload do stream. As únicas referências que um stream contribui vêm do seu dictionary, onde /Length, /Filter e /DecodeParms podem todos ser indiretos. Um coletor que analisa os bytes do stream procurando referências está fazendo trabalho caro à toa; um coletor que ignora stream dictionaries perde o objeto de length e corrompe o arquivo

O que acontece com os números de objeto que você libera

Eles viram entradas livres, e não são reutilizados no mesmo save. A varredura percorre a lista de objetos em ordem decrescente para que as exclusões fiquem estáveis por índice, reconstrói o índice de lookup uma única vez no final em vez de a cada remoção, e para cada objeto removido registra o número na free list com sua geração incrementada em um, exatamente como a §7.5.4 especifica para uma entrada que pode ser reutilizada mais tarde. Uma geração já em 65535 permanece lá, marcando esse número como aposentado permanentemente. Números de objeto deliberadamente não são compactados. Depois de uma coleta, o arquivo mantém buracos: o objeto 12 pode estar livre enquanto 13 e 14 estão em uso, e o /Size do trailer continua reportando o maior número mais um, não a contagem de sobreviventes. Isso é legal e normal. Renumerar economizaria alguns bytes na cross-reference table e exigiria reescrever toda referência no documento, o tipo de mudança que invalida silenciosamente qualquer coisa que guarde números de objeto vindos de fora. O tamanho que você recupera vem dos corpos dos objetos, não da tabela xref

Quando você não deve rodar o coletor

Nunca em um incremental update. O coletor é restrito a full saves e a flag simplesmente não é lida quando o documento está sendo apendado, e essa restrição não é uma limitação para se contornar. Um incremental update (§7.5.6) deixa os bytes originais intocados e adiciona uma nova seção de cross-reference encadeada à anterior via /Prev. Toda revisão anterior ainda aponta para os objetos que sempre apontou, então um objeto inalcançável na revisão atual é perfeitamente alcançável em uma mais antiga. Excluí-lo quebraria toda revisão menos a última, e os detalhes disso estão cobertos no artigo sobre incremental updates e saves em modo append. O mesmo raciocínio descarta garbage collection em um documento assinado, porque a reescrita completa que torna a coleta possível é exatamente o que invalida a assinatura

Também vale deixar claro o que a coleta não é. Ela não é um sanitizador. O coletor remove objetos que nada referencia; ele não tem opinião sobre se o conteúdo deles 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 arquivo menor, o grafo de objetos é a camada errada, e redação no nível de instrução e sanitização de documento é a certa. As duas se combinam bem nessa ordem: redija e sanitize primeiro, depois colete, para que os objetos que a redação desvinculou realmente saiam do arquivo. A mesma combinação existe na API de resource purge, onde passar a opção de garbage-collect faz a purga rodar uma coleta em seguida e reportar os órfãos removidos em OrphanObjectsRemoved

Um último hábito que vale a pena adotar. Registre o valor de retorno de GarbageCollectObjects em qualquer job em lote que faça suas exclusões de página, e observe isso ao longo de algumas semanas com documentos reais. Um zero em um arquivo que você acabou de cortar pela metade significa que algo a montante ainda está segurando uma referência que você não esperava, geralmente uma entrada de name tree, um destino de outline ou um campo de AcroForm que sobreviveu à página à qual estava anexado. O coletor é o depurador de alcançabilidade mais barato que você vai ter, porque ele responde à pergunta que o próprio formato PDF se recusa a responder

O garbage collector, o registro de save-options e a API de resource purge descritos aqui fazem parte da losLab PDF Library para Delphi e C++Builder, cuja página de produto traz a referência completa do pipeline de save incluindo a interação entre coleta, empacotamento de object streams e linearização