Artigo Técnico

Diff visual de PDF em Delphi: pixéis e emparelhamento

A comparação visual do PDFlibPas renderiza dois PDFs ao mesmo DPI e faz diff do que um leitor realmente vê. O TPDFlib.CompareRenderedPagesWith compara um par de páginas e devolve pixéis alterados, regiões, uma máscara e um overlay. O TPDFlib.CompareDocumentVisualsWith e o TPDFlib.CompareFileVisuals emparelham documentos inteiros página a página, detetam páginas inseridas e removidas, e reportam tudo como JSON

O caso de uso é o teste de regressão visual. Alguém reescreve o template da fatura, muda o pacote de fontes de versão, ou troca o motor de renderização, e a questão não é saber se os bytes mudaram (mudam sempre) mas se algum cliente verá uma diferença. Um diff estrutural responde a outra questão. Dois ficheiros podem diferir em todos os content streams e ainda assim pintar páginas idênticas, e um único operando deslocado pode mexer num total em três pontos sem tocar em nada que um extrator de texto assinalasse. Os pixéis são a única camada onde "parece igual" é testável

O que compara afinal um diff visual de PDF?

O PDFlibPas compara o resultado visível numa página branca, não o conteúdo bruto dos buffers. Cada página é renderizada através do RenderPageToString como um BMP não comprimido, descodificada para um bitmap top-down, e qualquer pixel que traga alpha é composto sobre branco antes de os canais serem comparados. Um bitmap RGB e um bitmap RGBA que pareçam idênticos no ecran comparam-se por isso como idênticos, que é exatamente o que se quer quando os dois lados vieram de caminhos de renderização diferentes

O descodificador BMP tem uma particularidade deliberada que convém conhecer. Um bitmap de 32 bits BI_RGB transporta um quarto byte por pixel, mas o formato deixa esse byte indefinido: uns escritores enchem-no com 0, outros com 255, outros com sobras. O PLDecodeVisualBMP ignora-o e guarda a cor. Tratar esse byte como opacidade permitiria que um renderizador que escreve zeros apagasse todos os pixéis da página, e o diff reportaria alegremente dois documentos brancos e idênticos. Se alimenta os seus próprios bitmaps à API de nível mais baixo, o alpha só é honrado quando você declara o formato como vpfRGBA ou vpfBGRA

Um pixel conta como alterado quando qualquer um dos seus canais vermelho, verde ou azul difere em mais de ColorTolerance. Tolerância 0 significa bit-exact. Na prática um valor de um dígito absorve o jitter de anti-aliasing entre builds do renderizador sem esconder um glifo alterado, porque uma alteração real de texto mexe os canais em muito mais do que isso. Escolha o número a partir de uma corrida de base conhecida como boa, não por adivinhação

Comparar um par de páginas com o CompareRenderedPagesWith

O TPDFlib.CompareRenderedPagesWith é a ferramenta quando já sabe que páginas pertencem juntas e quer detalhe: uma bounding box, regiões de alteração conectadas, uma máscara por pixel opcional e um overlay avermelhado opcional. O record de opções e os tipos de resultado vivem na unidade PDFlibVisualCompare, e o PLDefaultVisualCompareOptions dá-lhe tolerância 0, mosaicos de 32 pixéis, sem regiões a ignorar e sem máscara nem overlay

uses
  PDFlibrary, PDFlibVisualCompare;

var
  Baseline, Candidate: TPDFlib;
  Options: TPDFVisualCompareOptions;
  Comparison: TPDFVisualCompareResult;
  ErrorText: WideString;
begin
  Baseline := TPDFlib.Create;
  Candidate := TPDFlib.Create;
  try
    if (Baseline.LoadFromFile('invoice-v1.pdf', '') = 1) and
       (Candidate.LoadFromFile('invoice-v2.pdf', '') = 1) then
    begin
      Options := PLDefaultVisualCompareOptions;
      Options.ColorTolerance := 8;
      Options.IncludeOverlay := True;
      // Mascarar o carimbo de impressão; unidades em pixéis no DPI abaixo
      SetLength(Options.IgnoreRects, 1);
      Options.IgnoreRects[0].Left := 600;
      Options.IgnoreRects[0].Top := 20;
      Options.IgnoreRects[0].Right := 790;
      Options.IgnoreRects[0].Bottom := 50;

      if Baseline.CompareRenderedPagesWith(Candidate, 1, 1, 96, Options,
        Comparison, ErrorText) then
      begin
        if Comparison.ChangedPixels > 0 then
          Writeln(PLVisualCompareResultToJSON(Comparison));
      end
      else
        Writeln('Compare failed: ', ErrorText);
    end;
  finally
    Candidate.Free;
    Baseline.Free;
  end;
end;

Três detalhes mordem as pessoas aqui. Os retângulos a ignorar estão em pixéis renderizados com origem no canto superior esquerdo e intervalos meio abertos, por isso Right e Bottom são exclusivos e o mesmo retângulo cobre uma parte diferente da página se mudar o DPI. Retângulos a ignorar sobrepostos contam-se uma vez, e os pixéis ignorados aparecem em IgnoredPixels em vez de desaparecer, o que mantém o ChangedRatio honesto porque divide apenas por pixéis comparados. Por fim, a chamada de página única exige que ambas as renderizações tenham as mesmas dimensões; uma página que mudou de tamanho falha com um erro em vez de ser esticada para caber

As regiões saem de uma passagem de mosaicos: os pixéis alterados são agrupados em quadrados TileSize e mosaicos adjacentes fundem-se numa só região com os seus próprios limites e contagem de pixéis. Mosaicos maiores dão menos regiões, mais grossas, o que geralmente é o que um relatório de teste quer. O overlay é uma cópia RGB da segunda página com os pixéis alterados tingidos de vermelho, devolvida como bytes brutos em Comparison.Overlay; escrevê-la num PNG para os artefactos do seu CI é código seu, não da biblioteca

Como é que o emparelhamento ao nível do documento trata páginas inseridas?

O TPDFlib.CompareDocumentVisualsWith emparelha páginas com uma longest common subsequence sobre impressões digitais exatas de pixéis, por isso uma página inserida não transforma todas as páginas seguintes numa alteração falsa. Um loop ingénuo de página 1 para página 1, página 2 para página 2 é inútil no momento em que um template ganha uma folha de rosto: todas as páginas depois da inserção ficam desfasadas em um e o relatório acende vermelho de cima a baixo

Cada página é renderizada e reduzida a uma chave feita da sua largura e altura em pixéis e de um digest SHA-256 dos pixéis visíveis. O tamanho importa mais do que parece: uma página vertical toda branca e uma página horizontal toda branca da mesma área têm buffers de pixéis byte-identicos, e sem as dimensões na chave ancorariam uma à outra. Chaves idênticas dos dois lados tornam-se âncoras, a LCS escolhe a maior cadeia ordenada delas, e as páginas entre duas âncoras consecutivas são emparelhadas por ordem. Quando um intervalo guarda mais páginas de um lado, o excedente é reportado como added ou removed

var
  Options: TPDFVisualCompareOptions;
  Report: WideString;
begin
  // Baseline e Candidate carregados como antes, com definições de renderização iguais
  Options := PLDefaultVisualCompareOptions;
  Options.ColorTolerance := 8;
  Options.TileSize := 64;
  Report := Baseline.CompareDocumentVisualsWith(Candidate, 96, Options);
  if Baseline.LastErrorCode <> 0 then
    Writeln('Visual diff failed: ', Report)
  else
    Writeln(Report);
end;

As âncoras são bit-exact, e isso é uma escolha de design e não uma lacuna. O ColorTolerance aplica-se apenas à comparação de páginas emparelhadas, nunca à impressão digital. Uma página sem nada além de ruído de anti-aliasing não vai ancorar, mas continua a cair num intervalo, é emparelhada por ordem com a sua contraparte e depois compara como unchanged sob tolerância. Âncoras difusas seriam mais indulgentes e muito menos previsíveis, porque páginas quase duplicadas (separadores em branco, páginas de termos repetidas) começariam a emparelhar-se umas com as outras

Seja claro quanto ao que este alinhamento é. O JSON chama-lhe exact-raster-lcs-gap-order de propósito: é um alinhamento raster, não reconhecimento semântico de páginas. Se um intervalo contém uma página reescrita e uma página inserida, o emparelhamento por ordem pode comparar a reescrita contra a inserção e reportar a original como removida. Documentos cheios de páginas idênticas são igualmente ambíguos. Quando o emparelhamento importa para um veredicto, olhe para os pares, não só para os contadores

Ler o relatório JSON

O relatório de documento tem uma forma fixa que se pode verificar num test runner. Em sucesso traz "status":"complete", "complete":true, o nome do alinhamento, firstPageCount, secondPageCount, e os contadores changedPages, addedPages, removedPages e unchangedPages. O array pairs lista depois cada linha com firstPage, secondPage (0 para o lado em falta) e um kind de unchanged, changed, size_changed, added ou removed

Linhas de tipo changed e unchanged incorporam um objeto comparison com width, height, comparedPixels, changedPixels, ignoredPixels, changedRatio, boundingBox (null quando nada mudou) e uma lista regions de bounds mais changedPixels. Uma linha size_changed não tem comparação nenhuma; reporta firstWidth, firstHeight, secondWidth e secondHeight e conta para changedPages. Esticar uma página redimensionada para a comparar produziria uma parede de pixéis alterados sem sentido, por isso a biblioteca reporta a alteração de geometria e fica por aí

O relatório de documento nunca contém máscaras nem overlays. O CompareDocumentVisualsWith força IncludeMask e IncludeOverlay a desligados mesmo que as suas opções os peçam, para a memória se manter estável à medida que a contagem de páginas cresce em vez de guardar uma pré-visualização de página inteira por cada par. Use o relatório para encontrar as páginas interessantes, e depois chame o CompareRenderedPagesWith só nessas páginas para obter o overlay. Falhas voltam como "status":"error", "complete":false com uma string error, e o LastErrorCode fica em 401

Comparar contra um ficheiro com o CompareFileVisuals

O TPDFlib.CompareFileVisuals é a forma de chamada única para CI: carrega o outro PDF, copia para ele as definições atuais do renderizador e devolve o mesmo JSON de documento. A assinatura é CompareFileVisuals(OtherFileName, Password, DPI, ColorTolerance), em que a tolerância tem de ser 0 a 255 e o DPI tem de ser finito e positivo

var
  Lib: TPDFlib;
  Report: WideString;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('baseline\statement.pdf', '') = 1 then
    begin
      Report := Lib.CompareFileVisuals('build\statement.pdf', '', 96, 8);
      Writeln(Report);
    end;
  finally
    Lib.Free;
  end;
end;

A herança do renderizador é a razão para preferir esta chamada a carregar o segundo ficheiro você mesmo. O documento carregado assume a escala de renderização, o tipo de crop e as definições gráficas da instância atual, mais a escolha de motor Cairo ou PDFium quando estes estão compilados. Se construir duas instâncias de TPDFlib à mão para o CompareDocumentVisualsWith, configurar ambas de forma idêntica é problema seu, e um desfasamento aparece como todas as páginas alteradas. Também não aceita regiões a ignorar; recorra à API tipada quando precisar delas

Limites, custos e onde encaixa o diff estrutural

O PDFlibPas limita o trabalho explicitamente para uma entrada hostil ou enorme falhar com um erro em vez de esgotar a memória. Uma página renderizada não pode exceder 64 Mi pixéis, a grelha de mosaicos está limitada a 4 Mi mosaicos, e o alinhamento de páginas recusa entradas em que a soma das duas contagens de páginas passe 8192 ou a matriz LCS exceda 4 Mi células. A comparação de documentos também renderiza cada página duas vezes, uma para a impressão digital e outra para a comparação emparelhada, por isso faça contas a isso a 150 DPI e acima. Para a maioria das suítes de regressão, 72 a 96 DPI apanha alterações de layout e de texto numa fração do tempo

Diff visual e diff estrutural respondem a questões diferentes, e um pipeline a sério normalmente quer ambos. A comparação de pixéis diz-lhe se um leitor repararia em algo. Não lhe diz se um documento assinado ganhou uma anotação que renderiza fora da página, nem se uma atualização incremental trocou em silêncio o valor de um campo de formulário pelo mesmo texto visível. Para essa camada, o artigo sobre classificar o que mudou num PDF depois de assinado percorre o diff ao nível da revisão. E porque a escolha do renderizador determina os pixéis, vale a pena ler renderização PDF multi-motor em Delphi antes de fixar uma baseline a um só motor

FAQ

A comparação visual do PDFlibPas estica páginas que mudaram de tamanho?

Não. O CompareRenderedPagesWith devolve um erro para páginas de tamanho de pixel diferente, e o relatório de documento marca esses pares como size_changed com ambas as dimensões em vez de os comparar

Porque não se usam páginas quase idênticas como âncoras?

As âncoras usam uma impressão digital SHA-256 exata dos pixéis visíveis mais o tamanho da página, para o alinhamento se manter previsível. A tolerância aplica-se depois, quando as páginas emparelhadas são comparadas, por isso páginas de só ruído acabam mesmo assim como unchanged

Dá para obter uma imagem overlay para todas as páginas de um documento?

Não a partir da chamada de documento, que omite deliberadamente máscaras e overlays para manter a memória estável. Use o JSON para encontrar as páginas alteradas, e depois chame o CompareRenderedPagesWith com IncludeOverlay nessas páginas

A comparação visual, o emparelhamento de páginas e o resto das APIs de renderização e de documentos vêm com a PDFlibPas PDF library for Delphi