Artykuł techniczny

Wizualny diff PDF w Delphi: piksele i parowanie stron

Wizualne porównanie PDFlibPas renderuje dwa PDF-y w tym samym DPI i robi diff tego, co widzi czytelnik. TPDFlib.CompareRenderedPagesWith porównuje jedną parę stron i zwraca zmienione piksele, regiony, maskę i overlay. TPDFlib.CompareDocumentVisualsWith i TPDFlib.CompareFileVisuals parują całe dokumenty strona po stronie, wykrywają wstawione i usunięte strony i raportują wszystko jako JSON

Zastosowaniem jest testowanie regresji wizualnej. Ktoś przepisuje szablon faktury, podbija pakiet fontów albo wymienia silnik renderowania, a pytanie nie brzmi, czy bajty się zmieniły (zawsze się zmienią), tylko czy którykolwiek klient zobaczy różnicę. Strukturalny diff odpowiada na inne pytanie. Dwa pliki mogą różnić się w każdym strumieniu treści i mimo to malować identyczne strony, a jeden przesunięty operand potrafi przesunąć sumę o trzy punkty bez dotknięcia czegokolwiek, co wyflagowałby ekstraktor tekstu. Piksele są jedyną warstwą, w której "wygląda tak samo" daje się przetestować

Co właściwie porównuje wizualny diff PDF?

PDFlibPas porównuje widoczny wynik na białej stronie, a nie surową zawartość buforów. Każda strona jest renderowana przez RenderPageToString jako nieskompresowany BMP, dekodowana do bitmapy od góry, a każdy piksel niosący alfę jest kompozytowany na biało, zanim kanały zostaną porównane. Bitmapa RGB i bitmapa RGBA wyglądające identycznie na ekranie porównają się więc jako identyczne, co jest dokładnie tym, czego chcesz, gdy obie strony pochodzą z różnych ścieżek renderowania

Dekoder BMP ma jedną świadomą dziwność, którą warto znać. Bitmapa 32-bit BI_RGB niesie czwarty bajt na piksel, ale format zostawia go niezdefiniowanym: jedni writerzy wypełniają go 0, inni 255, jeszcze inni resztkami. PLDecodeVisualBMP go ignoruje i zostawia kolor. Gdyby traktować ten bajt jako krycie, renderer piszący zera skasowałby każdy piksel na stronie, a diff raportowałby z wolna dwa puste, identyczne dokumenty. Jeśli karmisz własne bitmapy przez API niższego poziomu, alfa jest honorowana tylko wtedy, gdy sam zadeklarujesz format jako vpfRGBA albo vpfBGRA

Piksel liczy się jako zmieniony, gdy którykolwiek z jego kanałów czerwonego, zielonego lub niebieskiego różni się o więcej niż ColorTolerance. Tolerancja 0 znaczy bit w bit. W praktyce wartość jednocyfrowa pochłania drgania antyaliasingu między buildami renderera, nie chowając przy tym zmienionego glifa, bo prawdziwa zmiana tekstu przesuwa kanały o znacznie więcej. Wybieraj liczbę z uruchomienia na znanej dobrej bazie, nie zgadując

Porównanie jednej pary stron przez CompareRenderedPagesWith

TPDFlib.CompareRenderedPagesWith to narzędzie, gdy już wiesz, które strony do siebie należą, i chcesz szczegółów: bounding box, połączone regiony zmian, opcjonalną maskę per piksel i opcjonalny overlay z czerwonym odcieniem. Rekord opcji i typy wynikowe mieszkają w module PDFlibVisualCompare, a PLDefaultVisualCompareOptions daje tolerancję 0, kafle po 32 piksele, bez obszarów ignorowania i bez maski czy overlaya

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;
      // Zamaskuj znacznik czasu druku; jednostki pikselowe przy DPI poniżej
      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;

Trzy szczegóły tutaj gryzą. Prostokąty ignorowania są w wyrenderowanych pikselach z początkiem w lewym górnym rogu i przedziałami półotwartymi, więc Right i Bottom są wyłączne, a ten sam prostokąt pokrywa inną część strony, jeśli zmienisz DPI. Nakładające się prostokąty ignorowania liczą się raz, a ignorowane piksele pojawiają się w IgnoredPixels zamiast znikać, co trzyma ChangedRatio w uczciwości, bo dzieli on wyłącznie przez piksele porównane. Wreszcie, wywołanie jednostronicowe wymaga, by oba rendery miały te same wymiary; strona, która zmieniła rozmiar, kończy się błędem zamiast zostać rozciągnięta na siłę

Regiony pochodzą z przebiegu po kaflach: zmienione piksele trafiają do kwadratów TileSize, a sąsiednie kafle scalają się w jeden region z własnymi granicami i liczbą pikseli. Większe kafle dają mniej, grubiej ciętych regionów, co zwykle chce raport testowy. Overlay to RGB-owa kopia drugiej strony ze zmienionymi pikselami w czerwieni, zwracana jako surowe bajty w Comparison.Overlay; zapisanie go do PNG dla artefaktów CI to już twój kod, nie biblioteki

Jak parowanie stron na poziomie dokumentu radzi sobie z wstawionymi stronami?

TPDFlib.CompareDocumentVisualsWith paruje strony najdłuższym wspólnym podciągiem po dokładnych odciskach pikseli, więc jedna wstawiona strona nie zamienia każdej następnej w fałszywą zmianę. Naiwna pętla strona 1 do strony 1, strona 2 do strony 2 jest bezużyteczna w chwili, gdy szablon dostaje stronę okładkową: każda strona po wstawieniu jest przesunięta o jeden, a raport świeci czerwienią od góry do dołu

Każda strona jest renderowana i redukowana do klucza złożonego ze swojej szerokości i wysokości w pikselach oraz skrótu SHA-256 widocznych pikseli. Rozmiar znaczy więcej, niż wygląda: całkiem biała strona portretowa i całkiem biała strona landscape tej samej powierzchni mają bajt w bajt identyczne bufory pikseli, a bez wymiarów w kluczu zakotwiczyłyby się nawzajem. Identyczne klucze po obu stronach stają się kotwicami, LCS wybiera najdłuższy uporządkowany łańcuch, a strony między dwiema kolejnymi kotwicami są parowane po kolei. Gdy jedna luka trzyma więcej stron po jednej stronie, nadwyżka jest raportowana jako added albo removed

var
  Options: TPDFVisualCompareOptions;
  Report: WideString;
begin
  // Baseline i Candidate wczytane jak wyżej, z pasującymi ustawieniami renderowania
  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;

Kotwice są bit w bit dokładne i to jest świadomy wybór projektowy, nie przeoczenie. ColorTolerance dotyczy wyłącznie porównania sparowanych stron, nigdy odcisku. Strona z samym szumem antyaliasingu nie zakotwiczy się, ale wciąż wyląduje w luce, zostanie sparowana po kolei ze swoim odpowiednikiem i potem porówna się jako unchanged pod tolerancją. Rozmyte kotwice byłyby pobłażliwsze i znacznie mniej przewidywalne, bo prawie-zduplikowane strony (puste separatory, powtarzane strony warunków) zaczęłyby się w siebie wpasowywać

Miej jasność, czym jest to wyrównanie. JSON nazywa je exact-raster-lcs-gap-order nie bez powodu: to wyrównanie rastra, nie semantyczne rozpoznawanie stron. Jeśli luka zawiera przepisaną stronę i wstawioną stronę, parowanie po kolei może porównać przepisaną z wstawioną i raportować oryginał jako usuniętą. Dokumenty pełne identycznych stron są podobnie niejednoznaczne. Gdy parowanie ma znaczenie dla werdyktu, patrz na pary, nie tylko na liczniki

Czytanie raportu JSON

Raport dokumentu ma stały kształt, na którym można asertować w test runnerze. Przy sukcesie niesie "status":"complete", "complete":true, nazwę wyrównania, firstPageCount, secondPageCount oraz liczniki changedPages, addedPages, removedPages i unchangedPages. Tablica pairs wypisuje potem każdy wiersz z firstPage, secondPage (0 dla brakującej strony) i kind równym unchanged, changed, size_changed, added albo removed

Wiersze rodzaju changed i unchanged osadzają obiekt comparison z width, height, comparedPixels, changedPixels, ignoredPixels, changedRatio, boundingBox (null, gdy nic się nie zmieniło) i listą regions z bounds plus changedPixels. Wiersz size_changed nie ma porównania wcale; raportuje firstWidth, firstHeight, secondWidth i secondHeight i liczy się do changedPages. Rozciągnięcie strony o zmienionym rozmiarze, żeby ją porównać, wyprodukowałoby mur bezsensownych zmienionych pikseli, więc biblioteka raportuje zmianę geometrii i na tym staje

Raport dokumentu nigdy nie zawiera masek ani overlayów. CompareDocumentVisualsWith wymusza wyłączenie IncludeMask i IncludeOverlay nawet, gdy twoje opcje o nie proszą, więc pamięć trzyma się płasko wraz ze wzrostem liczby stron, zamiast trzymać podgląd pełnej strony dla każdej pary. Użyj raportu, by znaleźć interesujące strony, a potem puść CompareRenderedPagesWith na samych nich, żeby dostać overlay. Porażki wracają jako "status":"error", "complete":false ze stringiem error, a LastErrorCode jest ustawiane na 401

Porównanie z plikiem przez CompareFileVisuals

TPDFlib.CompareFileVisuals to forma jednego wywołania dla CI: wczytuje drugi PDF, kopiuje na niego bieżące ustawienia renderera i zwraca ten sam JSON dokumentu. Sygnatura to CompareFileVisuals(OtherFileName, Password, DPI, ColorTolerance), gdzie tolerancja musi być od 0 do 255, a DPI skończone i dodatnie

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;

Dziedziczenie renderera to powód, by wolać to wywołanie od samodzielnego wczytania drugiego pliku. Wczytany dokument bierze skalę renderowania, typ crop i ustawienia grafiki bieżącej instancji, plus wybór silnika Cairo albo PDFium, gdy te są skompilowane. Jeśli budujesz dwie instancje TPDFlib ręcznie dla CompareDocumentVisualsWith, skonfigurowanie obu identycznie leży na tobie, a rozjazd wychodzi jako każda strona zmieniona. To wywołanie nie umie też przyjąć obszarów ignorowania; sięgaj po typowane API, gdy ich potrzebujesz

Limity, koszt i gdzie pasuje diff strukturalny

PDFlibPas graniczy pracę jawnie, żeby wrogi albo ogromne wejście kończyło się błędem, nie wyczerpaniem pamięci. Wyrenderowana strona nie może przekroczyć 64 Mi pikseli, siatka kafli jest ograniczona do 4 Mi kafli, a wyrównanie stron odmawia wejść, gdzie suma liczby stron przekracza 8192 albo macierz LCS przekroczyłaby 4 Mi komórek. Porównanie dokumentów renderuje też każdą stronę dwa razy, raz na odcisk i jeszcze raz na porównanie sparowane, więc zaplanuj to przy 150 DPI i wyżej. Dla większości suitów regresji 72 do 96 DPI łapie zmiany layoutu i tekstu za ułamek czasu

Diff wizualny i diff strukturalny odpowiadają na różne pytania, a poważny potok zwykle chce obu. Porównanie pikseli mówi ci, czy czytelnik zauważyłby cokolwiek. Nie powie ci, czy podpisany dokument dostał adnotację renderującą się poza stroną, ani czy przyrostowa aktualizacja po cichu podmieniła wartość pola formularza na ten sam widoczny tekst. Za tą warstwą klasyfikowanie tego, co zmieniło się w PDF po podpisaniu przeprowadza diff na poziomie rewizji. A ponieważ wybór renderera napędza piksele, wielosilnikowe renderowanie PDF w Delphi warto przeczytać, zanim przypniesz bazę do jednego silnika

FAQ

Czy wizualne porównanie PDFlibPas rozciąga strony, które zmieniły rozmiar?

Nie. CompareRenderedPagesWith zwraca błąd dla stron o różnych rozmiarach w pikselach, a raport dokumentu oznacza takie pary jako size_changed z oboma wymiarami zamiast ich porównywać

Dlaczego prawie identyczne strony nie służą jako kotwice?

Kotwice używają dokładnego odcisku SHA-256 widocznych pikseli plus rozmiaru strony, więc wyrównanie zostaje przewidywalne. Tolerancja jest nakładana później, przy porównaniu sparowanych stron, więc strony z samym szumem i tak kończą jako unchanged

Czy dostanę obraz overlay dla każdej strony dokumentu?

Nie z wywołania dokumentowego, które świadomie pomija maski i overlaye, żeby pamięć trzymała się płasko. Użyj JSON, by znaleźć zmienione strony, a potem puść CompareRenderedPagesWith z IncludeOverlay na tych stronach

Porównanie wizualne, parowanie stron i reszta API renderowania i dokumentów są częścią PDFlibPas PDF library for Delphi