Articolo tecnico

Confrontare due file PDF in Delphi: struttura e pixel

HotPDF confronta due documenti PDF da Delphi tramite THPDFDocComparison, che percorre il grafo degli oggetti di entrambi i file a partire dal catalogo e, su richiesta, renderizza anche ogni coppia di pagine misurando i pixel che differiscono. Il risultato è un report JSON che elenca ogni differenza trovata, il budget consumato e se il confronto è arrivato al completamento. Entrambi i passaggi contano, perché un diff strutturale e un diff visivo rispondono a domande diverse

La domanda dietro questa funzionalità è di solito una domanda legata al rilascio. Un motore di report riceve una modifica, l'output viene rigenerato, e qualcuno deve decidere se qualcosa si è spostato. Aprire entrambi i file affiancati regge fino a circa tre pagine, poi l'attenzione cede. Confrontare i byte grezzi fallisce immediatamente, poiché due esecuzioni dello stesso generatore producono byte diversi per motivi che non hanno nulla a che fare con ciò che vede un lettore

Perché due PDF possono essere diversi a livello di byte ma identici visivamente?

Due PDF generati indipendentemente che stampano in modo identico differiscono regolarmente nei byte, e le ragioni sono strutturali, non estetiche. I numeri degli oggetti vengono assegnati nell'ordine in cui gli oggetti vengono scritti. I subset dei font allocano i CID nell'ordine in cui i glifi vengono incontrati per la prima volta, quindi un subset costruito durante un percorso leggermente diverso produce byte di content stream diversi per lo stesso testo visibile. Gli offset della cross-reference si spostano ogni volta che qualcosa a monte cambia lunghezza

Questo è il motivo per cui i numeri degli oggetti non possono essere usati come identità tra documenti diversi. HotPDF costruisce invece ogni snapshot percorrendo il documento a partire dal catalogo, espandendo i dizionari nell'ordine dei byte delle proprie chiavi e gli array per indice, così ogni oggetto viene identificato dal percorso che lo raggiunge. Gli oggetti che la traversata non riesce a raggiungere dalla radice ricadono su un percorso sintetico $Unreachable[...] che porta il numero di oggetto e la generazione, il che mantiene visibile nel report il contenuto orfano invece di farlo sparire silenziosamente

Gli stream non vengono confrontati per copia. Ogni stream contribuisce con una firma SHA-256 incrementale, calcolata ripristinando poi la posizione originale dello stream, quindi confrontare due file da duecento megabyte non significa materializzare duecento megabyte due volte

Allineare le pagine quando un documento contiene un inserimento

Confrontare la pagina 1 con la pagina 1, la pagina 2 con la pagina 2 e così via è corretto solo quando non è stato inserito nulla. Inserisci una copertina e un confronto ingenuo riporta ogni pagina come cambiata, il che è tecnicamente vero ma operativamente inutile

HotPDF allinea le pagine prima di confrontarle. Costruisce una firma per pagina a partire dal testo estraibile, ricade su una firma strutturale per le pagine senza testo, e poi calcola la sottosequenza crescente più lunga sugli indici di destinazione abbinati. Le pagine all'interno di quella sottosequenza sono quelle semplicemente spostate; le pagine al di fuori sono modifiche genuine. Questa distinzione è ciò che rende leggibile il diff di un manuale di 400 pagine, perché il report dice che è stata inserita una pagina anziché che sono cambiate quattrocento pagine

Eseguire un confronto strutturale

La chiamata più semplice accetta due documenti caricati e una modalità. cmStructural esegue la percorrenza del grafo degli oggetti, cmRenderedImage esegue il confronto dei pixel, cmFull fa entrambe le cose, e le modalità più leggere cmPageCount, cmPageText e cmObjectCount esistono per controlli smoke economici:

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;

Il report distingue tre stati che un booleano non può rappresentare. identical dice se qualcosa è differito, comparisonComplete dice se la percorrenza è terminata, e comparisonBudget indica il limite che l'ha fermata, se ce n'è stato uno. Un confronto che esaurisce un budget riporta insieme comparisonComplete=false e identical=false, perché una percorrenza troncata non ha alcuna base per affermare l'uguaglianza. Qualsiasi automazione che legge solo identical finirà prima o poi per trattare un arresto per budget come una differenza reale, quindi leggi tutti e tre i valori

Quali limiti mantengono limitata la percorrenza?

I valori predefiniti in THPDFStructuralCompareLimits.Default sono dimensionati per documenti reali piuttosto che per casi avversari, e ogni budget semanticamente rilevante ha il proprio limite massimo: 250.000 oggetti, 2.000.000 di archi, profondità 128, 10.000 differenze riportate, 64 MB per stream e 512 MB di byte di stream in totale, 1 MB per valore e 4.096 byte per percorso. Alzali deliberatamente quando conosci il tuo corpus di documenti, e abbassali quando confronti file arrivati dall'esterno:

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

  Options := THPDFRenderedCompareOptions.Default;
  Options.DPI := 150;                  // il valore predefinito è 72
  Options.ColorTolerance := 2;         // ignora il rumore di arrotondamento di 1-2 livelli
  Options.MinimumSimilarity := 0.9995;
  Options.MaxChangedPixelRatio := 0.0005;
  Options.GenerateHeatmaps := True;    // scrivi immagini overlay per la revisione

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

Il passaggio di rendering stima il numero di pixel dalle dimensioni della pagina e dal DPI richiesto prima che venga allocata qualsiasi bitmap, e ricontrolla la bitmap effettiva in seguito, così una geometria di pagina malformata non può eludere il budget mentendo sulla propria dimensione. Aumentare il DPI aumenta fedeltà e costo in modo quadratico: 150 DPI corrisponde a quattro volte i pixel di 72, e i limiti massimi per pagina e totali esistono proprio perché un job batch a 300 DPI finirebbe altrimenti nei guai allocando memoria

Quanto deve essere simile per essere abbastanza simile?

Due pagine contano come simili solo quando entrambe le condizioni valgono: il rapporto di pixel cambiati è pari o inferiore a MaxChangedPixelRatio e la similarità è pari o superiore a MinimumSimilarity. Due soglie anziché una, perché una manciata di pixel catastroficamente sbagliati e un'ampia patina di minuscoli spostamenti di colore sono fallimenti diversi, e ciascuno da solo può essere accettabile in un workflow e squalificante in un altro. I test sulle soglie usano valori non arrotondati; le sei cifre decimali nel JSON esistono per mantenere i report stabili e confrontabili, non per definire il confronto

I pixel cambiati vengono raggruppati in regioni usando tile di dimensione fissa come nodi con adiacenza a quattro vie, anziché un flood fill pixel per pixel. Questo mantiene la memoria limitata e l'elenco delle regioni stabile tra un'esecuzione e l'altra. Troncare il dettaglio delle regioni conservate influisce solo sull'elenco, non sul conteggio delle regioni riportato, quindi una pagina con più regioni cambiate di MaxChangedRegions riporta comunque quante ce n'erano

Un comportamento vale la pena dichiararlo apertamente perché inverte l'istinto abituale. I fallimenti del renderer, i fallimenti di allocazione e i fallimenti dell'overlay non vengono mai inghiottiti. Qualsiasi cosa di questo tipo viene registrata come renderError o renderBudget e forza renderComparisonComplete=false, perché una pagina che non è riuscita a renderizzare è una pagina che nessuno ha confrontato, e riportarla come identica è peggio che non riportare nulla

Dove colloca ciascuna modalità in una pipeline

Il confronto strutturale risponde a cosa è cambiato ed è la scelta predefinita giusta per le suite di regressione: indica il percorso, l'indice di pagina e i numeri degli oggetti coinvolti, così un fallimento punta al codice che lo ha prodotto. Il confronto renderizzato risponde a se qualcuno se ne accorgerà, che è la domanda giusta per le approvazioni e per verificare che un passaggio di ottimizzazione fosse davvero lossless

Si combinano bene. Esegui cmStructural su ogni build e lascia che fallisca rumorosamente su cambiamenti inattesi a livello di oggetto; esegui cmFull con le heatmap prima di un rilascio, quando è disponibile una persona per guardare gli overlay. Per le pipeline che già emettono markup di pagina per altri motivi, l'output testuale descritto in esportazione di pagine PDF in SVG offre una terza vista, confrontabile da un umano, e i controlli automatizzati in automazione dei report di preflight coprono questioni di conformità a cui nessuna delle due modalità di diff è pensata per rispondere

Confronto, preflight e rendering condividono lo stesso modello a oggetti dei documenti caricati, quindi un singolo passaggio su un file può alimentare tutti e tre. L'elenco completo delle funzionalità per Delphi e C++Builder si trova nella pagina del componente PDF Delphi HotPDF