Technický článek

Vizuální PDF diff v Delphi: pixely a párování stránek

Vizuální srovnání v PDFlibPas vykreslí obě PDF ve stejném DPI a diffuje to, co čtečka skutečně vidí. TPDFlib.CompareRenderedPagesWith srovná jednu dvojici stránek a vrátí změněné pixely, oblasti, masku a overlay. TPDFlib.CompareDocumentVisualsWith a TPDFlib.CompareFileVisuals párují celé dokumenty strana po straně, detekují vložené a odebrané stránky a vše reportují jako JSON

Případ použití je vizuální regresní testování. Někdo přepíše šablonu faktury, povýší fontový balíček nebo vymění renderovací engine a otázkou není, zda se bajty změnily (mění vždycky), ale zda nějaký zákazník uvidí rozdíl. Strukturální diff odpovídá na jinou otázku. Dva soubory se mohou lišit v každém content streamu a přesto malovat identické stránky a jediný posunutý operand může pohnout součtem o tři body, aniž by se dotkl čehokoli, co by textový extraktor označil. Pixely jsou jediná vrstva, kde je "vypadá stejně" testovatelné

Co vizuální PDF diff doopravdy srovnává?

PDFlibPas srovnává viditelný výsledek na bílé stránce, ne syrový obsah bufferů. Každá stránka se vykreslí přes RenderPageToString jako nekomprimované BMP, dekóduje do bitmapy shora dolů a každý pixel nesoucí alfu se před porovnáním kanálů složí přes bílou. RGB bitmapa a RGBA bitmapa, které na obrazovce vypadají stejně, se proto porovnají jako identické — přesně to chcete, když obě strany přišly z různých renderovacích cest

BMP dekodér má jednu záměrnou zvláštnost, o které stojí za to vědět. 32bitová bitmapa BI_RGB nese čtvrtý bajt na pixel, ale formát tento bajt nedefinuje: někteří zapisovači ho plní nulou, někteří 255 a někteří zbytky. PLDecodeVisualBMP ho ignoruje a barvu zachová. Brát ten bajt jako krytí by znamenalo, že renderer zapisující nuly smaže každý pixel na stránce a diff by rád ohlásil dva prázdné identické dokumenty. Krmíte-li do API nižší úrovně vlastní bitmapy, alfa se respektuje jen tehdy, když formát sami deklarujete jako vpfRGBA nebo vpfBGRA

Pixel se počítá jako změněný, když se kterýkoli z jeho kanálů červené, zelené nebo modré liší o víc než ColorTolerance. Tolerance 0 znamená shodu na bit. V praxi jednociferná hodnota pohltí chvění anti-aliasingu mezi buildy rendereru, aniž by schovala změněný znak, protože skutečná změna textu pohne kanály o hodně víc. Číslo si vyberte z ověřeného základního běhu, ne hádáním

Srovnání jedné dvojice stránek přes CompareRenderedPagesWith

TPDFlib.CompareRenderedPagesWith je nástroj pro případ, kdy už víte, které stránky k sobě patří, a chcete detail: bounding box, souvislé změněné oblasti, volitelnou masku po pixelech a volitelný overlay zbarvený do červena. Záznam voleb a typy výsledků žijí v unitě PDFlibVisualCompare a PLDefaultVisualCompareOptions dává toleranci 0, dlaždice 32 pixelů, žádné ignorované oblasti a žádnou masku ani 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;
      // Zamaskujte tiskové razítko času; pixelové jednotky při DPI níže
      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;

Tři detaily tady kousají. Ignorované obdélníky jsou ve vykreslených pixelech s počátkem vlevo nahoře a polouzavřenými intervaly, takže Right a Bottom jsou exkluzivní a tentýž obdélník pokryje jinou část stránky, když změníte DPI. Překrývající se ignorované obdélníky se počítají jednou a ignorované pixely se objeví v IgnoredPixels, aniž by zmizely, takže ChangedRatio zůstává poctivý, protože dělí jen porovnanými pixely. Nakonec volání pro jednu stránku vyžaduje, aby obě vykreslení měla stejný rozměr; stránka, která změnila velikost, selže chybou místo aby se natáhla na míru

Oblasti vycházejí z průchodu dlaždicemi: změněné pixely se sypou do čtverců TileSize a sousední dlaždice se sloučí do jedné oblasti s vlastními mezemi a počtem pixelů. Větší dlaždice dají méně a hrubších oblastí, což je obvykle to, co test report chce. Overlay je RGB kopie druhé stránky se změněnými pixely zbarvenými do červena, vrácená jako syrové bajty v Comparison.Overlay; zapsat ji do PNG pro artefakty CI je věc vašeho kódu, ne knihovny

Jak párování stránek na úrovni dokumentu zachází s vloženými stránkami?

TPDFlib.CompareDocumentVisualsWith páruje stránky nejdelší společnou podsekvencí (LCS) nad přesnými otisky pixelů, takže jedna vložená stránka nepřemění každou následující na falešnou změnu. Naivní smyčka stránka 1 na stránku 1, stránka 2 na stránku 2 je k ničemu v okamžiku, kdy šablona dostane titulní list: každá stránka po vložení je posunutá o jednu a report svítí červeně odshora dolů

Každá stránka se vykreslí a zredukuje na klíč složený z pixelové šířky, výšky a SHA-256 digestu viditelných pixelů. Rozměr má větší váhu, než vypadá: celobílá stránka na výšku a celobílá stránka na šířku o stejné ploše mají bajtově identické pixelové buffery a bez rozměrů v klíči by se kotvily navzájem. Identické klíče na obou stranách se stanou kotvami, LCS vybere z nich nejdelší uspořádaný řetěz a stránky mezi dvěma sousedními kotvami se párují v pořadí. Když jedna mezera drží na jedné straně víc stránek, přebytek se reportuje jako added nebo removed

var
  Options: TPDFVisualCompareOptions;
  Report: WideString;
begin
  // Baseline a Candidate načtené jako dřív, se shodným nastavením vykreslení
  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;

Kotvy jsou přesné na bit a je to záměr, ne opomenutí. ColorTolerance se vztahuje jen na srovnání párovaných stránek, nikdy na otisk. Stránka, na které není nic než šum anti-aliasingu, se nezakotví, ale pořád spadne do mezery, spáruje se v pořadí se svým protějškem a pak se pod tolerancí porovná jako unchanged. Rozmazané kotvy by byly shovívavější a mnohem méně předvídatelné, protože téměř duplicitní stránky (prázdné oddělovače, opakované stránky podmínek) by se začaly párovat navzájem

Mějte jasno v tom, co toto zarovnání je. JSON ho jmenuje exact-raster-lcs-gap-order záměrně: je to rastrové zarovnání, ne sémantické rozpoznávání stránek. Pokud mezera obsahuje přepsanou stránku a vloženou stránku, párování v pořadí může srovnat přepis s vložkou a ohlásit originál jako removed. Dokumenty plné identických stránek jsou podobně dvouznačné. Když na párování verdiktu záleží, koukejte na páry, ne jen na počítadla

Čtení JSON reportu

Dokumentový report má pevný tvar, na který můžete test runnerem assertovat. Při úspěchu nese "status":"complete", "complete":true, název zarovnání, firstPageCount, secondPageCount a počítadla changedPages, addedPages, removedPages a unchangedPages. Pole pairs pak vypisuje každý řádek s firstPage, secondPage (0 pro chybějící stranu) a kind o hodnotě unchanged, changed, size_changed, added nebo removed

Řádky druhu changed a unchanged vkládají objekt comparison s width, height, comparedPixels, changedPixels, ignoredPixels, changedRatio, boundingBox (null, když se nic nezměnilo) a seznam regions z bounds plus changedPixels. Řádek size_changed nemá žádné srovnání; reportuje firstWidth, firstHeight, secondWidth a secondHeight a počítá se do changedPages. Natáhnout stránku se změněným formátem, aby se dala srovnat, by vyprodukovalo zeď nesmyslných změněných pixelů, takže knihovna ohlásí změnu geometrie a tam skončí

Dokumentový report nikdy neobsahuje masky ani overlaye. CompareDocumentVisualsWith vypne IncludeMask a IncludeOverlay, i když je vaše volby žádají, aby paměť při rostoucím počtu stránek zůstala plochá místo držet náhled celé stránky pro každý pár. Report použijte k nalezení zajímavých stránek a pak zavolejte CompareRenderedPagesWith jen na nich, abyste overlay dostali. Selhání přijdou jako "status":"error", "complete":false s řetězcem error a LastErrorCode se nastaví na 401

Srovnání se souborem přes CompareFileVisuals

TPDFlib.CompareFileVisuals je forma na jedno volání pro CI: načte druhé PDF, zkopíruje na něj aktuální nastavení rendereru a vrátí tentýž dokumentový JSON. Signatura je CompareFileVisuals(OtherFileName, Password, DPI, ColorTolerance), kde tolerance musí být 0 až 255 a DPI musí být konečné a kladné

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;

Dědičnost rendereru je důvod, proč toto volání preferovat nad vlastním načtením druhého souboru. Načtený dokument převezme renderovací měřítko, typ ořezu a grafická nastavení aktuální instance, plus volbu enginu Cairo nebo PDFium, jsou-li zakompilované. Stavíte-li dvě instance TPDFlib ručně kvůli CompareDocumentVisualsWith, jejich identická konfigurace je na vás a nesouhlas se projeví jako každá stránka změněná. Toto volání navíc nemůže brát ignorované oblasti; sáhněte po typovaném API, když je potřebujete

Limity, cena a kam patří strukturální diff

PDFlibPas ohraničuje práci explicitně, aby nepřátelský nebo obří vstup selhal chybou místo vyčerpat paměť. Vyrenderovaná stránka nesmí přesáhnout 64 Mi pixelů, dlaždicová mřížka má strop 4 Mi dlaždic a zarovnání stránek odmítá vstupy, kde součet obou počtů stránek přeleze 8192 nebo kde matice LCS přesáhne 4 Mi buněk. Srovnání dokumentů navíc renderuje každou stránku dvakrát, jednou pro otisk a znovu pro párované srovnání, takže s tím počítejte na 150 DPI a výš. Pro většinu regresních sad chytne 72 až 96 DPI změny layoutu a textu za zlomek času

Vizuální diff a strukturální diff odpovídají na různé otázky a seriózní pipeline chce obvykle obojí. Pixelové srovnání vám řekne, zda by čtečka něco zaznamenala. Neřekne vám, zda podepsaný dokument dostal anotaci, která se renderuje mimo stránku, nebo zda inkrementální aktualizace potichu vyměnila hodnotu formulářového pole za tentýž viditelný text. Pro tu vrstvu prochází třídění změn PDF po podpisu diff na úrovni revizí. A protože volba rendereru řídí pixely, víceenginové vykreslování PDF v Delphi stojí za přečtení, než nějaký engine ukotvíte jako základnu

FAQ

Natahuje vizuální srovnání PDFlibPas stránky, které změnily velikost?

Ne. CompareRenderedPagesWith vrací chybu u stránek s různou pixelovou velikostí a dokumentový report označí takové páry size_changed s oběma rozměry místo je srovnávat

Proč se téměř identické stránky nepoužívají jako kotvy?

Kotvy používají přesný SHA-256 otisk viditelných pixelů plus velikost stránky, takže zarovnání zůstává předvídatelné. Tolerance se aplikuje poté, když se párované stránky srovnávají, takže stránky se samým šumem skončí i tak jako unchanged

Dostanu obrázek overlay pro každou stránku dokumentu?

Ne z volání na dokument, které masky a overlaye záměrně vynechává, aby paměť rostla plochě. JSON použijte k nalezení změněných stránek a pak zavolejte CompareRenderedPagesWith s IncludeOverlay na nich

Vizuální srovnání, párování stránek a zbytek renderovacích a dokumentových API dodává PDFlibPas PDF library for Delphi