Technisch artikel

Visuele PDF-diff in Delphi: pixelvergelijking en koppeling

De visuele vergelijking van PDFlibPas rendert twee PDF's op dezelfde DPI en difft wat een lezer werkelijk ziet. TPDFlib.CompareRenderedPagesWith vergelijkt één paginapaar en geeft gewijzigde pixels, regio's, een mask en een overlay terug. TPDFlib.CompareDocumentVisualsWith en TPDFlib.CompareFileVisuals koppelen hele documenten pagina voor pagina, detecteren ingevoegde en verwijderde pagina's en rapporteren alles als JSON

De use case is visuele regressietesting. Iemand herschrijft het factuursjabloon, lift een fontpakket op of wisselt van rendering-engine, en de vraag is niet of de bytes veranderden (dat doen ze altijd) maar of een klant het verschil zal zien. Een structurele diff beantwoordt een andere vraag. Twee bestanden kunnen in elke content stream verschillen en toch identieke pagina's tekenen, en één verschoven operand kan een totaal drie punten laten opschuiven zonder ook maar iets aan te raken dat een text extractor zou vlaggen. Pixels zijn de enige laag waar "ziet er hetzelfde uit" testbaar is

Wat vergelijkt een visuele PDF-diff eigenlijk?

PDFlibPas vergelijkt het zichtbare resultaat op een witte pagina, niet de ruwe bufferinhoud. Elke pagina wordt via RenderPageToString gerenderd als ongecomprimeerde BMP, gedecodeerd naar een top-down bitmap, en elke pixel met alpha wordt over wit gecompositeerd voordat de kanalen vergeleken worden. Een RGB-bitmap en een RGBA-bitmap die er op het scherm hetzelfde uitzien, vergelijken dus als identiek — precies wat u wilt wanneer de twee kanten uit verschillende renderpaden komen

De BMP-decoder heeft één bewuste eigenaardigheid die goed om te weten. Een 32-bits BI_RGB-bitmap voert per pixel een vierde byte mee, maar het formaat laat die byte ongedefinieerd: de ene schrijver vult hem met 0, de andere met 255, weer een andere met restjes. PLDecodeVisualBMP negeert hem en houdt de kleur aan. Die byte als dekking behandelen zou een renderer die nullen schrijft in staat stellen elke pixel op de pagina uit te wissen, en de diff zou vrolijk twee lege, identieke documenten melden. Voert u uw eigen bitmaps in via de lagere API, dan wordt alpha alleen gerespecteerd als u het formaat zelf als vpfRGBA of vpfBGRA declareert

Een pixel telt als gewijzigd zodra een van zijn rood-, groen- of blauwkanalen meer dan ColorTolerance afwijkt. Tolerantie 0 betekent bit-exact. In de praktijk absorbeert een waarde van enkele cijfers de anti-aliasing-jitter tussen rendererbuilds zonder een gewijzigde glyph te verhullen, want een echte tekstwijziging verschuift kanalen met veel meer dan dat. Kies het getal uit een known-good-baselinerun, niet door te gokken

Eén paginapaar vergelijken met CompareRenderedPagesWith

TPDFlib.CompareRenderedPagesWith is het gereedschap wanneer u al weet welke pagina's bij elkaar horen en detail wilt: een bounding box, aaneengesloten wijzigingsregio's, een optioneel mask per pixel en een optioneel rood gekleurde overlay. Het options-record en de resultaattypes staan in de unit PDFlibVisualCompare, en PLDefaultVisualCompareOptions geeft tolerantie 0, tiles van 32 pixels, geen negeerregio's en geen mask of 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;
      // Drukdatumstempel maskeren; pixeleenheden bij de DPI hieronder
      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;

Drie details bijten mensen hier. Negeerrectangles staan in gerenderde pixels met een origin linksboven en halfopen intervallen, dus Right en Bottom zijn exclusief en dezelfde rectangle dekt een ander deel van de pagina zodra u de DPI wijzigt. Overlappende negeerrectangles tellen één keer mee, en genegeerde pixels duiken op in IgnoredPixels in plaats van te verdwijnen, waardoor ChangedRatio eerlijk blijft omdat hij alleen door vergeleken pixels deelt. Tot slot eist de single-page-aanroep dat beide renders dezelfde afmetingen hebben; een pagina die van formaat veranderde, faalt met een fout in plaats van opgerekt te worden

De regio's komen uit een tile-passage: gewijzigde pixels worden gebatcht in TileSize-vierkanten en aangrenzende tiles worden samengevoegd tot één regio met eigen bounds en pixeltelling. Grotere tiles geven minder, grovere regio's, wat een testrapport meestal wil. De overlay is een RGB-kopie van de tweede pagina met gewijzigde pixels rood gekleurd, teruggegeven als ruwe bytes in Comparison.Overlay; die naar een PNG wegschrijven voor uw CI-artifacts is uw code, niet die van de bibliotheek

Hoe gaat paginakoppeling op documentniveau om met ingevoegde pagina's?

TPDFlib.CompareDocumentVisualsWith koppelt pagina's met een longest common subsequence over exacte pixelfingerprints, zodat één ingevoegde pagina niet elke volgende pagina in een valse wijziging verandert. Een naïeve lus van pagina 1 naar pagina 1, pagina 2 naar pagina 2 is nutteloos zodra een sjabloon een voorblad krijgt: elke pagina na de invoeging schuift er één op en het rapport licht van boven tot onder rood op

Elke pagina wordt gerenderd en gereduceerd tot een sleutel van pixelbreedte, hoogte en een SHA-256-digest van de zichtbare pixels. De afmeting telt zwaarder dan hij lijkt: een volledig witte staande pagina en een volledig witte liggende pagina met dezelfde oppervlakte hebben byte-identieke pixelbuffers, en zonder de afmetingen in de sleutel zouden ze aan elkaar vastkomen. Identieke sleutels aan beide kanten worden ankers, de LCS kiest de langste geordende keten ervan, en de pagina's tussen twee opeenvolgende ankers worden in volgorde gekoppeld. Bevat één tussenruimte aan één kant meer pagina's, dan wordt het overschot als added of removed gemeld

var
  Options: TPDFVisualCompareOptions;
  Report: WideString;
begin
  // Baseline en Candidate geladen als eerder, met matchende renderinstellingen
  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;

De ankers zijn bit-exact, en dat is een ontwerpkeuze in plaats van een vergissing. ColorTolerance geldt alleen voor de vergelijking van gekoppelde pagina's, nooit voor de fingerprint. Een pagina met niets dan anti-aliasing-ruis komt niet als anker in aanmerking, maar landt alsnog in een tussenruimte, wordt in volgorde aan zijn tegenhanger gekoppeld en vergelijkt onder tolerantie dan als unchanged. Vage ankers zouden vergevingsgezinder en veel minder voorspelbaar zijn, want nabij-dubbele pagina's (lege scheidingspagina's, herhaalde voorwaardenpagina's) zouden elkaar beginnen te matchen

Wees duidelijk over wat deze uitlijning is. De JSON noemt hem met opzet exact-raster-lcs-gap-order: het is een rasteruitlijning, geen semantische paginaherkenning. Bevat een tussenruimte een herschreven pagina en een ingevoegde pagina, dan kan koppeling in volgorde de herschrijving tegen de invoeging vergelijken en het origineel als verwijderd melden. Documenten vol identieke pagina's zijn op vergelijkbare manier dubbelzinnig. Als koppeling ertoe doet voor een oordeel, kijk dan naar de paren, niet alleen naar de tellers

Het JSON-rapport lezen

Het documentrapport heeft een vaste vorm waar u in een testrunner op kunt asserten. Bij succes voert hij "status":"complete", "complete":true, de naam van de uitlijning, firstPageCount, secondPageCount en de tellers changedPages, addedPages, removedPages en unchangedPages. De pairs-array somt daarna elke rij op met firstPage, secondPage (0 voor de ontbrekende kant) en een kind van unchanged, changed, size_changed, added of removed

Rijen van het soort changed en unchanged bevatten een comparison-object met width, height, comparedPixels, changedPixels, ignoredPixels, changedRatio, boundingBox (null wanneer niets veranderde) en een regions-lijst van bounds plus changedPixels. Een rij size_changed heeft helemaal geen comparison; hij meldt firstWidth, firstHeight, secondWidth en secondHeight en telt mee voor changedPages

Het documentrapport bevat nooit masks of overlays. CompareDocumentVisualsWith zet IncludeMask en IncludeOverlay uit, zelfs als uw opties erom vragen, zodat het geheugen vlak blijft naarmate het paginaaantal groeit in plaats van voor elk paar een paginavoorbeeld vast te houden. Gebruik het rapport om de interessante pagina's te vinden en roep daarna CompareRenderedPagesWith aan op precies die pagina's voor de overlay. Mislukkingen komen terug als "status":"error", "complete":false met een error-string, en LastErrorCode wordt op 401 gezet

Vergelijken tegen een bestand met CompareFileVisuals

TPDFlib.CompareFileVisuals is de één-aanroep-vorm voor CI: hij laadt de andere PDF, kopieert de huidige rendererinstellingen erheen en geeft dezelfde document-JSON terug. De signatuur is CompareFileVisuals(OtherFileName, Password, DPI, ColorTolerance), waarbij tolerantie 0 tot 255 moet zijn en DPI eindig en positief

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;

De renderer-overerving is de reden om deze aanroep te verkiezen boven het zelf laden van het tweede bestand. Het geladen document neemt de render scale, het croptype en de grafische instellingen van de huidige instantie over, plus de keuze van de Cairo- of PDFium-engine wanneer die zijn ingebouwd. Bouwt u twee TPDFlib-instanties met de hand voor CompareDocumentVisualsWith, dan is het identiek configureren van beide uw verantwoordelijkheid, en een mismatch verschijnt als elke pagina gewijzigd. Negeerregio's kan hij ook niet aannemen; grijp naar de getypeerde API wanneer u die nodig heeft

Grenzen, kosten en waar de structurele diff past

PDFlibPas begrenst het werk expliciet zodat een vijandige of enorme invoer met een fout faalt in plaats van het geheugen uit te putten. Een gerenderde pagina mag niet boven 64 Mi pixels uitkomen, het tile-raster is afgetopt op 4 Mi tiles, en pagina-uitlijning weigert invoer waarvan de twee paginaaantallen samen boven 8192 uitkomen of waarvan de LCS-matrix meer dan 4 Mi cellen zou beslaan. Documentvergelijking rendert bovendien elke pagina tweemaal, een keer voor de fingerprint en nog een keer voor de gekoppelde vergelijking, dus houd daar bij 150 DPI en hoger rekening mee. Voor de meeste regression suites vangt 72 tot 96 DPI lay-out- en tekstwijzigingen in een fractie van de tijd

Visuele diff en structurele diff beantwoorden verschillende vragen, en een serieuze pijplijn wil meestal beide. De pixelvergelijking vertelt u of een lezer iets zou opmerken. Ze kan u niet vertellen of een ondertekend document een annotatie heeft gekregen die buiten de pagina rendert, of dat een incrementele update stilletjes een formulierveldwaarde heeft vervangen door dezelfde zichtbare tekst. Voor die laag loopt classificeren van wat er in een PDF veranderde nadat hij was ondertekend de diff op revisieniveau door. En omdat de renderkeuze de pixels bepaalt, is multi-engine PDF-rendering in Delphi het lezen waard voordat u een baseline aan één engine vastpint

FAQ

Rekt de visuele vergelijking van PDFlibPas pagina's van gewijzigd formaat uit?

Nee. CompareRenderedPagesWith geeft een fout terug voor pagina's met verschillende pixelformaat, en het documentrapport markeert zulke paren als size_changed met beide afmetingen in plaats van ze te vergelijken

Waarom worden nabij-identieke pagina's niet als ankers gebruikt?

Ankers gebruiken een exacte SHA-256-fingerprint van de zichtbare pixels plus paginaformaat, zodat de uitlijning voorspelbaar blijft. Tolerantie wordt daarna toegepast, wanneer gekoppelde pagina's vergeleken worden, dus ruispagina's komen alsnog op unchanged uit

Kan ik een overlay-afbeelding krijgen voor elke pagina van een document?

Niet via de documentaanroep, die masks en overlays bewust weglaat om het geheugen vlak te houden. Gebruik de JSON om gewijzigde pagina's te vinden en roep daarna CompareRenderedPagesWith met IncludeOverlay aan op die pagina's

Visuele vergelijking, paginakoppeling en de rest van de rendering- en document-API's worden geleverd met de PDFlibPas PDF library for Delphi