Immagina di scrivere un piccolo validatore. Apre un PDF, scorre fino alla fine, trova startxref, legge l'offset e si aspetta di trovare la parola chiave xref con sotto una tabella di riferimento incrociato a larghezza fissa. Da quella tabella raccoglie gli offset degli oggetti, quindi esegue una scansione all'indietro alla ricerca della parola chiave trailer per ricavare /Root e /Size. Funziona perfettamente su ogni file generato per testarlo. Poi, arriva un file prodotto da una versione recente di Word o da una libreria destinata a PDF 1.5, e il validatore lo dichiara corrotto. Non c'è alcuna parola chiave xref dove punta l'offset, nessun dizionario trailer, e la tabella degli oggetti creata dal validatore è quasi vuota. Il file è valido. Il validatore lo sta leggendo con criteri obsoleti di quindici anni fa.
Questo è il motivo più comune per cui una verifica a livello di byte scritta basandosi sul layout classico fallisce sui documenti moderni. La struttura su cui fa affidamento (la tabella di riferimento incrociato in testo normale e la parola chiave trailer) è stata resa opzionale in PDF 1.5 ed è spesso assente. Due funzionalità l'hanno sostituita: lo stream di riferimento incrociato (cross-reference stream) e lo stream di oggetti compressi (compressed object stream). Entrambi sono descritti nello standard ISO 32000-1, e un validatore che non li gestisce vede un file corretto come un insieme di oggetti mancanti.
Cosa ha cambiato il PDF 1.5 nella parte finale dei file
Lo standard ISO 32000-1 §7.5.8 definisce lo stream di riferimento incrociato, e il §7.5.7 definisce lo stream di oggetti di tipo /ObjStm. Insieme, consentono a un writer di omettere le due strutture su cui si basa un parser classico. Un file PDF 1.5 può non contenere affatto la tabella xref. Al suo posto, l'oggetto a cui punta startxref è un normale oggetto stream il cui dizionario porta la voce /Type /XRef, e tale stream contiene i dati di riferimento incrociato in forma binaria compatta. Non c'è nemmeno la parola chiave trailer, poiché il trailer corrisponde ora al dizionario dello stream stesso. Le chiavi cercate da un parser classico, /Root, /Size e /ID, risiedono all'interno di quel dizionario.
La seconda modifica riguarda la posizione degli oggetti. Invece di scrivere ciascun oggetto indiretto al proprio offset di byte, un writer può raggruppare molti piccoli oggetti (dizionari delle pagine, delle annotazioni, l'albero di struttura) in un unico stream di oggetti e comprimere l'intero contenitore con Flate. I singoli oggetti non hanno più un offset di byte nel file, hanno una posizione all'interno di un blocco compresso. Un validatore che analizza i byte grezzi alla ricerca di 1 0 obj non li troverà mai, poiché tale testo esiste solo dopo la decompressione. Per un parser classico, metà del documento è semplicemente scomparsa.
Le chiavi del trailer sono in testo normale, anche in un file compresso
L'aspetto rassicurante è che la lettura del trailer di uno stream di riferimento incrociato non richiede alcuna decompressione. Un oggetto stream viene scritto come un dizionario seguito dalla parola chiave stream e poi dai byte compressi. Il dizionario è in testo normale. Perciò, quando startxref punta a uno stream di riferimento incrociato, i byte subito dopo il numero dell'oggetto appaiono come un dizionario standard, e /Root, /Size e /ID si trovano in chiaro, prima della parola chiave stream e dell'inizio dei dati Flate.
Ciò significa che un validatore può ricavare le tre informazioni fondamentali (la posizione del catalogo, quanti oggetti dichiara il file e l'identificatore del file) analizzando unicamente il dizionario dello stream. Non deve decomprimere i dati di riferimento incrociato, né interpretarne le voci binarie. L'operazione che mette in difficoltà un parser semplice non è leggere il trailer, bensì individuare gli oggetti. Si tratta di due problemi distinti, e risolvere il primo richiede poche risorse.
Stream di oggetti: un'intestazione seguita da un blocco Flate
Uno stream di oggetti è un contenitore. Il suo dizionario porta la voce /Type /ObjStm, un valore /N che indica il numero di oggetti racchiusi all'interno, e un valore /First che fornisce l'offset in byte, all'interno dei dati decompressi, in cui inizia il corpo del primo oggetto. Il contenuto compresso, una volta decompresso, inizia con una piccola intestazione di /N coppie di interi. Ciascuna coppia corrisponde a un numero di oggetto e all'offset del corpo di tale oggetto rispetto a /First. Dopo l'intestazione seguono i corpi degli oggetti stessi, concatenati.
L'espansione è un'operazione meccanica una volta decompressi i byte. Si legge il dizionario per ricavare /N e /First, si decomprime lo stream con un decodificatore Flate, si scorrono le prime /N coppie per conoscere quale numero di oggetto risiede a ciascun offset, e infine si estrae ciascun corpo come se fosse un normale oggetto indiretto. L'unica vera dipendenza è il decodificatore Flate, già disponibile: Delphi include System.ZLib e Free Pascal fornisce l'unità zstream, entrambe le quali racchiudono zlib e decomprimono uno stream Flate grezzo senza codice di terze parti. Una routine che aggiunge ogni oggetto estratto alla tabella degli oggetti del validatore consente al resto del codice (la parte che esamina /Root e controlla l'albero delle pagine) di comportarsi esattamente come su un file classico.
Cosa non è necessario implementare
È facile sovrastimare il lavoro richiesto. Leggere le chiavi del trailer da un file compresso non richiede la decodifica delle voci binarie dello stream di riferimento incrociato. Lo stream di riferimento incrociato descritto nel §7.5.8 utilizza tre tipi di voci, e la voce di tipo 2 (quella che indica questo oggetto risiede nello stream di oggetti N all'indice i
) è ciò che dovresti decodificare per creare una mappa completa degli offset. Tale mappa è necessaria per risolvere oggetti arbitrari in base al numero. Non serve invece per leggere /Root, /Size e /ID, che si trovano nel dizionario in chiaro, e non serve per espandere gli stream di oggetti, poiché ciascun /ObjStm dichiara il proprio contenuto tramite /N e /First.
Inoltre, non è necessario gestire le funzioni di predizione (predictor) PNG e TIFF che uno stream di riferimento incrociato può applicare tramite /DecodeParms al solo scopo di ricavare le chiavi del trailer. I predittori filtrano le righe binarie dei riferimenti incrociati per ottimizzarne la compressione; non hanno alcuna relazione con il dizionario che precede lo stream. L'aggiornamento minimo per rendere un validatore classico compatibile con i PDF moderni è quindi ridotto: quando startxref fa riferimento a uno stream anziché alla parola chiave xref, si analizza il dizionario dello stream per ricavare le chiavi del trailer e si espande qualsiasi oggetto /ObjStm rilevato inserendone il contenuto nella tabella degli oggetti. La decodifica delle voci di tipo 2 e dei predittori rappresenta un'attività separata e più complessa, da rimandare a quando sarà realmente necessaria la risoluzione casuale degli oggetti.
Perché una verifica di conformità deve prima espandere gli stream
Questo cessa di essere un discorso teorico non appena si esegue una verifica di conformità a un profilo. Un validatore PDF/A o PDF/X controlla oggetti specifici: il catalogo del documento alla ricerca di un array /OutputIntents, lo stream /Metadata per un pacchetto XMP con il corretto identificatore, ogni descrittore di font alla ricerca del relativo file incorporato, il trailer per la voce /ID. In un file compresso, la maggior parte di questi oggetti risiede all'interno di stream di oggetti. Un validatore che non ha espanso tali stream non può vedere le chiavi del catalogo, non trova i metadati e non può elencare i font. Di conseguenza, segnalerà un documento perfettamente conforme come privo di intento di rendering, di metadati XMP e di metà della sua struttura, poiché le informazioni necessarie risiedono ancora in un blocco Flate non decompresso.
L'ordine è fondamentale
L'ordine è fondamentale. L'espansione deve avvenire prima dell'esecuzione dei controlli, non in parallelo, poiché ciascuna verifica assume di poter raggiungere un oggetto in base al numero. Se si collega un controllo di conformità a un profilo direttamente su una scansione di byte grezzi, questo eredita i limiti del parser classico e produce falsi errori proprio sui file moderni che hanno la massima probabilità di essere corretti, essendo stati generati da strumenti abbastanza recenti da scrivere stream di riferimento incrociato.
Lasciare che sia PDFium a eseguire il parsing
Il componente PDFium analizza gli stream di riferimento incrociato e di oggetti durante il caricamento di un documento, offrendo una soluzione pratica per evitare la gestione manuale di decompressione ed espansione. Quando si carica un file con il componente TPdf, gli oggetti racchiusi nei contenitori /ObjStm risultano già risolti, e i punti di ingresso di convalida vedono il documento completamente espanso. ValidatePdfA restituisce un record TPdfAValidationResult il cui campo Conformance è un valore TPdfAConformance come pac1b o pacNone, il cui campo Issues contiene l'insieme dei problemi specifici riscontrati, e il cui metodo IsCompliant è vero solo se è stato rilevato un livello di conformità e l'insieme delle anomalie è vuoto. Poiché gli oggetti sono stati espansi durante il caricamento, un array /OutputIntents o un font incorporato situati in uno stream di oggetti verranno individuati correttamente.
uses
PDFium, FPdfPdfa;
function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True; // parses xref/object streams on load
Result := Pdf.ValidatePdfA; // sees the expanded object table
finally
Pdf.Free;
end;
end;
Lo stesso vale per ValidatePdfX, che restituisce un record TPdfXValidationResult con la medesima struttura. Il vantaggio di affidarsi a PDFium è che la decompressione strutturale descritta avviene una sola volta, in modo corretto, all'interno del loader, perciò il codice di convalida non avvertirà alcuna differenza tra un file classico e uno interamente compresso. Entrambi si presentano al validatore come un insieme risolto di oggetti.
var
Pdf: TPdf;
R : TPdfXValidationResult;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'Press_Ready.pdf';
Pdf.Active := True;
R := Pdf.ValidatePdfX;
if R.IsCompliant then
Writeln('PDF/X conformance: ', Ord(R.Conformance))
else
Writeln('Not conformant; issue count = ', SizeOf(R.Issues));
finally
Pdf.Free;
end;
end;
Se i byte si trovano già in memoria anziché su disco, la medesima sequenza di caricamento e convalida funziona tramite l'overload LoadDocument(const Data: TBytes), che accetta il contenuto grezzo del file ed esegue il parsing dei relativi stream nello stesso modo in cui farebbe per un percorso di file. L'aspetto fondamentale da ricordare per un validatore scritto manualmente è la regola strutturale, non l'API: leggere le chiavi del trailer in chiaro dal dizionario dello stream, espandere ogni /ObjStm con un decodificatore Flate prima di scorrere il documento, e considerare la decodifica delle voci binarie di riferimento incrociato come un'attività secondaria e opzionale.
Una volta espansa la struttura, un validatore può gestire il resto del flusso di lavoro su di essa. Per un sistema da riga di comando che rapporti la conformità su una cartella di input, si veda la nostra guida alla creazione di uno strumento CLI per report di preflight in batch. Quando la convalida rappresenta un filtro preliminare prima di dividere un documento di grandi dimensioni, le tecniche illustrate nella nostra guida alla suddivisione di documenti PDF in più file si integrano perfettamente con il modello di caricamento e controllo qui illustrato. Entrambi gli scenari si basano sulla superficie di caricamento e convalida del PDFium Component per Delphi e C++Builder.