Artigo Técnico

Comparar dois arquivos PDF no Delphi: estrutura e pixels

O HotPDF compara dois documentos PDF a partir do Delphi por meio de THPDFDocComparison, que percorre o grafo de objetos dos dois arquivos a partir do catálogo para fora e, quando solicitado, também renderiza cada par de páginas e mede os pixels que diferem. O resultado é um relatório JSON que nomeia cada diferença encontrada, o orçamento consumido e se a comparação chegou até o fim. As duas passagens importam, porque um diff estrutural e um diff visual respondem a perguntas diferentes

A pergunta por trás do recurso costuma ser uma pergunta de release. Um motor de relatórios recebe uma mudança, a saída é regenerada, e alguém precisa decidir se algo mudou. Abrir os dois arquivos lado a lado funciona até cerca de três páginas, depois a atenção falha. Comparar bytes brutos falha de imediato, já que duas execuções do mesmo gerador produzem bytes diferentes por motivos que nada têm a ver com o que um leitor vê

Por que PDFs podem ser diferentes em bytes e idênticos visualmente?

Dois PDFs gerados de forma independente que imprimem de forma idêntica costumam diferir em seus bytes, e os motivos são estruturais, não cosméticos. Os números de objeto são atribuídos na ordem em que os objetos são escritos. Subconjuntos de fontes alocam CIDs na ordem em que os glifos são encontrados pela primeira vez, então um subconjunto construído durante uma travessia ligeiramente diferente produz bytes de fluxo de conteúdo diferentes para o mesmo texto visível. Os deslocamentos da tabela de referência cruzada mudam sempre que algo anterior muda de tamanho

É por isso que os números de objeto não podem ser usados como identidade entre documentos. Em vez disso, o HotPDF constrói cada snapshot percorrendo a partir do catálogo, expandindo dicionários na ordem de bytes de suas chaves e arrays por índice, de modo que cada objeto é nomeado pelo caminho que leva até ele. Objetos que a travessia não consegue alcançar a partir da raiz recaem para um caminho sintético $Unreachable[...] que carrega o número do objeto e a geração, o que mantém o conteúdo órfão visível no relatório em vez de simplesmente ausente

Os fluxos (streams) não são comparados por cópia. Cada stream contribui com uma assinatura SHA-256 incremental, calculada enquanto a posição original do stream é restaurada em seguida, de modo que comparar dois arquivos de cem megabytes não significa materializar duzentos megabytes duas vezes

Alinhando páginas quando um documento tem uma inserção

Comparar a página 1 com a página 1, a página 2 com a página 2 e assim por diante só é correto quando nada foi inserido. Insira uma página de capa e uma comparação ingênua relata todas as páginas como alteradas, o que é tecnicamente verdadeiro e operacionalmente inútil

O HotPDF alinha as páginas antes de compará-las. Ele constrói uma assinatura por página a partir do texto extraível, recorre a uma assinatura estrutural para páginas sem texto e, então, calcula a maior subsequência crescente sobre os índices de destino correspondidos. As páginas dentro dessa subsequência são as que apenas se deslocaram; as páginas fora dela são movimentos genuínos. Essa distinção é o que torna legível o diff de um manual de 400 páginas, porque o relatório diz que uma página foi inserida, em vez de dizer que quatrocentas páginas mudaram

Executando uma comparação estrutural

A chamada mais simples recebe dois documentos carregados e um modo. cmStructural executa a travessia do grafo de objetos, cmRenderedImage executa a comparação de pixels, cmFull faz as duas coisas, e os modos mais leves cmPageCount, cmPageText e cmObjectCount existem para verificações rápidas e baratas:

uses
  HPDFDoc, HPDFDocCompare;

var
  DocA, DocB: THotPDF;
  Report: AnsiString;
begin
  DocA := THotPDF.Create(nil);
  DocB := THotPDF.Create(nil);
  try
    if (DocA.LoadFromFile('baseline.pdf') <= 0) or
       (DocB.LoadFromFile('candidate.pdf') <= 0) then
      Exit;
    Report := THPDFDocComparison.Compare(DocA, DocB, cmStructural);
    with TFileStream.Create('diff.json', fmCreate) do
    try
      WriteBuffer(Report[1], Length(Report));
    finally
      Free;
    end;
  finally
    DocB.Free;
    DocA.Free;
  end;
end;

O relatório distingue três estados que um booleano não consegue distinguir. identical diz se algo mudou, comparisonComplete diz se a travessia terminou, e comparisonBudget nomeia o limite que a interrompeu, se algum interrompeu. Uma comparação que esgota um orçamento relata comparisonComplete=false e identical=false ao mesmo tempo, porque uma travessia truncada não tem base para afirmar igualdade. Qualquer automação que leia apenas identical acabará tratando uma parada por orçamento como uma diferença real, então leia os três campos

Quais limites mantêm a travessia contida?

Os padrões em THPDFStructuralCompareLimits.Default são dimensionados para documentos reais, não para documentos adversariais, e cada orçamento semanticamente relevante tem seu próprio teto: 250.000 objetos, 2.000.000 de arestas, profundidade 128, 10.000 diferenças relatadas, 64 MB por stream e 512 MB de bytes de stream no total, 1 MB por valor e 4.096 bytes por caminho. Eleve-os deliberadamente quando conhecer seu corpus, e reduza-os ao comparar arquivos vindos de fora:

var
  Limits: THPDFStructuralCompareLimits;
  Options: THPDFRenderedCompareOptions;
begin
  Limits := THPDFStructuralCompareLimits.Default;
  Limits.MaxDifferences := 200;        // falha rápido na CI
  Limits.MaxTotalStreamBytes := 128 * 1024 * 1024;

  Options := THPDFRenderedCompareOptions.Default;
  Options.DPI := 150;                  // o padrão é 72
  Options.ColorTolerance := 2;         // ignora ruído de arredondamento de 1-2 níveis
  Options.MinimumSimilarity := 0.9995;
  Options.MaxChangedPixelRatio := 0.0005;
  Options.GenerateHeatmaps := True;    // grava imagens de sobreposição para revisão

  Report := THPDFDocComparison.CompareWithOptions(DocA, DocB, cmFull,
    Limits, Options);
end;

A passagem renderizada estima a contagem de pixels a partir das dimensões da página e do DPI solicitado antes de qualquer bitmap ser alocado, e reverifica o bitmap real em seguida, de modo que uma geometria de página malformada não consegue driblar o orçamento mentindo sobre seu tamanho. Aumentar o DPI aumenta a fidelidade e o custo de forma quadrática: 150 DPI corresponde a quatro vezes os pixels de 72, e os tetos de pixels por página e totais existem justamente porque um job em lote a 300 DPI, do contrário, vai se meter em problemas de alocação

Quão semelhante é semelhante o suficiente?

Duas páginas são consideradas semelhantes apenas quando as duas condições se cumprem: a proporção de pixels alterados está igual ou abaixo de MaxChangedPixelRatio e a semelhança está igual ou acima de MinimumSimilarity. Dois limiares em vez de um, porque um punhado de pixels catastroficamente errados e uma ampla lavagem de pequenas mudanças de cor são falhas diferentes, e qualquer uma delas isoladamente pode ser aceitável em um fluxo de trabalho e desqualificante em outro. Os testes de limiar usam valores não arredondados; as seis casas decimais no JSON existem para manter os relatórios estáveis e comparáveis, não para definir a comparação

Os pixels alterados são agrupados em regiões usando blocos de tamanho fixo como nós com adjacência de quatro direções, em vez de preenchimento por inundação pixel a pixel. Isso mantém a memória contida e a lista de regiões estável entre execuções. Truncar o detalhe de região retido afeta apenas a listagem, não a contagem de regiões relatada, então uma página com mais regiões alteradas do que MaxChangedRegions ainda relata quantas realmente havia

Um comportamento vale a pena declarar explicitamente porque inverte o instinto usual. Falhas de renderizador, falhas de alocação e falhas de sobreposição nunca são engolidas silenciosamente. Qualquer uma delas é registrada como renderError ou renderBudget e força renderComparisonComplete=false, porque uma página que falhou ao renderizar é uma página que ninguém comparou, e relatá-la como idêntica é pior do que não relatar nada

Onde cada modo se encaixa em um pipeline

A comparação estrutural responde o que mudou e é o padrão certo para suítes de regressão: ela nomeia o caminho, o índice da página e os números de objeto envolvidos, então uma falha aponta diretamente para o código que a produziu. A comparação renderizada responde se alguém vai perceber, que é a pergunta certa para aprovações e para verificar se uma passagem de otimização realmente não teve perdas

Os dois modos se combinam bem. Execute cmStructural em cada build e deixe que ele falhe ruidosamente diante de mudanças inesperadas em nível de objeto; execute cmFull com mapas de calor antes de um release, quando houver uma pessoa disponível para olhar as sobreposições. Para pipelines que já emitem marcação de página por outros motivos, a saída em texto descrita em exportação de páginas PDF para SVG oferece uma terceira visão, comparável por humanos, e as verificações automatizadas em automação de relatórios de preflight cobrem questões de conformidade que nenhum dos dois modos de diff pretende responder

Comparação, preflight e renderização compartilham o mesmo modelo de objeto de documento carregado, então uma única passagem sobre um arquivo pode alimentar os três. A lista completa de recursos para Delphi e C++Builder está na página do componente PDF para Delphi HotPDF