Technischer Artikel

Zwei PDF-Dateien in Delphi vergleichen: Struktur und Pixel

HotPDF vergleicht zwei PDF-Dokumente von Delphi aus über THPDFDocComparison, das den Objektgraphen beider Dateien ausgehend vom Katalog durchläuft und, auf Wunsch, zusätzlich jedes Seitenpaar rendert und die abweichenden Pixel misst. Das Ergebnis ist ein JSON-Bericht, der jede gefundene Abweichung benennt, das verbrauchte Budget angibt und mitteilt, ob der Vergleich vollständig durchgelaufen ist. Beide Durchläufe zählen, denn ein struktureller und ein visueller Diff beantworten unterschiedliche Fragen

Die Frage hinter dem Feature ist meist eine Release-Frage. Eine Berichts-Engine erhält eine Änderung, die Ausgabe wird neu erzeugt, und jemand muss entscheiden, ob sich etwas verschoben hat. Beide Dateien nebeneinander zu öffnen, funktioniert bis etwa drei Seiten, dann versagt die Aufmerksamkeit. Rohe Bytes zu vergleichen scheitert sofort, da zwei Läufe desselben Generators aus Gründen unterschiedliche Bytes erzeugen, die nichts mit dem zu tun haben, was ein Leser sieht

Warum können PDFs byteweise unterschiedlich und visuell identisch sein?

Zwei unabhängig erzeugte PDFs, die identisch drucken, unterscheiden sich regelmäßig in ihren Bytes, und die Gründe sind struktureller, nicht kosmetischer Natur. Objektnummern werden in der Reihenfolge vergeben, in der Objekte zufällig geschrieben werden. Schrift-Subsets weisen CIDs in der Reihenfolge zu, in der Glyphen erstmals angetroffen werden, sodass ein während eines leicht anderen Durchlaufs erstelltes Subset für denselben sichtbaren Text andere Content-Stream-Bytes erzeugt. Querverweis-Offsets verschieben sich, sobald sich vorgelagert irgendetwas in der Länge ändert

Deshalb können Objektnummern nicht als dokumentübergreifende Identität dienen. HotPDF baut stattdessen jeden Schnappschuss auf, indem es ausgehend vom Katalog durchläuft, Dictionaries in der Byte-Reihenfolge ihrer Schlüssel und Arrays nach Index expandiert, sodass jedes Objekt durch den Pfad benannt wird, der zu ihm führt. Objekte, die der Durchlauf von der Wurzel aus nicht erreichen kann, fallen auf einen synthetischen $Unreachable[...]-Pfad zurück, der Objektnummer und Generation trägt, wodurch verwaiste Inhalte im Bericht sichtbar bleiben, statt still zu fehlen

Streams werden nicht durch Kopieren verglichen. Jeder Stream liefert eine inkrementelle SHA-256-Signatur, berechnet unter anschließender Wiederherstellung der ursprünglichen Stream-Position, sodass der Vergleich zweier hundert Megabyte großer Dateien nicht bedeutet, zweihundert Megabyte zweimal zu materialisieren

Seiten ausrichten, wenn ein Dokument eine Einfügung enthält

Seite 1 gegen Seite 1, Seite 2 gegen Seite 2 und so weiter zu vergleichen, ist nur dann korrekt, wenn nichts eingefügt wurde. Fügt man eine Deckblattseite ein, meldet ein naiver Vergleich jede Seite als geändert, was technisch korrekt und operativ nutzlos ist

HotPDF richtet Seiten aus, bevor es sie vergleicht. Es baut eine Signatur pro Seite aus extrahierbarem Text auf, greift bei Seiten ohne Text auf eine strukturelle Signatur zurück und berechnet dann die längste aufsteigende Teilfolge über die zugeordneten Zielindizes. Seiten innerhalb dieser Teilfolge sind jene, die sich nur verschoben haben; Seiten außerhalb sind echte Verschiebungen. Diese Unterscheidung macht den Diff eines 400-seitigen Handbuchs lesbar, denn der Bericht sagt, dass eine Seite eingefügt wurde, statt dass vierhundert Seiten sich geändert hätten

Einen strukturellen Vergleich durchführen

Der einfachste Aufruf nimmt zwei geladene Dokumente und einen Modus entgegen. cmStructural führt den Objektgraphen-Durchlauf aus, cmRenderedImage führt den Pixelvergleich aus, cmFull macht beides, und die leichteren Modi cmPageCount, cmPageText und cmObjectCount existieren für günstige Rauchtests:

uses
  HPDFDoc, HPDFDocCompare;

var
  DocA, DocB: THotPDF;
  Report: AnsiString;
begin
  DocA := THotPDF.Create(nil);
  DocB := THotPDF.Create(nil);
  try
    if (DocA.LoadFromFile('baseline.pdf') <= 0) or
       (DocB.LoadFromFile('candidate.pdf') <= 0) then
      Exit;
    Report := THPDFDocComparison.Compare(DocA, DocB, cmStructural);
    with TFileStream.Create('diff.json', fmCreate) do
    try
      WriteBuffer(Report[1], Length(Report));
    finally
      Free;
    end;
  finally
    DocB.Free;
    DocA.Free;
  end;
end;

Der Bericht unterscheidet drei Zustände, die ein Boolescher Wert nicht abbilden kann. identical gibt an, ob überhaupt etwas abweicht, comparisonComplete gibt an, ob der Durchlauf abgeschlossen wurde, und comparisonBudget nennt das Limit, das den Abbruch verursacht hat, falls dies der Fall war. Ein Vergleich, der ein Budget erschöpft, meldet comparisonComplete=false und identical=false zusammen, weil ein abgebrochener Durchlauf keine Grundlage hat, Gleichheit zu behaupten. Jede Automatisierung, die nur identical ausliest, wird irgendwann einen Budget-Stopp als echte Abweichung behandeln, daher sollten alle drei Felder gelesen werden

Welche Grenzen halten den Durchlauf beschränkt?

Die Standardwerte in THPDFStructuralCompareLimits.Default sind für reale Dokumente bemessen, nicht für feindselige, und jedes semantisch relevante Budget hat seine eigene Obergrenze: 250.000 Objekte, 2.000.000 Kanten, Tiefe 128, 10.000 gemeldete Abweichungen, 64 MB pro Stream und insgesamt 512 MB an Stream-Bytes, 1 MB pro Wert und 4.096 Bytes pro Pfad. Diese sollten bewusst angehoben werden, wenn der eigene Bestand bekannt ist, und gesenkt werden, wenn Dateien von außen verglichen werden:

var
  Limits: THPDFStructuralCompareLimits;
  Options: THPDFRenderedCompareOptions;
begin
  Limits := THPDFStructuralCompareLimits.Default;
  Limits.MaxDifferences := 200;        // in CI schnell fehlschlagen lassen
  Limits.MaxTotalStreamBytes := 128 * 1024 * 1024;

  Options := THPDFRenderedCompareOptions.Default;
  Options.DPI := 150;                  // Standard ist 72
  Options.ColorTolerance := 2;         // Rundungsrauschen von 1-2 Stufen ignorieren
  Options.MinimumSimilarity := 0.9995;
  Options.MaxChangedPixelRatio := 0.0005;
  Options.GenerateHeatmaps := True;    // Overlay-Bilder zur Durchsicht schreiben

  Report := THPDFDocComparison.CompareWithOptions(DocA, DocB, cmFull,
    Limits, Options);
end;

Der Render-Durchlauf schätzt die Pixelanzahl aus den Seitenabmessungen und der angeforderten DPI, bevor eine Bitmap zugewiesen wird, und prüft anschließend die tatsächliche Bitmap nach, sodass eine fehlerhafte Seitengeometrie das Budget nicht durch falsche Größenangaben umgehen kann. Eine höhere DPI erhöht Genauigkeit und Kosten quadratisch: 150 DPI ergibt die vierfache Pixelzahl von 72, und die Pixel-Obergrenzen pro Seite und insgesamt existieren genau deshalb, weil sich ein Batch-Job bei 300 DPI sonst in Schwierigkeiten hineinallokieren würde

Wie ähnlich ist ähnlich genug?

Zwei Seiten gelten nur dann als ähnlich, wenn beide Bedingungen erfüllt sind: Das Verhältnis geänderter Pixel liegt bei oder unter MaxChangedPixelRatio, und die Ähnlichkeit liegt bei oder über MinimumSimilarity. Zwei Schwellenwerte statt einem, weil eine Handvoll katastrophal falscher Pixel und eine breite Welle winziger Farbverschiebungen unterschiedliche Fehlerarten sind, und jede für sich kann in einem Workflow akzeptabel und in einem anderen ein Ausschlusskriterium sein. Schwellenwerttests verwenden ungerundete Werte; die sechs Nachkommastellen im JSON dienen dazu, Berichte stabil und diffbar zu halten, nicht dazu, den Vergleich zu definieren

Geänderte Pixel werden mithilfe fester Kachelgrößen als Knoten mit Vier-Wege-Nachbarschaft zu Regionen gruppiert, statt per pixelweisem Flood-Fill. Das hält den Speicherbedarf begrenzt und die Regionsliste über Läufe hinweg stabil. Das Kürzen der aufbewahrten Regionsdetails betrifft nur die Auflistung, nicht die gemeldete Regionsanzahl, sodass eine Seite mit mehr geänderten Regionen als MaxChangedRegions trotzdem meldet, wie viele es tatsächlich waren

Ein Verhalten ist es wert, klar ausgesprochen zu werden, weil es den üblichen Instinkt umkehrt. Renderer-Fehler, Zuweisungsfehler und Overlay-Fehler werden nie verschluckt. Alles dieser Art wird als renderError oder renderBudget erfasst und erzwingt renderComparisonComplete=false, denn eine Seite, deren Rendern fehlschlug, ist eine Seite, die niemand verglichen hat, und sie als identisch zu melden ist schlimmer, als nichts zu melden

Wo gehört welcher Modus in eine Pipeline?

Der strukturelle Vergleich beantwortet, was sich geändert hat, und ist der richtige Standard für Regressionssuiten: Er benennt den Pfad, den Seitenindex und die beteiligten Objektnummern, sodass ein Fehlschlag auf den Code zeigt, der ihn verursacht hat. Der gerenderte Vergleich beantwortet, ob es jemand bemerken wird, was die Frage bei Freigaben und bei der Verifizierung ist, dass ein Optimierungsdurchlauf wirklich verlustfrei war

Beide lassen sich gut kombinieren. cmStructural bei jedem Build ausführen und laut bei unerwarteten Änderungen auf Objektebene fehlschlagen lassen; cmFull mit Heatmaps vor einem Release ausführen, wenn ein Mensch verfügbar ist, um sich die Overlays anzusehen. Für Pipelines, die aus anderen Gründen bereits Seiten-Markup ausgeben, liefert die in PDF-Seiten in SVG exportieren beschriebene Textausgabe eine dritte, für Menschen diffbare Ansicht, und die automatisierten Prüfungen in Preflight-Berichtsautomatisierung decken Konformitätsfragen ab, die keiner der beiden Diff-Modi beantworten soll

Vergleich, Preflight und Rendering teilen sich dasselbe Objektmodell für geladene Dokumente, sodass ein einziger Durchlauf über eine Datei alle drei speisen kann. Die vollständige Funktionsliste für Delphi und C++Builder findet sich auf der HotPDF-Delphi-PDF-Komponentenseite