Teknisk artikel

Visuell PDF-diff i Delphi: pixeljämförelse och sidparning

PDFlibPas visuella jämförelse renderar två PDF:er med samma DPI och diffar det en läsare faktiskt ser. TPDFlib.CompareRenderedPagesWith jämför ett sidpar och returnerar ändrade pixlar, regioner, en mask och en overlay. TPDFlib.CompareDocumentVisualsWith och TPDFlib.CompareFileVisuals parar hela dokument sida för sida, detekterar infogade och borttagna sidor och rapporterar allt som JSON

Användningsfallet är visuell regressionstestning. Någon skriver om fakturamallen, uppdaterar ett typsnittspaket eller byter renderingsmotor, och frågan är inte om bytena ändrats (det gör de alltid) utan om någon kund kommer att se en skillnad. En strukturell diff svarar på en annan fråga. Två filer kan skilja sig i varje content stream och ändå rita identiska sidor, och en enda förskjuten operand kan flytta en summa med tre punkter utan att röra något en textextraktor skulle flagga. Pixlar är det enda lagret där "ser likadant ut" går att testa

Vad jämför en visuell PDF-diff egentligen?

PDFlibPas jämför det synliga resultatet på en vit sida, inte råa bufferinnehåll. Varje sida renderas via RenderPageToString som en okomprimerad BMP, avkodas till en top-down-bitmap, och varje pixel som bär alpha komponeras över vitt innan kanalerna jämförs. En RGB-bitmap och en RGBA-bitmap som ser identiska ut på skärmen jämförs därför som identiska, vilket är precis vad du vill ha när de två sidorna kom från olika renderingsvägar

BMP-avkodaren har en medveten egenhet värd att känna till. En 32-bitars BI_RGB-bitmap bär en fjärde byte per pixel, men formatet lämnar den byten odefinierad: vissa skrivare fyller den med 0, vissa med 255, vissa med rester. PLDecodeVisualBMP ignorerar den och behåller färgen. Att behandla byten som opacitet skulle låta en renderare som skriver nollor radera varje pixel på sidan, och difffen skulle glatt rapportera två tomma, identiska dokument. Om du matar in egna bitmaps i API:et på lägre nivå hedras alpha bara när du själv deklarerar formatet som vpfRGBA eller vpfBGRA

En pixel räknas som ändrad när någon av dess röda, gröna eller blå kanaler skiljer sig med mer än ColorTolerance. Tolerans 0 betyder bitexakt. I praktiken absorberar ett värde i ental anti-aliasing-jitter mellan renderarbyggen utan att gömma en ändrad glyph, eftersom en verklig textändring flyttar kanalerna långt mer än så. Välj siffran från en känd-god baslinjekörning, inte genom att gissa

Jämföra ett enskilt sidpar med CompareRenderedPagesWith

TPDFlib.CompareRenderedPagesWith är verktyget när du redan vet vilka sidor som hör ihop och vill ha detaljer: en begränsningsruta, sammanhängande ändringsregioner, en valfri per-pixel-mask och en valfri rödtonad overlay. Options-posten och resultattyperna bor i uniten PDFlibVisualCompare, och PLDefaultVisualCompareOptions ger dig tolerans 0, 32-pixel-tiles, inga ignore-regioner och ingen mask 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;
      // Maskera bort utskriftstidsstämpeln; pixelenheter vid DPI:n nedan
      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 biter folk här. Ignore-rektanglar är i renderade pixlar med ursprung uppe till vänster och halvöppna intervall, så Right och Bottom är exklusiva och samma rektangel täcker en annan del av sidan om du ändrar DPI. Överlappande ignore-rektanglar räknas en gång, och ignorerade pixlar dyker upp i IgnoredPixels i stället för att försvinna, vilket håller ChangedRatio ärlig eftersom den dividerar med jämförda pixlar bara. Slutligen kräver anropet för en enda sida att båda renderingarna har samma dimensioner; en sida som ändrat storlek fallerar med ett fel i stället för att sträckas ut för att passa

Regionerna kommer från ett tile-pass: ändrade pixlar sorteras i TileSize-kvadrater och angränsande tiles slås samman till en region med egna gränser och pixelantal. Större tiles ger färre, gröbere regioner, vilket oftast är vad en testrapport vill ha. Overlayen är en RGB-kopia av den andra sidan med ändrade pixlar tonade röda, returnerad som råa bytes i Comparison.Overlay; att skriva den till en PNG för dina CI-artefakter är din kod, inte bibliotekets

Hur hanterar sidparning på dokumentnivå infogade sidor?

TPDFlib.CompareDocumentVisualsWith parar sidor med en longest common subsequence över exakta pixel-fingeravtryck, så en infogad sida förvandlar inte varje följande sida till en falsk ändring. En naiv loop som parar sida 1 mot sida 1, sida 2 mot sida 2 är värdelös i ögonblicket en mall får ett försättsblad: varje sida efter infogningen är förskjuten ett steg och rapporten lyser rött från topp till botten

Varje sida renderas och reduceras till en nyckel bestående av dess pixelbredd, höjd och en SHA-256-digest av de synliga pixlarna. Storleken betyder mer än den ser ut: en helvit stående sida och en helvit liggande sida med samma area har byte-identiska pixelbuffers, och utan dimensionerna i nyckeln skulle de förankras vid varandra. Identiska nycklar på båda sidor blir ankare, LCS:en plockar den längsta ordnade kedjan av dem, och sidorna mellan två på varandra följande ankare paras i ordning. När ett gap rymmer fler sidor på ena sidan rapporteras överskottet som added eller removed

var
  Options: TPDFVisualCompareOptions;
  Report: WideString;
begin
  // Baseline och Candidate laddade som tidigare, med matchande renderingsinställningar
  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;

Ankrena är bitexakta, och det är ett designval snarare än en förbiseende. ColorTolerance tillämpas bara på jämförelsen av parade sidor, aldrig på fingeravtrycket. En sida med inget annat än anti-aliasing-brus förankras inte, men den landar ändå i ett gap, paras i ordning med sin motsvarighet och jämförs sedan som unchanged under toleransen. Fuzzy-ankare skulle vara mer förlåtande och långt mindre förutsägbara, eftersom nästan duplikata sidor (tomma separatorer, upprepade villkorssidor) skulle börja matcha varandra

Var tydlig med vad den här aligneringen är. JSON:en namnger den exact-raster-lcs-gap-order med flit: det är en raster-alignering, inte semantisk sidigenkänning. Innehåller ett gap en omskriven sida och en infogad sida kan parning i ordning jämföra omskrivningen mot infogningen och rapportera originalet som borttaget. Dokument fulla av identiska sidor är på samma sätt tvetydiga. När parningen spelar roll för en dom, titta på paren, inte bara räknarna

Läsa JSON-rapporten

Dokumentrapporten har en fast form du kan assertera på i en test runner. Vid framgång bär den "status":"complete", "complete":true, aligneringsnamnet, firstPageCount, secondPageCount och räknarna changedPages, addedPages, removedPages och unchangedPages. Arrayen pairs listar sedan varje rad med firstPage, secondPage (0 för den saknade sidan) och en kind av unchanged, changed, size_changed, added eller removed

Rader av typ changed och unchanged bäddar in ett comparison-objekt med width, height, comparedPixels, changedPixels, ignoredPixels, changedRatio, boundingBox (null när inget ändrats) och en regions-lista av bounds plus changedPixels. En rad av typ size_changed har ingen comparison alls; den rapporterar firstWidth, firstHeight, secondWidth och secondHeight och räknas in i changedPages. Att sträcka ut en sida med ny storlek för att jämföra den skulle producera en vägg av meningslösa ändrade pixlar, så biblioteket rapporterar geometriändringen och stannar där

Dokumentrapporten innehåller aldrig masker eller overlays. CompareDocumentVisualsWith tvingar IncludeMask och IncludeOverlay av även om dina options ber om dem, så minnet hålls plant när sidantalet växer i stället för att hålla en helsidig förhandsvy för varje par. Använd rapporten för att hitta de intressanta sidorna, anropa sedan CompareRenderedPagesWith på bara de sidorna för att få overlayen. Falleringar kommer tillbaka som "status":"error", "complete":false med en error-sträng, och LastErrorCode sätts till 401

Jämföra mot en fil med CompareFileVisuals

TPDFlib.CompareFileVisuals är formulen med ett anrop för CI: den laddar den andra PDF:en, kopierar aktuella renderingsinställningar på den och returnerar samma dokument-JSON. Signaturen är CompareFileVisuals(OtherFileName, Password, DPI, ColorTolerance), där toleransen måste vara 0 till 255 och DPI måste vara ändlig och 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;

Rendererarvenet är skälet att föredra det här anropet framför att själv ladda den andra filen. Det laddade dokumentet tar aktuella instansens render scale, crop-typ och grafikinställningar, plus valet av Cairo- eller PDFium-motor när de är kompilerade in. Bygger du två TPDFlib-instanser för hand för CompareDocumentVisualsWith är det din uppgift att konfigurera båda identiskt, och en mismatch dyker upp som att varje sida ändrats. Den kan inte heller ta ignore-regioner; nå efter det typade API:et när du behöver dem

Gränser, kostnad och var den strukturella difffen passar

PDFlibPas avgränsar arbetet explicit så att fientlig eller enorm indata fallerar med ett fel i stället för att tömma minnet. En renderad sida får inte överstiga 64 Mi pixlar, tile-rutnätet är takat vid 4 Mi tiles, och sidaligneringen vägrar indata där de två sidantalen summerar över 8192 eller där LCS-matrisen skulle överskrida 4 Mi celler. Dokumentjämförelse renderar också varje sida två gånger, en gång för fingeravtrycket och igen för den parade jämförelsen, så budgetera för det vid 150 DPI och uppåt. För de flesta regressionssviter fångar 72 till 96 DPI layout- och textändringar för en bråkdel av tiden

Visuell diff och strukturell diff svarar på olika frågor, och en seriös pipeline vill oftast ha båda. Pixeljämförelsen talar om huruvida en läsare skulle lägga märke till något. Den kan inte tala om huruvida ett signerat dokument fått en anteckning som renderas utanför sidan, eller huruvida en inkrementell uppdatering tyst bytte ut ett formulärfältvärde mot samma synliga text. För det lagret går klassificeringen av vad som ändrats i en PDF efter att den signerats igenom difffen på revisionsnivå. Och eftersom val av renderare styr pixlarna är rendering med flera motorer i Delphi värd att läsa innan du låser en baslinje till en motor

FAQ

Sträcker PDFlibPas visuella jämförelse ut sidor som ändrat storlek?

Nej. CompareRenderedPagesWith returnerar ett fel för sidor med olika pixelstorlek, och dokumentrapporten märker sådana par size_changed med båda dimensionerna i stället för att jämföra dem

Varför används nästan identiska sidor inte som ankare?

Ankaren använder ett exakt SHA-256-fingeravtryck av de synliga pixlarna plus sidstorlek, så aligneringen förblir förutsägbar. Tolerans tillämpas efteråt, när parade sidor jämförs, så sidor med enbart brus hamnar ändå som unchanged

Kan jag få en overlay-bild för varje sida i ett dokument?

Inte från dokumentanropet, som med flit utelämnar masker och overlays för att hålla minnet plant. Använd JSON:en för att hitta ändrade sidor, anropa sedan CompareRenderedPagesWith med IncludeOverlay på de sidorna

Visuell jämförelse, sidparning och resten av rendering- och dokument-API:erna medföljer PDFlibPas PDF library for Delphi