O HotPDF compara dois documentos PDF a partir do Delphi através de THPDFDocComparison, que percorre o grafo de objetos de ambos os ficheiros 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 todas as diferenças encontradas, o orçamento consumido, e se a comparação chegou ao fim. Ambas as passagens importam, porque uma comparação estrutural e uma comparação visual respondem a perguntas diferentes
A questão por trás desta funcionalidade é, normalmente, uma questão de lançamento. Um motor de relatórios recebe uma alteração, o resultado é regenerado, e alguém tem de decidir se algo mudou. Abrir os dois ficheiros lado a lado funciona até cerca de três páginas, depois a atenção falha. Comparar bytes em bruto falha de imediato, uma vez que duas execuções do mesmo gerador produzem bytes diferentes por razões que nada têm a ver com aquilo que um leitor vê
Por que razão os PDF podem ser diferentes ao nível dos bytes mas visualmente idênticos?
Dois PDF gerados de forma independente que imprimem de forma idêntica diferem rotineiramente nos seus bytes, e as razões são estruturais, não cosméticas. Os números de objeto são atribuídos pela ordem em que os objetos calham a ser escritos. Os subconjuntos de fontes atribuem CID pela ordem em que os glifos são encontrados pela primeira vez, pelo que 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ências cruzadas mudam sempre que algo a montante muda de comprimento
É por isso que os números de objeto não podem ser usados como identidade entre documentos. O HotPDF constrói, em vez disso, cada instantâneo percorrendo a partir do catálogo, expandindo dicionários pela ordem de bytes das suas chaves e arrays por índice, pelo que cada objeto é nomeado pelo caminho que o alcança. Os objetos que a travessia não consegue alcançar a partir da raiz recuam para um caminho sintético $Unreachable[...] que transporta o número de objeto e a geração, o que mantém o conteúdo órfão visível no relatório em vez de silenciosamente ausente
Os fluxos (streams) não são comparados por cópia. Cada fluxo contribui com uma assinatura SHA-256 incremental, calculada ao mesmo tempo que a posição original do fluxo é restaurada no final, pelo que comparar dois ficheiros de cem megabytes não implica materializar duzentos megabytes duas vezes
Alinhar 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 sucessivamente só está correto quando nada foi inserido. Insira uma página de rosto e uma comparação ingénua reporta todas as páginas como alteradas, o que é tecnicamente verdade e operacionalmente inútil
O HotPDF alinha as páginas antes de as comparar. Constrói uma assinatura por página a partir do texto extraível, recua para uma assinatura estrutural nas páginas sem texto, e depois calcula a subsequência crescente mais longa 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 a comparação 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
Executar 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 ambas, e os modos mais leves cmPageCount, cmPageText e cmObjectCount existem para verificações rápidas e económicas:
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 diferiu, comparisonComplete diz se a travessia terminou, e comparisonBudget nomeia o limite que a interrompeu, se algum o fez. Uma comparação que esgota um orçamento reporta comparisonComplete=false e identical=false em conjunto, porque uma travessia truncada não tem base para afirmar igualdade. Qualquer automatismo que leia apenas identical acabará por tratar uma paragem por orçamento como uma diferença real, pelo que se deve ler os três campos
Que limites mantêm a travessia limitada?
As predefinições em THPDFStructuralCompareLimits.Default estão dimensionadas para documentos reais e não para documentos adversariais, e cada orçamento semanticamente relevante tem o seu próprio teto: 250 000 objetos, 2 000 000 arestas, profundidade 128, 10 000 diferenças reportadas, 64 MB por fluxo e 512 MB de bytes de fluxo no total, 1 MB por valor e 4096 bytes por caminho. Aumente-os deliberadamente quando conhecer o seu corpus de ficheiros, e reduza-os quando comparar ficheiros vindos do exterior:
var
Limits: THPDFStructuralCompareLimits;
Options: THPDFRenderedCompareOptions;
begin
Limits := THPDFStructuralCompareLimits.Default;
Limits.MaxDifferences := 200; // falhar depressa em CI
Limits.MaxTotalStreamBytes := 128 * 1024 * 1024;
Options := THPDFRenderedCompareOptions.Default;
Options.DPI := 150; // a predefinição é 72
Options.ColorTolerance := 2; // ignorar ruído de arredondamento de 1-2 níveis
Options.MinimumSimilarity := 0.9995;
Options.MaxChangedPixelRatio := 0.0005;
Options.GenerateHeatmaps := True; // escrever 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 da resolução (DPI) pedida antes de qualquer bitmap ser alocado, e volta a verificar o bitmap real no final, pelo que uma geometria de página malformada não consegue escapar ao orçamento mentindo sobre o seu tamanho. Aumentar o DPI aumenta a fidelidade e o custo de forma quadrática: 150 DPI equivale a quatro vezes os pixels de 72, e os tetos de pixels por página e totais existem precisamente porque um trabalho em lote a 300 DPI, de outro modo, acabaria por se meter em problemas de alocação
Quão semelhante é semelhante o suficiente?
Duas páginas só contam como semelhantes quando ambas as condições se verificam: o rácio de pixels alterados está no ou abaixo de MaxChangedPixelRatio e a semelhança está no ou acima de MinimumSimilarity. Dois limiares em vez de um, porque um punhado de pixels catastroficamente errados e uma onda ampla de pequenos desvios de cor são falhas diferentes, e qualquer uma delas, isoladamente, pode ser aceitável num fluxo de trabalho e desqualificante noutro. 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 em texto, 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 limitada 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 reportada, pelo que uma página com mais regiões alteradas do que MaxChangedRegions continua a reportar quantas houve
Vale a pena afirmar claramente um comportamento, porque inverte o instinto habitual. As falhas do renderizador, as falhas de alocação e as falhas de sobreposição nunca são engolidas. Qualquer coisa desse tipo é registada 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 reportá-la como idêntica é pior do que não reportar nada
Onde é que cada modo se encaixa num pipeline
A comparação estrutural responde ao que mudou e é a predefinição correta para suítes de regressão: nomeia o caminho, o índice de página e os números de objeto envolvidos, pelo que uma falha aponta para o código que a produziu. A comparação renderizada responde se alguém vai reparar, que é a pergunta certa para aprovações e para verificar se uma passagem de otimização foi realmente sem perdas
Combinam-se bem. Execute cmStructural em cada compilação e deixe-o falhar ruidosamente perante alterações inesperadas ao nível do objeto; execute cmFull com mapas de calor antes de um lançamento, quando há uma pessoa disponível para olhar para as sobreposições. Para pipelines que já emitem marcação de página por outras razões, o resultado em texto descrito em exportar páginas PDF para SVG dá uma terceira vista, comparável por um humano, e as verificações automatizadas em automação de relatórios de preflight cobrem questões de conformidade que nenhum dos modos de comparação pretende responder
A comparação, o preflight e a renderização partilham o mesmo modelo de objetos de documento carregado, pelo que uma única passagem sobre um ficheiro pode alimentar os três. A lista completa de funcionalidades para Delphi e C++Builder está na página do componente PDF para Delphi HotPDF