Technischer Artikel

Visueller PDF-Diff in Delphi: Pixel und Seiten-Pairing

Der visuelle Vergleich von PDFlibPas rendert zwei PDFs mit demselben DPI und diffed, was ein Reader tatsächlich sieht. TPDFlib.CompareRenderedPagesWith vergleicht ein Seitenpaar und liefert geänderte Pixel, Regionen, eine Maske und ein Overlay. TPDFlib.CompareDocumentVisualsWith und TPDFlib.CompareFileVisuals paaren ganze Dokumente Seite für Seite, erkennen eingefügte und entfernte Seiten und melden alles als JSON

Der Anwendungsfall ist visuelles Regressionstesting. Jemand schreibt die Rechnungsvorlage um, hebt eine Font-Package an oder tauscht die Rendering-Engine, und die Frage ist nicht, ob sich die Bytes geändert haben (tun sie immer), sondern ob irgendein Kunde einen Unterschied sehen wird. Ein struktureller Diff beantwortet eine andere Frage. Zwei Dateien können in jedem Content Stream differieren und trotzdem identische Seiten malen, und ein einziger verschobener Operand kann eine Summe um drei Punkte verschieben, ohne irgendetwas anzufassen, was ein Text-Extraktor markieren würde. Pixel sind die einzige Schicht, auf der "sieht gleich aus" testbar ist

Was vergleicht ein visueller PDF-Diff tatsächlich?

PDFlibPas vergleicht das sichtbare Ergebnis auf weißer Seite, nicht rohe Buffer-Inhalte. Jede Seite wird über RenderPageToString als unkomprimiertes BMP gerendert, in eine Top-Down-Bitmap dekodiert, und jedes Pixel, das Alpha trägt, wird vor dem Kanalvergleich über Weiß komponiert. Eine RGB-Bitmap und eine RGBA-Bitmap, die auf dem Bildschirm identisch aussehen, vergleichen sich also als identisch – genau das, was Sie wollen, wenn die beiden Seiten aus unterschiedlichen Render-Pfaden stammen

Der BMP-Decoder hat eine absichtliche Eigenheit, die man kennen sollte. Eine 32-Bit-BI_RGB-Bitmap trägt ein viertes Byte pro Pixel, aber das Format lässt dieses Byte undefiniert: Manche Writer füllen es mit 0, manche mit 255, manche mit Resten. PLDecodeVisualBMP ignoriert es und behält die Farbe. Dieses Byte als Opazität zu behandeln würde einem Renderer, der Nullen schreibt, erlauben, jedes Pixel der Seite auszulöschen, und der Diff würde fröhlich zwei leere, identische Dokumente melden. Speisen Sie Ihre eigenen Bitmaps in die niedrigere API ein, wird Alpha nur beachtet, wenn Sie das Format selbst als vpfRGBA oder vpfBGRA deklarieren

Ein Pixel gilt als geändert, wenn einer seiner Rot-, Grün- oder Blaukanäle um mehr als ColorTolerance abweicht. Toleranz 0 heißt bit-exakt. In der Praxis absorbiert ein einstelliger Wert Anti-Aliasing-Jitter zwischen Renderer-Builds, ohne eine geänderte Glyphe zu verstecken, denn eine echte Textänderung bewegt Kanäle um weit mehr als das. Nehmen Sie die Zahl aus einem bekannten guten Baseline-Lauf, nicht durch Raten

Ein einzelnes Seitenpaar mit CompareRenderedPagesWith vergleichen

TPDFlib.CompareRenderedPagesWith ist das Werkzeug, wenn Sie schon wissen, welche Seiten zusammengehören, und Details wollen: eine Bounding Box, zusammenhängende Änderungsregionen, eine optionale Maske pro Pixel und ein optional rot getöntes Overlay. Der Options-Record und die Result-Typen liegen in der Unit PDFlibVisualCompare, und PLDefaultVisualCompareOptions liefert Toleranz 0, 32-Pixel-Tiles, keine Ignore-Regionen und keine Maske oder kein 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;
      // Druck-Zeitstempel ausmaskieren; Pixeleinheiten beim DPI weiter unten
      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;

Drei Details stolpern Leute hier. Ignore-Rechtecke liegen in gerenderten Pixeln mit Ursprung oben links und halboffenen Intervallen, Right und Bottom sind also exklusiv, und dasselbe Rechteck deckt einen anderen Seitenteil ab, wenn Sie das DPI ändern. Überlappende Ignore-Rechtecke zählen einmal, und ignorierte Pixel tauchen in IgnoredPixels auf, statt zu verschwinden, was ChangedRatio ehrlich hält, weil er nur durch verglichene Pixel teilt. Schließlich verlangt der Einzelseiten-Aufruf, dass beide Renderings dieselben Dimensionen haben; eine Seite, die die Größe geändert hat, failt mit einem Fehler, statt gestreckt zu werden

Die Regionen kommen aus einem Tile-Durchlauf: Geänderte Pixel werden in TileSize-Quadrate einsortiert, und benachbarte Tiles werden zu einer Region mit eigenen Bounds und Pixelanzahl verschmolzen. Größere Tiles ergeben weniger, gröbere Regionen, was ein Testreport meist will. Das Overlay ist eine RGB-Kopie der zweiten Seite mit rot getönten geänderten Pixeln, zurückgegeben als rohe Bytes in Comparison.Overlay; es als PNG in Ihre CI-Artefakte zu schreiben, ist Ihr Code, nicht der der Bibliothek

Wie geht das Paaring auf Dokumentebene mit eingefügten Seiten um?

TPDFlib.CompareDocumentVisualsWith paart Seiten über eine Longest Common Subsequence über exakte Pixel-Fingerprints, sodass eine eingefügte Seite nicht jede folgende Seite in eine falsche Änderung verwandelt. Eine naive Schleife Seite 1 zu Seite 1, Seite 2 zu Seite 2 ist nutzlos, sobald eine Vorlage ein Deckblatt dazubekommt: Jede Seite nach der Einfügung ist um eins versetzt, und der Report leuchtet von oben bis unten rot

Jede Seite wird gerendert und auf einen Key reduziert, der aus Pixelbreite, -höhe und einem SHA-256-Digest der sichtbaren Pixel besteht. Die Größe zählt mehr, als sie aussieht: Eine komplett weiße Hochformat-Seite und eine komplett weiße Querformat-Seite derselben Fläche haben byte-identische Pixel-Buffer, und ohne die Dimensionen im Key würden sie aneinander andocken. Identische Keys auf beiden Seiten werden zu Ankern, die LCS wählt die längste geordnete Kette davon, und die Seiten zwischen zwei aufeinanderfolgenden Ankern werden der Reihe nach gepaart. Hält eine Lücke auf einer Seite mehr Seiten, wird der Überschuss als added oder removed gemeldet

var
  Options: TPDFVisualCompareOptions;
  Report: WideString;
begin
  // Baseline und Candidate wie zuvor geladen, mit passenden Render-Einstellungen
  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;

Die Anker sind bit-exakt, und das ist eine Design-Entscheidung, kein Versäumnis. ColorTolerance greift nur beim Vergleich gepaarter Seiten, nie beim Fingerprint. Eine Seite, die nichts als Anti-Aliasing-Rauschen hat, wird nicht ankern, aber sie landet trotzdem in einer Lücke, wird der Reihe nach mit ihrem Gegenstück gepaart und vergleicht sich dann unter Toleranz als unchanged. Unschärfere Anker wären gnädiger und weit weniger vorhersagbar, denn near-duplicate-Seiten (leere Trennseiten, wiederholte AGB-Seiten) würden anfangen, sich gegenseitig zu matchen

Seien Sie sich im Klaren, was dieses Alignment ist. Das JSON nennt es absichtlich exact-raster-lcs-gap-order: Es ist ein Raster-Alignment, keine semantische Seiten-Erkennung. Enthält eine Lücke eine umgeschriebene Seite und eine eingefügte Seite, kann das Pairing der Reihe nach die Umschreibung gegen die Einfügung vergleichen und das Original als entfernt melden. Dokumente voller identischer Seiten sind ähnlich mehrdeutig. Wenn das Pairing für ein Urteil zählt, schauen Sie auf die Paare, nicht nur auf die Zähler

Den JSON-Report lesen

Der Dokument-Report hat eine feste Form, auf die Sie in einem Test-Runner asserten können. Bei Erfolg trägt er "status":"complete", "complete":true, den Alignment-Namen, firstPageCount, secondPageCount und die Zähler changedPages, addedPages, removedPages und unchangedPages. Das pairs-Array listet dann jede Zeile mit firstPage, secondPage (0 für die fehlende Seite) und einem kind von unchanged, changed, size_changed, added oder removed

Zeilen der Art changed und unchanged betten ein comparison-Objekt ein mit width, height, comparedPixels, changedPixels, ignoredPixels, changedRatio, boundingBox (null, wenn nichts geändert hat) und einer regions-Liste aus bounds plus changedPixels. Eine size_changed-Zeile hat gar keinen Vergleich; sie meldet firstWidth, firstHeight, secondWidth und secondHeight und zählt zu changedPages. Eine Seite geänderter Größe gestreckt zu vergleichen würde eine Wand bedeutungsloser geänderter Pixel produzieren, also meldet die Bibliothek die Geometrieänderung und stoppt dort

Der Dokument-Report enthält nie Masken oder Overlays. CompareDocumentVisualsWith erzwingt IncludeMask und IncludeOverlay aus, selbst wenn Ihre Optionen sie anfordern, damit der Speicher flach bleibt, wenn die Seitenzahl wächst, statt für jedes Paar eine Seitenvorschau zu halten. Nutzen Sie den Report, um die interessanten Seiten zu finden, und rufen dann CompareRenderedPagesWith nur auf diesen Seiten auf, um das Overlay zu bekommen. Fehler kommen als "status":"error", "complete":false mit einem error-String zurück, und LastErrorCode wird auf 401 gesetzt

Gegen eine Datei vergleichen mit CompareFileVisuals

TPDFlib.CompareFileVisuals ist die Ein-Aufruf-Form für CI: Er lädt das andere PDF, kopiert die aktuellen Renderer-Einstellungen darauf und gibt dasselbe Dokument-JSON zurück. Die Signatur ist CompareFileVisuals(OtherFileName, Password, DPI, ColorTolerance), wobei die Toleranz 0 bis 255 sein muss und das DPI endlich und 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;

Die Renderer-Vererbung ist der Grund, diesen Aufruf dem eigenen Laden der zweiten Datei vorzuziehen. Das geladene Dokument übernimmt Render-Scale, Crop-Typ und Grafikeinstellungen der aktuellen Instanz, dazu die Cairo- oder PDFium-Engine-Wahl, wenn diese einkompiliert sind. Bauen Sie zwei TPDFlib-Instanzen von Hand für CompareDocumentVisualsWith, ist identische Konfiguration beider Ihre Sache, und eine Abweichung zeigt sich als jede Seite geändert. Ignore-Regionen kann er auch nicht nehmen; greifen Sie zur typisierten API, wenn Sie die brauchen

Grenzen, Kosten und wo der strukturelle Diff passt

PDFlibPas begrenzt die Arbeit explizit, sodass eine feindliche oder riesige Eingabe mit einem Fehler failt statt den Speicher zu erschöpfen. Eine gerenderte Seite darf 64 Mi Pixel nicht überschreiten, das Tile-Gitter ist auf 4 Mi Tiles gedeckelt, und das Seiten-Alignment verweigert Eingaben, bei denen die beiden Seitenzahlen zusammen über 8192 liegen oder die LCS-Matrix 4 Mi Zellen überschreiten würde. Der Dokumentvergleich rendert zudem jede Seite zweimal, einmal für den Fingerprint und nochmal für den Paarvergleich; planen Sie das bei 150 DPI und darüber ein. Für die meisten Regression-Suiten fangen 72 bis 96 DPI Layout- und Textänderungen zu einem Bruchteil der Zeit

Visueller Diff und struktureller Diff beantworten verschiedene Fragen, und eine ernsthafte Pipeline will meist beide. Der Pixelvergleich sagt Ihnen, ob ein Reader etwas bemerken würde. Er kann Ihnen nicht sagen, ob ein signiertes Dokument eine Annotation dazubekommen hat, die außerhalb der Seite rendert, oder ob ein inkrementelles Update stillschweigend einen Formularfeld-Wert durch denselben sichtbaren Text ersetzt hat. Für diese Schicht geht Klassifizieren, was sich in einem PDF nach der Signatur geändert hat den Revision-Level-Diff durch. Und weil die Renderer-Wahl die Pixel treibt, lohnt Multi-Engine-PDF-Rendering in Delphi die Lektüre, bevor Sie eine Baseline an eine Engine nageln

FAQ

Streckt der PDFlibPas-Visuellvergleich Seiten, die die Größe geändert haben?

Nein. CompareRenderedPagesWith gibt bei Seiten unterschiedlicher Pixelgröße einen Fehler zurück, und der Dokument-Report markiert solche Paare als size_changed mit beiden Dimensionen, statt sie zu vergleichen

Warum dienen near-identische Seiten nicht als Anker?

Anker nutzen einen exakten SHA-256-Fingerprint der sichtbaren Pixel plus Seitengröße, damit das Alignment vorhersagbar bleibt. Toleranz greift danach, wenn gepaarte Seiten verglichen werden, sodass Nur-Rauschen-Seiten trotzdem als unchanged enden

Bekomme ich ein Overlay-Bild für jede Seite eines Dokuments?

Nicht vom Dokument-Aufruf, der Masken und Overlays absichtlich weglässt, um den Speicher flach zu halten. Nutzen Sie das JSON, um geänderte Seiten zu finden, und rufen dann CompareRenderedPagesWith mit IncludeOverlay auf diesen Seiten auf

Der visuelle Vergleich, das Seiten-Pairing und der Rest der Rendering- und Dokument-APIs kommen mit der PDFlibPas PDF library for Delphi