Teknisk artikel

Visuel PDF-diff i Delphi: Pixelsammenligning og sideparning

PDFlibPas visuel sammenligning renderer to PDF-filer ved samme DPI og diff'er det, en læser rent faktisk ser. TPDFlib.CompareRenderedPagesWith sammenligner ét sidepar og returnerer ændrede pixels, regioner, en maske og et overlay. TPDFlib.CompareDocumentVisualsWith og TPDFlib.CompareFileVisuals parer hele dokumenter side for side, opdager indsatte og fjernede sider og rapporterer alt som JSON

Brugsscenariet er visuel regressionstestning. Nogen omskriver fakturaskabelonen, opdaterer en fontpakke eller skifter rendering engine, og spørgsmålet er ikke, om bytes ændrede sig (det gør de altid), men om nogen kunde vil se en forskel. En strukturel diff svarer på et andet spørgsmål. To filer kan afvige i hver eneste content stream og stadig male identiske sider, og én forskudt operand kan flytte en total med tre point uden at røre noget, som en tekst-ekstraktor ville flagge. Pixels er det eneste lag, hvor "ser ens" kan testes

Hvad sammenligner en visuel PDF-diff egentlig?

PDFlibPas sammenligner det synlige resultat på en hvid side, ikke rå buffer-indhold. Hver side renderes gennem RenderPageToString som en ukomprimeret BMP, afkodes til en top-down bitmap, og enhver pixel, der bærer alpha, compositeres over hvidt, før kanalerne sammenlignes. En RGB-bitmap og en RGBA-bitmap, der ser identiske ud på skærmen, sammenlignes derfor som identiske, hvilket er præcis, hvad man vil have, når de to sider kommer fra forskellige render-paths

BMP-afkoderen har én bevidst særhed, værd at kende. En 32-bit BI_RGB-bitmap bærer en fjerde byte pr. pixel, men formatet lader den byte udefineret: nogle skrivere fylder den med 0, nogle med 255, nogle med rester. PLDecodeVisualBMP ignorerer den og beholder farven. Behandlede man den byte som opacitet, ville en renderer, der skriver nuller, kunne slette hver pixel på siden, og diff'en ville gladelig rapportere to blanke, identiske dokumenter. Giver man egne bitmaps til lavniveau-API'et, æres alpha kun, når man selv erklærer formatet som vpfRGBA eller vpfBGRA

En pixel tæller som ændret, når en af dens røde, grønne eller blå kanaler afviger med mere end ColorTolerance. Tolerance 0 betyder bit-eksakt. I praksis absorberer en ét-cifret værdi anti-aliasing-jitter mellem renderer-builds uden at skjule et ændret glyf, for en reel tekstændring flytter kanalerne langt mere. Tag tallet fra en kendt-god baseline-kørsel, ikke ved at gætte

Sammenligning af ét sidepar med CompareRenderedPagesWith

TPDFlib.CompareRenderedPagesWith er værktøjet, når man allerede ved, hvilke sider der hører sammen, og vil have detaljer: en bounding box, sammenhængende ændringsregioner, en valgfri maske pr. pixel og et valgfrit rødtonet overlay. Options-recorden og resultattyperne ligger i PDFlibVisualCompare-uniten, og PLDefaultVisualCompareOptions giver tolerance 0, 32-pixel-tiles, ingen ignore-regioner og ingen maske eller 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;
      // Maskér print-tidsstemplet; pixel-enheder ved DPI'en nedenfor
      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;

Tre detaljer bider folk her. Ignore-rektangler er i renderede pixels med øverste venstre hjørne som origo og halvåbne intervaller, så Right og Bottom er eksklusive, og det samme rektangel dækker en anden del af siden, hvis man ændrer DPI'en. Overlappende ignore-rektangler tælles én gang, og ignorerede pixels dukker op i IgnoredPixels i stedet for at forsvinde, hvilket holder ChangedRatio ærlig, fordi den kun dividerer med sammenlignede pixels. Til sidst kræver enkeltside-kaldet, at begge renders har samme dimensioner; en side, der ændrede størrelse, fejler med en fejl i stedet for at blive strakt til at passe

Regionerne kommer fra et tile-pass: ændrede pixels sættes i TileSize-kvadrater, og nabo-tiles fusioneres til én region med egne grænser og pixelantal. Større tiles giver færre, grovere regioner, hvilket normalt er, hvad en testrapport vil have. Overlayet er en RGB-kopi af den anden side med ændrede pixels tonet røde, returneret som rå bytes i Comparison.Overlay; at skrive den til en PNG til CI-artefakter er ens egen kode, ikke bibliotekets

Hvordan håndterer dokumentniveau-sideparning indsatte sider?

TPDFlib.CompareDocumentVisualsWith parer sider med en longest common subsequence over eksakte pixel-fingerprints, så én indsat side ikke forvandler hver efterfølgende side til en falsk ændring. En naiv side 1 til side 1-, side 2 til side 2-løkke er ubrugelig i det øjeblik, en skabelon får et forsideark: hver side efter indsættelsen er forskudt med én, og rapporten lyser rødt fra top til bund

Hver side renderes og reduceres til en nøgle bestående af pixelbredde, -højde og en SHA-256-digest af de synlige pixels. Størrelsen betyder mere, end den ligner: en helhvid portrætside og en helhvid landskabsside med samme areal har byte-identiske pixel-buffere, og uden dimensionerne i nøglen ville de forankre til hinanden. Identiske nøgler på begge sider bliver ankre, LCS vælger den længste ordnede kæde af dem, og siderne mellem to efterfølgende ankre pares i rækkefølge. Når ét mellemrum rummer flere sider på den ene side, rapporteres overskuddet som added eller removed

var
  Options: TPDFVisualCompareOptions;
  Report: WideString;
begin
  // Baseline og Candidate indlæst som før, med matchende render-indstillinger
  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;

Ankrene er bit-eksakte, og det er et designvalg snarere end en forglemmelse. ColorTolerance gælder kun sammenligningen af parede sider, aldrig fingerprintet. En side med intet andet end anti-aliasing-støj vil ikke forankre, men den lander stadig i et mellemrum, pares i rækkefølge med sin modpart og sammenlignes så som unchanged under tolerancen. Fuzzy-ankre ville være mere tilgivende og langt mindre forudsigelige, for near-duplicate-sider (blanke separatorsider, gentagne vilkårssider) ville begynde at matche hinanden

Vær klar over, hvad denne alignment er. JSON'en navngiver den med vilje exact-raster-lcs-gap-order: det er en raster-alignment, ikke semantisk sidegenkendelse. Indeholder et mellemrum en omskrevet side og en indsat side, kan in-order-paring sammenligne omskrivningen mod indsættelsen og rapportere originalen som fjernet. Dokumenter fulde af identiske sider er lige så tvetydige. Når paring betyder noget for en dom, så kig på parrene, ikke kun tællerne

At læse JSON-rapporten

Dokumentrapporten har en fast form, man kan asserte på i en test runner. Ved succes bærer den "status":"complete", "complete":true, alignment-navnet, firstPageCount, secondPageCount og tællerne changedPages, addedPages, removedPages og unchangedPages. pairs-arrayet lister derefter hver række med firstPage, secondPage (0 for den manglende side) og en kind af unchanged, changed, size_changed, added eller removed

Rækker af typen changed og unchanged indlejrer et comparison-objekt med width, height, comparedPixels, changedPixels, ignoredPixels, changedRatio, boundingBox (null, når intet ændrede sig) og en regions-liste af bounds plus changedPixels. En size_changed-række har slet ingen comparison; den rapporterer firstWidth, firstHeight, secondWidth og secondHeight og tæller med i changedPages. At strække en side med ny størrelse for at sammenligne den ville frembringe en mur af meningsløse ændrede pixels, så biblioteket rapporterer geometriændringen og stopper dér

Dokumentrapporten indeholder aldrig masker eller overlays. CompareDocumentVisualsWith tvinger IncludeMask og IncludeOverlay fra, selv hvis ens options beder om dem, så hukommelsen forbliver flad, når sidetallet vokser, i stedet for at holde et helside-preview for hvert par. Brug rapporten til at finde de interessante sider, og kald så CompareRenderedPagesWith på netop de sider for at få overlayet. Fejler kommer tilbage som "status":"error", "complete":false med en error-streng, og LastErrorCode sættes til 401

Sammenligning mod en fil med CompareFileVisuals

TPDFlib.CompareFileVisuals er ét-kalds-formen til CI: den indlæser den anden PDF, kopierer de aktuelle render-indstillinger over på den og returnerer samme dokument-JSON. Signaturen er CompareFileVisuals(OtherFileName, Password, DPI, ColorTolerance), hvor tolerance skal være 0 til 255, og DPI skal være endelig og positiv

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;

Renderer-arven er grunden til at foretrække dette kald frem for selv at indlæse den anden fil. Det indlæste dokument overtager den aktuelle instans' render scale, crop type og grafik-indstillinger plus valget af Cairo- eller PDFium-engine, når de er kompileret ind. Bygger man to TPDFlib-instanser i hånden til CompareDocumentVisualsWith, er identisk konfiguration af begge ens eget ansvar, og en mismatch viser sig som hver side ændret. Den kan heller ikke tage ignore-regioner; ræk ud efter det typede API, når man har brug for dem

Grænser, omkostning og hvor strukturel diff passer ind

PDFlibPas afgrænser arbejdet eksplicit, så et fjendtligt eller kæmpe input fejler med en fejl i stedet for at udtømme hukommelsen. En renderet side må ikke overstige 64 Mi pixels, tile-gitteret er begrænset til 4 Mi tiles, og side-alignment nægter input, hvor de to sidetal summer over 8192, eller hvor LCS-matricen ville overstige 4 Mi celler. Dokumentsammenligning renderer også hver side to gange, én gang til fingerprintet og igen til den parede sammenligning, så budgetter med det ved 150 DPI og opefter. Til de fleste regressionssuiter fanger 72 til 96 DPI layout- og tekstændringer på en brøkdel af tiden

Visuel diff og strukturel diff svarer på forskellige spørgsmål, og et alvorligt pipeline vil normalt have begge. Pixelsammenligningen fortæller, om en læser ville bemærke noget. Den kan ikke fortælle, om et signeret dokument fik en annotation, der renderes uden for siden, eller om en inkrementel opdatering stille erstattede en formularfeltværdi med samme synlige tekst. Til det lag gennemgår klassificering af, hvad der ændrede sig i en PDF, efter den blev signeret diff på revisionsniveau. Og fordi valget af renderer driver pixels, er multi-engine PDF-rendering i Delphi værd at læse, før man fæstner en baseline til én engine

FAQ

Strækker PDFlibPas visuel sammenligning sider, der ændrede størrelse?

Nej. CompareRenderedPagesWith returnerer en fejl for sider med forskellig pixelstørrelse, og dokumentrapporten markerer sådanne par size_changed med begge dimensioner i stedet for at sammenligne dem

Hvorfor bruges near-identiske sider ikke som ankre?

Ankre bruger et eksakt SHA-256-fingerprint af de synlige pixels plus sidestørrelse, så alignment forbliver forudsigelig. Tolerance anvendes bagefter, når parede sider sammenlignes, så rene støjsider stadig ender som unchanged

Kan man få et overlay-billede til hver side af et dokument?

Ikke fra dokumentkaldet, som med vilje udelader masker og overlays for at holde hukommelsen flad. Brug JSON'en til at finde ændrede sider, og kald så CompareRenderedPagesWith med IncludeOverlay på de sider

Visuel sammenligning, sideparning og resten af renderings- og dokument-API'erne følger med PDFlibPas PDF library til Delphi