Articolo tecnico

Diff visuale PDF in Delphi: pixel e pagine abbinate

Il confronto visuale di PDFlibPas renderizza due PDF alla stessa DPI e fa il diff di ciò che un lettore vede davvero. TPDFlib.CompareRenderedPagesWith confronta una coppia di pagine e restituisce pixel cambiati, regioni, una mask e un overlay. TPDFlib.CompareDocumentVisualsWith e TPDFlib.CompareFileVisuals abbinano documenti interi pagina per pagina, rilevano le pagine inserite e rimosse e riportano tutto come JSON

Il caso d'uso è il test di regressione visiva. Qualcuno riscrive il template della fattura, aggiorna un pacchetto font o cambia motore di rendering, e la domanda non è se i byte sono cambiati (cambiano sempre) ma se qualche cliente vedrà una differenza. Un diff strutturale risponde a una domanda diversa. Due file possono differire in ogni content stream e dipingere comunque pagine identiche, e un singolo operatore spostato può muovere un totale di tre punti senza toccare nulla che un estrattore di testo segnalerebbe. I pixel sono l'unico livello in cui "sembra uguale" è verificabile

Cosa confronta davvero un diff visuale di PDF?

PDFlibPas confronta il risultato visibile su una pagina bianca, non i contenuti grezzi dei buffer. Ogni pagina è renderizzata tramite RenderPageToString come BMP non compresso, decodificata in una bitmap top-down, e ogni pixel che porta alpha viene composizionato sul bianco prima di confrontare i canali. Una bitmap RGB e una bitmap RGBA che sembrano identiche a schermo vengono quindi confrontate come identiche, che è esattamente ciò che volete quando i due lati arrivano da percorsi di rendering diversi

Il decodificatore BMP ha una peculiarità voluta che vale la pena conoscere. Una bitmap BI_RGB a 32 bit porta un quarto byte per pixel, ma il formato lascia quel byte indefinito: alcuni scrittori lo riempiono con 0, altri con 255, altri con residui vari. PLDecodeVisualBMP lo ignora e conserva il colore. Trattare quel byte come opacità permetterebbe a un renderer che scrive zeri di cancellare ogni pixel della pagina, e il diff segnalerebbe felicemente due documenti bianchi e identici. Se date le vostre bitmap alla API di livello più basso, l'alpha viene onorato solo quando dichiarate voi il formato come vpfRGBA o vpfBGRA

Un pixel conta come cambiato quando uno qualsiasi dei suoi canali rosso, verde o blu differisce per più di ColorTolerance. Tolleranza 0 significa bit-exact. In pratica un valore a una cifra assorbe il tremolio dell'anti-aliasing fra build del renderer diverse senza nascondere un glifo cambiato, perché un vero cambio di testo muove i canali di molto di più. Scegliete il numero da una esecuzione di baseline affidabile, non tirando a indovinare

Confrontare una singola coppia di pagine con CompareRenderedPagesWith

TPDFlib.CompareRenderedPagesWith è lo strumento quando sapete già quali pagine stanno insieme e volete il dettaglio: un bounding box, regioni di cambiamento connesse, una mask per pixel opzionale e un overlay con tinta rossa opzionale. Il record di opzioni e i tipi risultato stanno nella unit PDFlibVisualCompare, e PLDefaultVisualCompareOptions vi dà tolleranza 0, tile da 32 pixel, nessuna regione da ignorare e né mask né 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;
      // Mascherate il timestamp di stampa; unità in pixel alla DPI sotto
      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;

Tre dettagli qui mordono la gente. I rettangoli da ignorare sono in pixel renderizzati con origine in alto a sinistra e intervalli mezzo aperti, così Right e Bottom sono esclusivi e lo stesso rettangolo copre una parte diversa della pagina se cambiate la DPI. I rettangoli da ignorare sovrapposti contano una volta sola, e i pixel ignorati compaiono in IgnoredPixels invece di svanire, il che mantiene ChangedRatio onesto perché divide soltanto per i pixel confrontati. Infine, la chiamata a pagina singola richiede che entrambi i render abbiano le stesse dimensioni; una pagina che è cambiata di formato fallisce con un errore invece di essere stirata per entrare

Le regioni vengono da un passaggio a tile: i pixel cambiati sono raggruppati in quadretti TileSize e i tile adiacenti vengono fusi in una regione con i suoi confini e il suo conteggio di pixel. Tile più grandi danno regioni meno numerose e più grossolane, che di solito è ciò che un report di test vuole. L'overlay è una copia RGB della seconda pagina con i pixel cambiati tinti di rosso, restituita come byte grezzi in Comparison.Overlay; scriverla come PNG per gli artefatti della vostra CI è compito del vostro codice, non della libreria

Come gestisce le pagine inserite l'abbinamento a livello di documento?

TPDFlib.CompareDocumentVisualsWith abbina le pagine con una longest common subsequence su fingerprint di pixel esatti, così una pagina inserita non trasforma ogni pagina successiva in un falso cambiamento. Un ingenuo ciclo pagina 1 con pagina 1, pagina 2 con pagina 2 è inutile nel momento in cui un template si guadagna una copertina: ogni pagina dopo l'inserimento è scostata di uno e il report si accende di rosso dall'alto in basso

Ogni pagina è renderizzata e ridotta a una chiave fatta di larghezza e altezza in pixel e di un digest SHA-256 dei pixel visibili. La dimensione conta più di quanto sembri: una pagina verticale tutta bianca e una pagina orizzontale tutta bianca della stessa area hanno buffer di pixel byte-identici, e senza le dimensioni nella chiave si ancorerebbero l'una all'altra. Chiavi identiche da entrambi i lati diventano ancore, l'LCS sceglie la più lunga catena ordinata di esse, e le pagine fra due ancore consecutive sono abbinate in ordine. Quando un intervallo contiene più pagine da un lato, l'eccedenza viene riportata come added o removed

var
  Options: TPDFVisualCompareOptions;
  Report: WideString;
begin
  // Baseline e Candidate caricati come prima, con impostazioni di rendering corrispondenti
  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;

Le ancore sono bit-exact, ed è una scelta di progetto e non una svista. ColorTolerance si applica solo al confronto delle pagine abbinate, mai al fingerprint. Una pagina con nient'altro che rumore da anti-aliasing non diventerà un'ancora, ma finisce comunque in un intervallo, viene abbinata in ordine alla sua controparte e poi risulta unchanged sotto tolleranza. Ancore fuzzy sarebbero più permissive e molto meno prevedibili, perché pagine quasi duplicate (separatori bianchi, pagine di clausole ripetute) inizierebbero ad abbinarsi fra loro

Siate chiari su cosa è questo allineamento. Il JSON lo chiama exact-raster-lcs-gap-order apposta: è un allineamento raster, non un riconoscimento semantico delle pagine. Se un intervallo contiene una pagina riscritta e una pagina inserita, l'abbinamento in ordine può confrontare la riscrittura con l'inserimento e riportare l'originale come rimossa. I documenti pieni di pagine identiche sono altrettanto ambigui. Quando l'abbinamento conta per un verdetto, guardate le coppie, non solo i contatori

Leggere il report JSON

Il report del documento ha una forma fissa su cui potete fare assert in un test runner. In caso di successo porta "status":"complete", "complete":true, il nome dell'allineamento, firstPageCount, secondPageCount, e i contatori changedPages, addedPages, removedPages e unchangedPages. L'array pairs elenca poi ogni riga con firstPage, secondPage (0 per il lato mancante) e un kind pari a unchanged, changed, size_changed, added o removed

Le righe di tipo changed e unchanged incorporano un oggetto comparison con width, height, comparedPixels, changedPixels, ignoredPixels, changedRatio, boundingBox (null quando nulla è cambiato) e un elenco regions di bounds più changedPixels. Una riga size_changed non ha alcun confronto; riporta firstWidth, firstHeight, secondWidth e secondHeight e conta in changedPages. Stirare una pagina ridimensionata per confrontarla produrrebbe un muro di pixel cambiati privi di senso, quindi la libreria riporta il cambio di geometria e si ferma lì

Il report del documento non contiene mai mask né overlay. CompareDocumentVisualsWith forza lo spegnimento di IncludeMask e IncludeOverlay anche se le vostre opzioni li chiedono, così la memoria resta piatta mentre il numero di pagine cresce invece di tenere un'anteprima a pagina intera per ogni coppia. Usate il report per trovare le pagine interessanti, poi chiamate CompareRenderedPagesWith solo su quelle pagine per ottenere l'overlay. I fallimenti tornano come "status":"error", "complete":false con una stringa error, e LastErrorCode viene impostato a 401

Confrontare con un file usando CompareFileVisuals

TPDFlib.CompareFileVisuals è la forma a chiamata unica per la CI: carica l'altro PDF, copia su di esso le impostazioni correnti del renderer e restituisce lo stesso JSON del documento. La firma è CompareFileVisuals(OtherFileName, Password, DPI, ColorTolerance), dove la tolleranza deve essere da 0 a 255 e la DPI deve essere finita e positiva

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;

L'ereditarietà del renderer è il motivo per preferire questa chiamata al caricamento manuale del secondo file. Il documento caricato prende la scala di rendering, il tipo di crop e le impostazioni grafiche dell'istanza corrente, più la scelta del motore Cairo o PDFium quando sono compilati dentro. Se costruite a mano due istanze TPDFlib per CompareDocumentVisualsWith, configurarle identiche è affare vostro, e un disallineamento si manifesta come ogni pagina cambiata. Inoltre non può accettare regioni da ignorare; puntate alla API tipizzata quando vi servono

Limiti, costi e dove sta il diff strutturale

PDFlibPas limita esplicitamente il lavoro così un input ostile o enorme fallisce con un errore invece di esaurire la memoria. Una pagina renderizzata non può superare 64 Mi pixel, la griglia dei tile è tappata a 4 Mi tile, e l'allineamento delle pagine rifiuta input dove la somma dei due conteggi di pagina supera 8192 o la matrice LCS supererebbe 4 Mi celle. Il confronto fra documenti renderizza inoltre ogni pagina due volte, una per il fingerprint e una per il confronto abbinato, quindi mettete in budget questo costo a 150 DPI e oltre. Per la maggior parte delle suite di regressione, 72 o 96 DPI beccano i cambiamenti di layout e di testo in una frazione del tempo

Diff visuale e diff strutturale rispondono a domande diverse, e una pipeline seria di solito vuole entrambi. Il confronto a pixel dice se un lettore noterebbe qualcosa. Non può dirvi se un documento firmato si è guadagnato un'annotazione che si renderizza fuori pagina, o se un aggiornamento incrementale ha sostituito in silenzio il valore di un campo di un modulo con lo stesso testo visibile. Per quel livello, classificare cosa è cambiato in un PDF dopo la firma percorre il diff a livello di revisione. E poiché la scelta del motore guida i pixel, il rendering PDF multi-motore in Delphi merita una lettura prima di fissare una baseline su un solo motore

FAQ

Il confronto visuale di PDFlibPas stira le pagine che sono cambiate di formato?

No. CompareRenderedPagesWith restituisce un errore per pagine di dimensione in pixel diversa, e il report del documento marca tali coppie come size_changed con entrambe le dimensioni invece di confrontarle

Perché le pagine quasi identiche non vengono usate come ancore?

Le ancore usano un fingerprint SHA-256 esatto dei pixel visibili più la dimensione della pagina, così l'allineamento resta prevedibile. La tolleranza si applica dopo, quando le pagine abbinate vengono confrontate, quindi le pagine di soli rumori finiscono comunque unchanged

Si può ottenere un'immagine overlay per ogni pagina di un documento?

Non dalla chiamata del documento, che omette deliberatamente mask e overlay per tenere la memoria piatta. Usate il JSON per trovare le pagine cambiate, poi chiamate CompareRenderedPagesWith con IncludeOverlay su quelle pagine

Il confronto visuale, l'abbinamento delle pagine e il resto delle API di rendering e documento vengono spediti con la PDFlibPas PDF library for Delphi