Un lettore PDF non inizia dall'inizio del file. Inizia dalla fine. Gli ultimi byte contengono l'indirizzo di tutto il resto, e un parser che non capisce quell'ordine leggerà in modo errato il formato dalla prima riga. Quindi il modo più utile per imparare come è fatto un PDF su disco è impararlo come fa un lettore: prima la fine, poi si salta indietro alla mappa, per poi risolvere gli oggetti puntati dalla mappa
I byte stessi sono abbastanza chiari da leggere in un editor di testo quando nulla è compresso. Un documento minimo di una pagina che disegna "Hello, World!" sta in meno di cinquecento byte, e ogni elemento strutturale del formato è visibile in esso. Ecco l'intero file, con le quattro parti contrassegnate:
%PDF-1.0 % Header
%âãÏÓ
1 0 obj % Body (Corpo): la sequenza di oggetti
<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>
endobj
2 0 obj
<<
/Rotate 0
/Parent 1 0 R
/Resources 3 0 R
/MediaBox [0 0 612 792]
/Contents [4 0 R]
/Type /Page
>>
endobj
3 0 obj
<< /Font << /F0 << /BaseFont /Times-Italic /Subtype /Type1 /Type /Font >> >> >>
endobj
4 0 obj
<< /Length 65 >>
stream
1. 0. 0. 1. 50. 700. cm BT
/F0 36. Tf
(Hello, World!) Tj
ET
endstream
endobj
5 0 obj
<< /Pages 1 0 R /Type /Catalog >>
endobj
xref % Tabella dei riferimenti incrociati (Cross-reference table)
0 6
0000000000 65535 f
0000000015 00000 n
0000000074 00000 n
0000000192 00000 n
0000000291 00000 n
0000000409 00000 n
trailer % Trailer
<<
/Root 5 0 R
/Size 6
>>
startxref
459
%%EOF
Quattro parti, sempre in questo ordine lungo il file: un header, un corpo di oggetti, una tabella di riferimenti incrociati (cross-reference table) e un trailer. L'inghippo è che li leggi quasi in ordine inverso. L'ISO 32000-2 §7.5.1 espone la stessa anatomia in quattro parti, e il motivo dell'accesso dall'indietro in avanti è puramente pratico: un lettore che salta direttamente all'oggetto di cui ha bisogno è molto più veloce di uno che esamina ogni byte dall'inizio, e quell'accesso casuale è esattamente ciò che il trailer e la tabella xref esistono per fornire
L'header è composto da due righe, e la seconda è importante
La prima riga è %PDF-1.0. Il segno di percentuale lo rende un commento per quanto riguarda la sintassi, ma i lettori lo trattano come la firma del file ed estraggono il numero di versione da esso. La gestione delle versioni è flessibile nella pratica. Un lettore creato per PDF 2.0 aprirà senza problemi un file che dichiara di essere 1.0, e la maggior parte dei lettori proverà un file la cui versione dichiarata è sbagliata o la cui riga di versione è sepolta un po' all'interno del file piuttosto che al byte zero. Il numero è un suggerimento su quali funzionalità aspettarsi, non una porta
La seconda riga è quella che le persone eliminano per sbaglio e poi passano un pomeriggio a eseguire il debug. Anche questo è un commento, ma il suo carico utile è di quattro byte superiori all'ASCII 127. Esistono in modo che tutto ciò che sposta il file in "modalità testo" lo riconosca come binario e smetta di riscrivere i finali di riga. Un PDF trasporta stream compressi i cui byte possono corrispondere a un ritorno a capo (carriage return) o a un avanzamento riga (line feed) per coincidenza; se uno strumento di trasferimento li riscrive, la lunghezza dello stream registrata nel dizionario non corrisponde più ai byte su disco e il file risulta corrotto. Il commento con byte alti è una difesa di quarant'anni fa contro l'FTP in modalità ASCII, ed è ancora in ogni file scritto da uno strumento serio perché l'errore che previene è silenzioso e totale
Il corpo contiene gli oggetti, ognuno numerato
Tutto ciò che compone il documento vive nel corpo come una sequenza piatta di oggetti indiretti. Ciascuno si apre con due numeri interi e la parola chiave obj, contiene il suo contenuto e si chiude con endobj. L'oggetto 1 nell'esempio sopra è il nodo dell'albero delle pagine: 1 0 obj, poi un dizionario, quindi endobj. Il primo numero intero è il numero dell'oggetto, il secondo è il numero di generazione. La generazione è quasi sempre zero in un file appena scritto; sale solo quando un numero di oggetto viene riutilizzato in più modifiche, il che è abbastanza raro da poter trattare una generazione diversa da zero come un segno che il file ha subito aggiornamenti incrementali. Il contenuto tra le parole chiave qui è un dizionario, scritto tra << e >>, ma potrebbe anche essere un numero, una stringa, un array o uno stream
Ciò che lo rende un grafo piuttosto che un elenco è il token di riferimento 2 0 R. Questo significa "oggetto 2, generazione 0, ovunque si trovi nel file". Il nodo dell'albero delle pagine di cui sopra non contiene la sua pagina; punta all'oggetto 2, che a sua volta punta alle sue risorse e al content stream con lo stesso meccanismo. Il corpo è disposto nell'ordine che l'autore ha ritenuto conveniente e i riferimenti lo cuciono in un albero con radice nel catalogo. La posizione nel file non ha alcun significato. L'identità deriva dal numero dell'oggetto e la posizione proviene dalla tabella xref
La tabella xref è un indice di offset di byte
La tabella xref è ciò che trasforma i numeri di oggetto in posizioni di file. È il motivo per cui un lettore può aprire un documento di mille pagine ed eseguire il rendering della pagina 850 senza analizzare le 849 pagine che la precedono. Ogni voce registra esattamente dove inizia il suo oggetto, contato in byte dall'inizio del file:
xref
0 6 % 6 voci, partendo dall'oggetto 0
0000000000 65535 f % voce 0: testa dell'elenco libero (free list)
0000000015 00000 n % l'oggetto 1 inizia al byte 15
0000000074 00000 n % l'oggetto 2 inizia al byte 74
0000000192 00000 n % l'oggetto 3 inizia al byte 192
0000000291 00000 n % l'oggetto 4 inizia al byte 291
0000000409 00000 n % l'oggetto 5 inizia al byte 409
La larghezza fissa è intenzionale. Ogni voce è di esattamente venti byte: un offset di dieci cifre, uno spazio, una generazione di cinque cifre, uno spazio, un tipo di un carattere e un fine riga di due byte. Poiché le righe sono uniformi, un lettore può indicizzare direttamente la voce per l'oggetto n tramite l'aritmetica anziché la scansione, quindi la tabella che fornisce l'accesso casuale al corpo è essa stessa accessibile in modo casuale. La riga 0 6 è l'intestazione di una sottosezione: indica che le voci successive descrivono sei oggetti a partire dal numero 0
L'oggetto 0 è speciale e sempre presente. Il suo tipo è f per libero (free), la sua generazione è 65535 ed è in testa all'elenco collegato (linked list) dei numeri di oggetti liberi. In un file che non è mai stato modificato l'elenco libero è solo questa singola voce, una formalità. Si guadagna da vivere durante gli aggiornamenti incrementali, quando l'eliminazione di un oggetto aggiunge il suo numero a quell'elenco in modo che una modifica successiva possa recuperarlo. Le altre voci sono di tipo n per in uso, e il loro numero a dieci cifre è l'offset che dovresti cercare per leggere la definizione di quell'oggetto
Il trailer è il punto di ingresso e si trova alla fine
Il trailer è la prima cosa che un lettore consuma effettivamente, anche se è scritto per ultimo. Un parser apre il file, va alla fine e torna indietro alla ricerca di %%EOF. Subito sopra si trova startxref seguito da un singolo numero, e quel numero è l'offset di byte della parola chiave xref. Con esso il lettore salta direttamente alla tabella xref senza aver scansionato un singolo oggetto:
trailer
<<
/Root 5 0 R % il catalogo del documento
/Size 6 % uno in più rispetto al numero di oggetto più alto
>>
startxref
459 % offset di byte della tabella xref
%%EOF
Il dizionario del trailer porta i due valori di cui un lettore ha bisogno prima di poter fare qualsiasi altra cosa. /Root punta al catalogo del documento, qui l'oggetto 5, che è la cima del grafo degli oggetti e il percorso verso l'albero delle pagine. /Size è il conteggio delle voci che la tabella xref dovrebbe contenere, che è uno in più rispetto al numero dell'oggetto più alto a causa della voce libera allo slot zero. Da %%EOF scaturisce l'intera sequenza di lettura: trova l'indicatore, leggi startxref per individuare la tabella, carica la tabella per sapere dove si trova ogni oggetto, leggi /Root per trovare il catalogo e risolvi gli oggetti su richiesta da lì. L'header, che si trova all'inizio, viene a malapena consultato fino a tardi. La mappa in fondo è ciò di cui il lettore ha bisogno per primo
L'aggiornamento incrementale aggiunge una seconda mappa invece di riscrivere
Quella progettazione orientata dalla coda ripaga quando un file cambia. Un PDF può essere modificato senza riscrivere nessuno dei byte già presenti su disco. Oggetti nuovi e modificati vengono aggiunti alla fine, seguiti da una nuova sezione xref e da un nuovo trailer, e il file originale sottostante viene lasciato intatto. L'unico nuovo elemento di contabilità è una voce /Prev nel nuovo trailer, che contiene l'offset dei byte della tabella xref precedente:
% ... il file originale, inalterato, finisce qui ...
6 0 obj % un oggetto aggiunto da questa modifica
<< /Type /Annot /Subtype /Text /Rect [100 700 120 720] >>
endobj
xref % una seconda sezione xref, solo per il nuovo oggetto
6 1
0000000612 00000 n
trailer
<<
/Root 5 0 R
/Size 7
/Prev 459 % offset di byte della precedente tabella xref
>>
startxref
680 % offset di questa nuova sezione xref
%%EOF
Un lettore inizia ancora dal %%EOF finale, segue ancora startxref fino alla tabella più recente, ma ora segue la catena /Prev a ritroso fino alle tabelle più vecchie, unendole in modo che la voce più recente per qualsiasi numero di oggetto vinca. Le sezioni dei riferimenti incrociati formano un elenco collegato (linked list) lungo il file, ognuna delle quali sovrascrive quella precedente per gli oggetti che tocca. Un oggetto che è stato sostituito da una modifica esiste ancora fisicamente al suo vecchio offset; semplicemente non è più raggiungibile, perché una successiva voce xref punta a qualcosa di più recente
Questo è il meccanismo che rende verificabili i PDF firmati. Una firma digitale copre un intervallo di byte del file e, poiché un aggiornamento incrementale accoda solamente, i byte firmati non si spostano mai. La firma è ancora valida rispetto all'intervallo originale mentre le revisioni successive si trovano oltre di esso, ciascuna con la propria xref e trailer. È anche il motivo per cui un PDF può portare una cronologia recuperabile: ogni oggetto sostituito è ancora su disco sotto una precedente sezione xref, che è una funzionalità per il monitoraggio delle versioni e un rischio per chiunque pensasse che "elimina" significasse che i byte erano spariti
Il costo è la crescita. Ogni modifica viene aggiunta; nulla viene recuperato sul posto, quindi un file revisionato molte volte accumula oggetti morti e una lunga catena di sezioni xref. Il rimedio è una riscrittura completa: carica il documento e salvalo a nuovo, il che rinumera gli oggetti sopravvissuti, elimina quelli irraggiungibili ed emette una singola tabella xref pulita. Le due strategie si scambiano l'una con l'altra direttamente. L'accodamento è veloce e preserva le firme e la cronologia; la riscrittura è più lenta e scarta entrambe, in cambio di un file compatto
Leggere le quattro parti in pratica
Conoscere il layout è sufficiente per eseguire il debug a mano della maggior parte dei problemi di tipo "questo file non si apre". Se un lettore rifiuta un PDF, i soliti colpevoli sono alle due estremità, non nel mezzo. Un download interrotto perde il trailer, quindi manca startxref o %%EOF e il lettore non ha alcun punto di ingresso; i lettori tolleranti ricorrono alla scansione dell'intero file per ricostruire l'xref, che è esattamente il percorso lento che la tabella doveva evitare. Un trasferimento errato in modalità testo corrompe i byte dello stream o gli offset smettono di corrispondere alla realtà e gli oggetti si caricano dalla posizione sbagliata. Quando gli offset nella tabella non puntano più alle vere parole chiave obj, il file è strutturalmente rotto anche se ogni oggetto è individualmente corretto
Per il nuovo codice, la lezione del layout è lasciare che una libreria gestisca la contabilità dei byte. Gli offset nella tabella xref devono concordare con le posizioni effettive di ogni oggetto in base ai byte, il trailer deve puntare alla tabella giusta e gli aggiornamenti incrementali devono concatenarsi correttamente tramite /Prev. Un componente nativo come l'HotPDF Component per Delphi e C++Builder gestisce tutto questo quando scrive un file, inclusa la scelta tra l'accodamento di una revisione incrementale e la riscrittura di una compatta. Se vuoi vedere la stessa struttura costruita dal nulla piuttosto che sezionata, l'articolo complementare sulla creazione di un documento PDF da zero illustra in ordine come vengono emessi header, oggetti, xref e trailer