HotPDF compare deux documents PDF depuis Delphi via THPDFDocComparison, qui parcourt le graphe d'objets des deux fichiers en partant du catalogue et, sur demande, restitue également chaque paire de pages et mesure les pixels qui diffèrent. Le résultat est un rapport JSON qui nomme chaque différence trouvée, le budget consommé, et si la comparaison s'est déroulée jusqu'au bout. Les deux passes comptent, car une comparaison structurelle et une comparaison visuelle répondent à des questions différentes
La question sous-jacente à cette fonctionnalité est généralement une question de mise en production. Un moteur de rapports reçoit un changement, la sortie est régénérée, et quelqu'un doit décider si quoi que ce soit a bougé. Ouvrir les deux fichiers côte à côte tient jusqu'à environ trois pages avant que l'attention ne flanche. Comparer les octets bruts échoue immédiatement, puisque deux exécutions du même générateur produisent des octets différents pour des raisons qui n'ont rien à voir avec ce qu'un lecteur voit
Pourquoi des PDF peuvent-ils différer octet par octet tout en étant visuellement identiques ?
Deux PDF générés indépendamment qui s'impriment de manière identique diffèrent couramment dans leurs octets, et les raisons sont structurelles plutôt que cosmétiques. Les numéros d'objet sont attribués dans l'ordre où les objets se trouvent être écrits. Les sous-ensembles de polices allouent des CID dans l'ordre où les glyphes sont rencontrés pour la première fois, de sorte qu'un sous-ensemble construit lors d'un parcours légèrement différent produit des octets de flux de contenu différents pour le même texte visible. Les décalages de la table de références croisées se déplacent dès que quelque chose en amont change de longueur
C'est pourquoi les numéros d'objet ne peuvent pas servir d'identité inter-documents. HotPDF construit à la place chaque instantané en parcourant depuis le catalogue, en développant les dictionnaires dans l'ordre des octets de leurs clés et les tableaux par index, de sorte que chaque objet est nommé par le chemin qui y mène. Les objets que le parcours ne peut pas atteindre depuis la racine retombent sur un chemin synthétique $Unreachable[...] portant le numéro d'objet et la génération, ce qui garde le contenu orphelin visible dans le rapport au lieu de le faire disparaître silencieusement
Les flux ne sont pas comparés par copie. Chaque flux fournit une signature SHA-256 incrémentale, calculée tout en restaurant ensuite la position d'origine du flux, de sorte que comparer deux fichiers de deux cents mégaoctets ne signifie pas matérialiser deux fois deux cents mégaoctets
Aligner les pages lorsqu'un document comporte une insertion
Comparer la page 1 à la page 1, la page 2 à la page 2 et ainsi de suite n'est correct que si rien n'a été inséré. Insérez une page de couverture et une comparaison naïve signale chaque page comme modifiée, ce qui est techniquement vrai et opérationnellement inutile
HotPDF aligne les pages avant de les comparer. Il construit une signature par page à partir du texte extractible, se rabat sur une signature structurelle pour les pages sans texte, puis calcule la plus longue sous-séquence croissante sur les index cibles appariés. Les pages à l'intérieur de cette sous-séquence sont celles qui ont simplement été déplacées ; les pages en dehors sont de véritables changements. C'est cette distinction qui rend lisible la comparaison d'un manuel de 400 pages, car le rapport indique qu'une page a été insérée plutôt que quatre cents pages modifiées
Exécuter une comparaison structurelle
L'appel le plus simple prend deux documents chargés et un mode. cmStructural effectue le parcours du graphe d'objets, cmRenderedImage effectue la comparaison de pixels, cmFull fait les deux, et les modes plus légers cmPageCount, cmPageText et cmObjectCount existent pour des vérifications rapides et peu coûteuses :
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;
Le rapport distingue trois états qu'un booléen ne peut pas exprimer. identical indique si quelque chose a différé, comparisonComplete indique si le parcours s'est terminé, et comparisonBudget nomme la limite qui l'a arrêté, le cas échéant. Une comparaison qui épuise un budget signale comparisonComplete=false et identical=false ensemble, car un parcours tronqué n'a aucune base pour revendiquer une égalité. Toute automatisation qui ne lit que identical finira par traiter un arrêt sur budget comme une différence réelle, donc lisez les trois valeurs
Quelles limites bornent le parcours ?
Les valeurs par défaut de THPDFStructuralCompareLimits.Default sont dimensionnées pour des documents réels plutôt que pour des documents adversariaux, et chaque budget sémantiquement pertinent a son propre plafond : 250 000 objets, 2 000 000 arêtes, profondeur 128, 10 000 différences signalées, 64 Mo par flux et 512 Mo d'octets de flux au total, 1 Mo par valeur et 4 096 octets par chemin. Augmentez-les délibérément lorsque vous connaissez votre corpus, et abaissez-les lorsque vous comparez des fichiers provenant de l'extérieur :
var
Limits: THPDFStructuralCompareLimits;
Options: THPDFRenderedCompareOptions;
begin
Limits := THPDFStructuralCompareLimits.Default;
Limits.MaxDifferences := 200; // échouer rapidement en CI
Limits.MaxTotalStreamBytes := 128 * 1024 * 1024;
Options := THPDFRenderedCompareOptions.Default;
Options.DPI := 150; // la valeur par défaut est 72
Options.ColorTolerance := 2; // ignorer le bruit d'arrondi de 1 à 2 niveaux
Options.MinimumSimilarity := 0.9995;
Options.MaxChangedPixelRatio := 0.0005;
Options.GenerateHeatmaps := True; // écrire des images de superposition pour relecture
Report := THPDFDocComparison.CompareWithOptions(DocA, DocB, cmFull,
Limits, Options);
end;
La passe de rendu estime le nombre de pixels à partir des dimensions de la page et de la résolution DPI demandée avant qu'un quelconque bitmap ne soit alloué, puis revérifie le bitmap réel ensuite, de sorte qu'une géométrie de page malformée ne puisse pas contourner le budget en mentant sur sa taille. Augmenter le DPI augmente la fidélité et le coût de manière quadratique : 150 DPI représente quatre fois les pixels de 72, et les plafonds de pixels par page et au total existent précisément parce qu'une tâche par lots à 300 DPI finirait sinon par s'allouer droit dans le mur
À partir de quand deux pages sont-elles suffisamment similaires ?
Deux pages ne comptent comme similaires que si les deux conditions sont réunies : le ratio de pixels modifiés est inférieur ou égal à MaxChangedPixelRatio et la similarité est supérieure ou égale à MinimumSimilarity. Deux seuils plutôt qu'un, car une poignée de pixels catastrophiquement erronés et une large nappe de minuscules variations de couleur sont des échecs différents, et l'un ou l'autre peut être acceptable dans un flux de travail et disqualifiant dans un autre. Les tests de seuil utilisent des valeurs non arrondies ; les six décimales du JSON existent pour garder les rapports stables et comparables, pas pour définir la comparaison
Les pixels modifiés sont regroupés en régions à l'aide de tuiles de taille fixe servant de nœuds avec adjacence à quatre directions, plutôt que par remplissage par diffusion pixel par pixel. Cela garde la mémoire bornée et la liste des régions stable d'une exécution à l'autre. Tronquer le détail des régions conservées n'affecte que la liste, pas le nombre de régions signalé, de sorte qu'une page comptant plus de régions modifiées que MaxChangedRegions indique quand même combien il y en avait
Un comportement mérite d'être énoncé clairement car il inverse le réflexe habituel. Les échecs de rendu, les échecs d'allocation et les échecs de superposition ne sont jamais avalés. Tout événement de ce type est enregistré comme renderError ou renderBudget et force renderComparisonComplete=false, car une page dont le rendu a échoué est une page que personne n'a comparée, et la signaler comme identique est pire que ne rien signaler
Où chaque mode a sa place dans un pipeline
La comparaison structurelle répond à la question de ce qui a changé et constitue le bon choix par défaut pour les suites de régression : elle nomme le chemin, l'index de page et les numéros d'objet impliqués, de sorte qu'un échec pointe vers le code qui l'a produit. La comparaison de rendu répond à la question de savoir si quelqu'un le remarquera, ce qui est la question pertinente pour les validations et pour vérifier qu'une passe d'optimisation était réellement sans perte
Les deux se combinent bien. Exécutez cmStructural à chaque build et laissez-le échouer bruyamment sur les changements imprévus au niveau des objets ; exécutez cmFull avec des cartes thermiques avant une mise en production, quand un humain est disponible pour examiner les superpositions. Pour les pipelines qui émettent déjà un balisage de page pour d'autres raisons, la sortie texte décrite dans l'export de pages PDF en SVG offre une troisième vue, comparable par un humain, et les vérifications automatisées de l'automatisation des rapports de préflight couvrent des questions de conformité auxquelles aucun des deux modes de comparaison n'est censé répondre
La comparaison, le préflight et le rendu partagent le même modèle d'objet de document chargé, de sorte qu'une seule passe sur un fichier peut alimenter les trois. La liste complète des fonctionnalités pour Delphi et C++Builder se trouve sur la page du composant PDF Delphi HotPDF