O PDFium Component for Delphi (PDFiumPas) encolhe ficheiros PDF com três APIs de reescrita em TPdf: o SaveAsReachable deita fora objetos que nada referencia, o SaveAsDeduplicated funde fontes, imagens e Form XObjects byte-idênticos, e o SaveAsFlateRecompressed recodifica streams Flate existentes à compressão máxima. Cada uma devolve um record de relatório para poder ver o que foi removido e quantos bytes poupou
O gatilho habitual é um bilhete de suporte que diz "apagámos vinte páginas e o ficheiro ficou maior". Alguém abriu um contrato, removeu um anexo, fundiu uma folha de rosto e gravou. Cada passo foi uma atualização incremental, por isso os objetos antigos continuam no ficheiro, e quando esse documento é mais tarde carregado e escrito com FPDF_SaveAsCopy eles voltam a sair. O PDFium serializa o que quer que assente na sua tabela de objetos em memória; não tem chamada pública para listar objetos indiretos, apagar um, ou reescrever referências. Tudo neste artigo corre por isso na camada de wrapper PDFiumPas por cima dos bytes que o PDFium produz
Porque é que o FPDF_SaveAsCopy mantém páginas apagadas no ficheiro?
O FPDF_SaveAsCopy mantém objetos órfãos porque o PDFium carrega para o documento todas as entradas vivas na tabela de cross-reference, aponte ou não nada para elas. A ISO 32000-1 §7.5.6 descreve as atualizações incrementais como acrescentar novas versões de objetos e uma nova secção xref ao fim do ficheiro. 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 essa página continuam listados como objetos em uso. Uma gravação completa 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 fusões de páginas, flattening de formulários feito por outras ferramentas, e gravações de reparação de ficheiros danificados. São invisíveis em todos os leitores e não acrescentam nada além de bytes. Em documentos digitalizados, uma única imagem de página órfã pode valer vários megabytes
Mark-and-sweep com o SaveAsReachable
O TPdf.SaveAsReachable remove órfãos com uma passagem clássica de mark-and-sweep: deixa o PDFium escrever uma cópia não incremental do documento atual, analisa essa cópia num grafo de objetos, marca tudo o que é alcançável a partir das raízes, e escreve uma única revisão clássica que contém só os objetos marcados. Objetos dentro de object streams são materializados como objetos indiretos ordinários ao longo do caminho, por isso o output usa sempre 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. A partir daí a travessia alcança a árvore de páginas, recursos de página, fontes, imagens, Form XObjects, AcroForm, a árvore de estrutura, outlines, name trees, conteúdo opcional, metadados XMP, output intents e ficheiros incorporados. Mas o dicionário de informação 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 apaga em silêncio o Title, o Author e o Producer de todos os ficheiros que processar. O PDFiumPas semeia a travessia com ambos /Root e /Info, e o trailer reconstruído só leva o /Info para a frente quando esse objeto realmente sobreviveu
O percorredor é uma travessia depth-first com uma pilha explícita e um conjunto de visitados, por isso ciclos de referências (ligações de pai na árvore de páginas, back-pointers /P de anotações) não o conseguem pendurar. Uma referência a um número de objeto já livre simplesmente não é seguida em vez de ser tratada como erro, o que mantém a passagem tolerante a entrada ligeiramente danificada
Os números de objeto preservam-se, não são renumerados
O TPdf.SaveAsReachable mantém cada objeto sobrevivente no seu número de objeto 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 lista livre. Porque nada é renumerado, toda a referência que sobrevive continua correta sem um único byte de reescrita, e não há mapeamento antigo-para-novo para errar. O custo são umas linhas xref não usadas, 20 bytes cada, uma troca que vale a pena fazer
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 transporta SourceByteCount (o tamanho da cópia não incremental que o PDFium produziu), OutputByteCount, KeptObjectCount, PrunedObjectCount, Succeeded e ErrorMessage. O overload de nome de ficheiro escreve para um ficheiro temporário ao lado do alvo e só substitui o alvo quando a reescrita tem sucesso, por isso uma corrida falhada nunca deixa para trás um PDF truncado
Que ficheiros estas APIs recusam?
Todas as três APIs de reescrita recusam documentos encriptados e, por predefinição, assinados. Uma entrada encriptada 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 encriptação depende, por isso o output ficaria ilegível. Um documento com assinaturas digitais levanta EPdfError a menos que passe AllowSignedDocument = True, porque uma reescrita completa invalida sempre o intervalo de bytes assinado. Pôr essa flag significa aceitar um ficheiro cujas assinaturas reportarão como partidas; é a decisão certa para uma cópia de arquivo, nunca para um contrato que está prestes a devolver
Como decide o SaveAsDeduplicated que duas imagens são a mesma?
O TPdf.SaveAsDeduplicated só trata dois recursos como idênticos quando os seus dicionários, todos os seus subgrafos alcançáveis e os seus bytes brutos de stream coincidem todos. Corre primeiro a mesma travessia de alcançabilidade, depois tira a impressão digital de cada fonte, image XObject e Form XObject alcançável, funde cada grupo de coincidências no membro com o número de objeto mais baixo, e reescreve cada referência para apontar para esse sobrevivente. Duplicados fundidos tornam-se entradas xref livres tal como órfãos. Esta é a correção para o clássico relatório de "logótipo incorporado 400 vezes" produzido por ferramentas de mail-merge que reincorporam os mesmos recursos em cada página
A impressão digital é um hash FNV-1a de 64 bits calculado recursivamente. Para cada candidato o percorredor toma o corpo do objeto, substitui cada referência N G R pela impressão digital do objeto para que aponta, e faz o hash do resultado. Isso torna o hash independente da numeração de objetos: duas cópias da mesma fonte cujos descritores calharem ser os objetos 12 e 87 produzem na mesma o mesmo valor. Os resultados são memoizados para que subobjetos partilhados sejam hashed uma vez, e um conjunto de visitados devolve um sentinela fixo para back edges de modo a que grafos cíclicos terminem
O bug subtil assenta no payload do stream. Para encontrar referências com segurança, o PDFiumPas analisa uma cópia de cada objeto na qual os dados de stream, strings e comentários foram apagados com espaços; caso contrário, uma sequência de dados binários de pixéis que por acaso pareça 12 0 R seria tratada como referência. Fazer hash só dessa cópia sanitizada e duas imagens com dicionários idênticos (mesmos /Width, /Height, /BitsPerComponent, /Filter, /Length) mas pixéis diferentes colapsariam numa única impressão digital, e a segunda fotografia da página seria substituída em silêncio pela primeira. O SaveAsDeduplicated acrescenta os bytes brutos entre stream e endstream aos dados com hash, por isso os pixéis fazem parte da identidade. Os candidatos também são agrupados por categoria antes da comparação, por isso uma rara colisão de hash entre uma fonte e uma imagem nunca os pode fundir
A reescrita de referências reutiliza o mesmo truque da análise sanitizada ao contrário: as referências localizam-se na cópia apagada mas os bytes são copiados do corpo original, por isso só a estrutura de dicionários e arrays é alguma vez 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 atenção. Ao lado dos três campos Merged*Count tem KeptObjectCount e PrunedObjectCount, e neste relatório o PrunedObjectCount conta só duplicados fundidos. Objetos inalcançáveis continuam a ser deitados fora, mas não são incluídos nesse número. Se precisar da contagem de órfãos para o log, tire-a de uma corrida separada de SaveAsReachable. O outro limite é que a coincidência é byte-exata no stream codificado: o mesmo logótipo comprimido por dois produtores diferentes a dois níveis Flate diferentes não será fundido
Recomprimir streams Flate ao nível máximo
O TPdf.SaveAsFlateRecompressed desinfla cada stream FlateDecode alcançável e volta a insuflá-lo ao nível de compressão mais alto, guardando o resultado só quando é mais pequeno e passa os seus limiares. Muitos produtores escrevem Flate a um nível predefinido rápido, por isso content streams e programas de fontes incorporados muitas vezes encolhem mais. Números e gerações de objetos ficam onde estão; só o payload e o seu /Length direto mudam
O TPdfFlateRecompressOptions tem dois campos e nenhum construtor predefinido, por isso defina ambos. O MinSavingBytes é a poupança absoluta mínima por stream, e o MinSavingRatio é uma fração do comprimento original (0.05 significa 5%); um rácio de 0 desliga o teste de rácio. O stream reconstruído é escrito com stream, CRLF, o novo payload, CRLF e endstream, por isso o resultado não depende dos fins de linha do ficheiro original
procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
Options: TPdfFlateRecompressOptions;
Report: TPdfFlateRecompressReport;
begin
Options.MinSavingBytes := 256; // ignorar ganhos minúsculos
Options.MinSavingRatio := 0.05; // e tudo 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 casos de limite são conservadores por design. Um stream cujo /Length é uma referência indireta é saltado, porque a reescrita tem de guardar um inteiro direto. Um payload que falhe ao desinflar conta para o FailedCount e é copiado inalterado. Um stream Flate que não passe os limiares conta para o SkippedCount. Imagens DCT, JPX, JBIG2 e CCITT nunca entram na lista de candidatos, por isso esta passagem nunca toca na qualidade JPEG. Para poupanças de imagens com perdas use reamostragem adaptativa de imagens no PDFiumPas antes da gravação final
Uma armadilha de portabilidade de escrever esse teste de limiar: o primeiro rascunho comparava Single(Saving)/Single(OrigLen) com Saving declarado como Int64. O Delphi aceita-o, mas o FPC 3.2.2 para com Illegal type conversion: "Int64" to "Single", porque em 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-se diretamente com um Single, por isso os casts eram simplesmente desnecessários
Em que ordem devem correr as três APIs?
Corra o SaveAsDeduplicated primeiro, recarregue o output, e depois corra o SaveAsFlateRecompressed. A eliminação de duplicados já inclui a varredura de alcançabilidade, por isso uma passagem separada de SaveAsReachable à frente dela é redundante; guarde o SaveAsReachable para trabalhos em que só quer limpeza de órfãos e uma contagem exata de podados. Recomprimir em segundo significa que o trabalho caro de desinflar e insuflar corre uma vez por stream sobrevivente em vez de uma vez por duplicado. A passagem de recompressão também percorre o grafo a partir de /Root e /Info outra vez, o que limpa descritores de fontes ou ficheiros de fontes a que só uma fonte duplicada fundida 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 + fusão
Pdf.Active := False;
Pdf.FileName := TempPdf;
Pdf.Active := True;
RecompressFlate(Pdf, TargetPdf); // re-desinflar sobreviventes
finally
Pdf.Free;
DeleteFile(TempPdf);
end;
end;
Se também aplica redações, faça-o antes de tudo isto, já que remover conteúdo fisicamente é o que cria órfãos novos em primeiro lugar; o artigo sobre redação ao nível de operador e poda de recursos cobre o lado da redação. E recarregue sempre o ficheiro final como verificação de sanidade no seu pipeline: uma contagem de páginas que bata com a origem é um seguro barato antes de sobrepor qualquer coisa de que um cliente goste
FAQ
O SaveAsReachable renumera objetos ou muda referências?
Não. O TPdf.SaveAsReachable mantém cada objeto sobrevivente no seu número e geração originais e marca os objetos removidos como entradas xref livres, por isso nenhuma referência tem de ser reescrita
O SaveAsDeduplicated pode fundir por engano duas imagens diferentes?
Não só por dicionários coincidentes. A impressão digital inclui os bytes brutos do stream e o subgrafo referenciado completo, e os candidatos só são comparados dentro da mesma categoria (fonte, imagem, Form XObject)
Estas APIs mantêm as minhas assinaturas digitais válidas?
Não. Cada API executa uma reescrita completa, que parte o intervalo de bytes assinado, por isso ficheiros assinados são recusados a menos que passe AllowSignedDocument = True e aceite assinaturas inválidas no output
Todas as três APIs vêm com o PDFium Component for Delphi and C++Builder, ao lado das funcionalidades de redação e reamostragem de imagens cobertas noutros artigos deste blogue