O PDFium Component para Delphi (PDFiumPas) encolhe arquivos PDF com três APIs de reescrita no TPdf: o SaveAsReachable descarta objetos que nada referencia, o SaveAsDeduplicated mescla fontes, imagens e Form XObjects byte-idênticos, e o SaveAsFlateRecompressed re-codifica streams Flate existentes na compressão máxima. Cada um retorna um record de relatório para você ver o que foi removido e quantos bytes economizou
O gatilho usual é um ticket de suporte que diz "apagamos vinte páginas e o arquivo ficou maior". Alguém abriu um contrato, removeu um apêndice, mesclou uma capa e salvou. Cada passo foi um update incremental, então os objetos antigos continuam no arquivo, e quando esse documento é carregado e escrito depois com FPDF_SaveAsCopy eles saem de volta. O PDFium serializa o que estiver sentado na sua tabela de objetos na memória; ele não tem chamada pública para listar objetos indiretos, apagar um ou reescrever referências. Tudo neste artigo, portanto, roda na camada wrapper do PDFiumPas por cima dos bytes que o PDFium produz
Por que o FPDF_SaveAsCopy mantém páginas apagadas no arquivo?
O FPDF_SaveAsCopy mantém objetos órfãos porque o PDFium carrega toda entrada viva da tabela de cross-reference no seu documento, tenha ou não algo ainda apontando para ela. A ISO 32000-1 §7.5.6 descreve updates incrementais como anexar novas versões de objetos e uma nova seção xref ao fim do arquivo. Remover uma página só edita a árvore de páginas; o dicionário da página, o seu content stream, e quaisquer fontes ou imagens usadas só por aquela página continuam listados como objetos em uso. Um salvamento completo não incremental não muda isso, porque do ponto de vista da tabela de objetos esses objetos são perfeitamente válidos
As mesmas sobras aparecem depois de mesclagens de páginas, flattening de formulários feito por outras ferramentas e salvamentos de reparo de arquivos danificados. Elas são invisíveis em todo viewer e não adicionam nada além de bytes. Em documentos escaneados, uma única imagem de página órfã pode ter vários megabytes
Mark-and-sweep com SaveAsReachable
O TPdf.SaveAsReachable remove órfãos com uma passada clássica de mark-and-sweep: ele deixa o PDFium escrever uma cópia não incremental do documento atual, faz o parse dessa cópia em um grafo de objetos, marca tudo que é alcançável a partir das raízes e escreve uma única revisão clássica que contém apenas os objetos marcados. Objetos dentro de object streams são materializados como objetos indiretos comuns no caminho, então a saída sempre usa uma tabela xref simples
Escolher as raízes é onde uma implementação ingênua erra. A semente óbvia é o Catalog nomeado pelo /Root do trailer. De lá a caminhada alcança a árvore de páginas, recursos de página, fontes, imagens, Form XObjects, AcroForm, a árvore de estrutura, outlines, name trees, optional content, metadados XMP, output intents e arquivos embutidos. Mas o dicionário de informações do documento é referenciado pela entrada /Info do trailer (ISO 32000-1 §7.5.5), e não pelo Catalog. Semear só a partir do /Root e você apaga em silêncio o Title, Author e Producer de todo arquivo que processar. O PDFiumPas semeia a caminhada com ambos /Root e /Info, e o trailer reconstruído só carrega o /Info adiante quando esse objeto de fato sobreviveu
O walker é uma travessia depth-first com pilha explícita e um conjunto de visitados, então ciclos de referência (links de pai na árvore de páginas, back-pointers /P de anotações) não conseguem travá-lo. Uma referência a um número de objeto que já está livre simplesmente não é seguida em vez de ser tratada como erro, o que mantém a passada tolerante a entrada levemente danificada
Números de objeto são preservados, não renumerados
O TPdf.SaveAsReachable mantém todo objeto sobrevivente no seu número e geração originais. Objetos varridos são escritos como entradas livres na tabela xref (ISO 32000-1 §7.5.4), os vãos entre números vivos são preenchidos com entradas livres, e o objeto 0 permanece a cabeça da free list. Como nada é renumerado, toda referência que sobrevive continua correta sem um único byte de reescrita, e não há mapeamento de antigo para novo para errar. O custo são algumas linhas xref não usadas, 20 bytes cada, uma troca que vale a pena
uses
PDFium, FPdfCompress;
procedure PruneOrphans(const SourcePdf, TargetPdf: string);
var
Pdf: TPdf;
Report: TPdfPruneReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourcePdf;
Pdf.Active := True;
if not Pdf.SaveAsReachable(TargetPdf, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('%d bytes -> %d bytes, kept %d object(s), pruned %d',
[Report.SourceByteCount, Report.OutputByteCount,
Report.KeptObjectCount, Report.PrunedObjectCount]));
finally
Pdf.Free;
end;
end;
O TPdfPruneReport carrega SourceByteCount (o tamanho da cópia não incremental que o PDFium produziu), OutputByteCount, KeptObjectCount, PrunedObjectCount, Succeeded e ErrorMessage. O overload de nome de arquivo escreve em um arquivo temporário ao lado do alvo e só substitui o alvo quando a reescrita funcionou, então uma execução que falha nunca deixa um PDF truncado para trás
Quais arquivos essas APIs recusam?
As três APIs de reescrita recusam documentos criptografados e, por padrão, assinados. Uma entrada criptografada falha com Succeeded = False e uma mensagem de erro em vez de uma exceção: uma reescrita de revisão única não consegue manter os números de objeto, intervalos de bytes e chaves por objeto de que o dicionário de criptografia depende, então a saída seria ilegível. Um documento com assinaturas digitais levanta EPdfError a menos que você passe AllowSignedDocument = True, porque uma reescrita completa sempre invalida o intervalo de bytes assinado. Definir essa flag significa aceitar um arquivo cujas assinaturas vão reportar como quebradas; é a decisão certa para uma cópia de arquivo morto, nunca para um contrato que você está prestes a devolver
Como o SaveAsDeduplicated decide que duas imagens são a mesma?
O TPdf.SaveAsDeduplicated trata dois recursos como idênticos apenas quando os seus dicionários, os seus subgrafos alcançáveis inteiros e os seus bytes brutos de stream todos batem. Ele roda a mesma caminhada de alcançabilidade primeiro, depois tira a fingerprint de toda fonte, image XObject e Form XObject alcançável, mescla cada grupo de correspondências no membro com o menor número de objeto, e reescreve toda referência para apontar para esse sobrevivente. Duplicatas mescladas viram entradas xref livres como os órfãos. Essa é a correção para o clássico relatório de "logo embutido 400 vezes" produzido por ferramentas de mail-merge que re-embutem os mesmos recursos em cada página
A fingerprint é um hash FNV-1a de 64 bits calculado recursivamente. Para cada candidato, o walker pega o corpo do objeto, substitui toda referência N G R pela fingerprint do objeto para o qual aponta, e faz o hash do resultado. Isso torna o hash independente da numeração de objetos: duas cópias da mesma fonte cujos descriptors por acaso são os objetos 12 e 87 ainda produzem o mesmo valor. Os resultados são memoizados para que subobjetos compartilhados tenham hash calculado uma vez, e um conjunto de visitados retorna um sentinel fixo para back edges, de modo que grafos cíclicos terminam
O bug sutil senta no payload do stream. Para achar referências com segurança, o PDFiumPas varre uma cópia de cada objeto em que os dados do stream, strings e comentários foram apagados com espaços; caso contrário, uma sequência de dados binários de pixel que por acaso pareça 12 0 R seria tratada como referência. Fazer o hash só dessa cópia sanitizada e duas imagens com dicionários idênticos (mesmos /Width, /Height, /BitsPerComponent, /Filter, /Length) mas pixels diferentes colapsam para uma fingerprint, e a segunda foto da página é silenciosamente substituída pela primeira. O SaveAsDeduplicated anexa os bytes brutos entre stream e endstream aos dados com hash, então os pixels fazem parte da identidade. Os candidatos também são agrupados por categoria antes da comparação, então uma rara colisão de hash entre uma fonte e uma imagem nunca pode mesclá-los
A reescrita de referências reutiliza o mesmo truque de varredura sanitizada ao contrário: as referências são localizadas na cópia apagada, mas os bytes são copiados do corpo original, então só a estrutura de dicionário e array é modificada e os payloads binários passam intocados
procedure MergeDuplicateResources(Pdf: TPdf; const TargetPdf: string);
var
Report: TPdfDedupReport;
begin
if not Pdf.SaveAsDeduplicated(TargetPdf, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('fonts %d, images %d, forms %d merged; %d -> %d bytes',
[Report.MergedFontCount, Report.MergedImageCount,
Report.MergedFormXObjectCount,
Report.SourceByteCount, Report.OutputByteCount]));
end;
Leia o TPdfDedupReport com cuidado. Ao lado dos três campos Merged*Count ele tem KeptObjectCount e PrunedObjectCount, e neste relatório o PrunedObjectCount conta apenas duplicatas mescladas. Objetos não alcançáveis ainda são descartados, mas não entram nesse número. Se você precisa da contagem de órfãos para logs, tire-a de uma execução separada do SaveAsReachable. O outro limite é que o casamento é byte-exato no stream codificado: o mesmo logo comprimido por dois produtores diferentes em dois níveis de Flate diferentes não será mesclado
Recomprimindo streams Flate no nível máximo
O TPdf.SaveAsFlateRecompressed infla cada stream FlateDecode alcançável e o desinfla de novo no nível de compressão mais alto, mantendo o resultado apenas quando ele é menor e passa nos seus limiares. Muitos produtores escrevem Flate em um nível padrão rápido, então content streams e programas de fonte embutidos frequentemente encolhem mais. Números e gerações de objetos ficam no lugar; só o payload e o seu /Length direto mudam
O TPdfFlateRecompressOptions tem dois campos e nenhum construtor padrão, então defina os dois. O MinSavingBytes é a economia absoluta mínima por stream, e o MinSavingRatio é uma fração do comprimento original (0.05 significa 5%); uma razão de 0 desativa o teste de razão. O stream reconstruído é escrito com stream, CRLF, o novo payload, CRLF e endstream, então o resultado não depende dos finais de linha do arquivo original
procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
Options: TPdfFlateRecompressOptions;
Report: TPdfFlateRecompressReport;
begin
Options.MinSavingBytes := 256; // ignore ganhos mínimos
Options.MinSavingRatio := 0.05; // e qualquer coisa abaixo de 5%
if not Pdf.SaveAsFlateRecompressed(TargetPdf, Options, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('recompressed %d, skipped %d, failed %d; streams %d -> %d bytes',
[Report.RecompressedCount, Report.SkippedCount, Report.FailedCount,
Report.OriginalStreamBytes, Report.RecompressedStreamBytes]));
end;
Os edge cases são conservadores de propósito. Um stream cujo /Length é uma referência indireta é pulado, porque a reescrita precisa guardar um inteiro direto. Um payload que falha em inflar conta para o FailedCount e é copiado inalterado. Um stream Flate que não atinge os limiares conta para o SkippedCount. Imagens DCT, JPX, JBIG2 e CCITT nunca entram na lista de candidatos, então essa passada nunca toca na qualidade do JPEG. Para economias com perdas em imagens, use reamostragem adaptativa de imagens no PDFiumPas antes do salvamento final
Uma armadilha de portabilidade vinda de escrever esse teste de limiar: o primeiro rascunho comparava Single(Saving)/Single(OrigLen) com Saving declarado como Int64. O Delphi aceita, mas o FPC 3.2.2 para com Illegal type conversion: "Int64" to "Single", porque no FPC essa sintaxe é um typecast e não uma conversão numérica. Um Saving / OrigLen simples é divisão real em ambos os compiladores e compara diretamente contra um Single, então os casts eram simplesmente desnecessários
Em que ordem as três APIs devem rodar?
Rode o SaveAsDeduplicated primeiro, recarregue a saída, depois rode o SaveAsFlateRecompressed. A deduplicação já inclui a varredura de alcançabilidade, então uma passada separada de SaveAsReachable na frente dela é redundante; guarde o SaveAsReachable para jobs em que você só quer a limpeza de órfãos e uma contagem exata de podados. Recomprimir em segundo significa que o trabalho caro de inflar e desinflar roda uma vez por stream sobrevivente em vez de uma por duplicata. A passada de recompressão também caminha pelo grafo a partir de /Root e /Info de novo, o que limpa quaisquer font descriptors ou arquivos de fonte para os quais só uma fonte duplicada mesclada ainda apontava
procedure ShrinkPdf(const SourcePdf, TargetPdf: string);
var
Pdf: TPdf;
TempPdf: string;
begin
TempPdf := ChangeFileExt(TargetPdf, '.dedup.pdf');
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourcePdf;
Pdf.Active := True;
MergeDuplicateResources(Pdf, TempPdf); // poda + mesclagem
Pdf.Active := False;
Pdf.FileName := TempPdf;
Pdf.Active := True;
RecompressFlate(Pdf, TargetPdf); // re-desinfla sobreviventes
finally
Pdf.Free;
DeleteFile(TempPdf);
end;
end;
Se você também aplica redactions, faça isso antes de tudo, porque remover conteúdo fisicamente é o que cria novos órfãos em primeiro lugar; o artigo sobre redaction em nível de operador e poda de recursos cobre o lado da redaction. E sempre recarregue o arquivo final como um sanity check no seu pipeline: uma contagem de páginas que bate com a origem é um seguro barato antes de você sobrescrever qualquer coisa com que um cliente se importa
FAQ
O SaveAsReachable renumera objetos ou muda referências?
Não. O TPdf.SaveAsReachable mantém todo objeto sobrevivente no seu número e geração originais e marca objetos removidos como entradas xref livres, então nenhuma referência precisa ser reescrita
O SaveAsDeduplicated pode mesclar duas imagens diferentes por engano?
Não só por dicionários correspondentes. A fingerprint inclui os bytes brutos do stream e o subgrafo referenciado completo, e os candidatos são comparados apenas dentro da mesma categoria (fonte, imagem, Form XObject)
Essas APIs vão manter as minhas assinaturas digitais válidas?
Não. Cada API executa uma reescrita completa, o que quebra o intervalo de bytes assinado, então arquivos assinados são recusados a menos que você passe AllowSignedDocument = True e aceite assinaturas inválidas na saída
As três APIs vêm com o PDFium Component para Delphi e C++Builder, junto com os recursos de redaction e reamostragem de imagens cobertos em outros lugares deste blog