Come scrivere un parser per i file PDF

In questo articolo, vi propongo un’analisi dello standard PDF, abbastanza generale, anche se mirata a scrivere un Parser che ci permetta di recuperare alcune informazioni di base (titolo, autore, oggetto, numero di pagine, contenuto delle pagine e così via).

Da dove iniziare?

Sicuramente dalla fine, nel senso che l’analisi di un file PDF deve iniziare necessariamente dalla sua fine. Per chiarire subito questa affermazione, credo sia opportuno fare alcune premesse.

I mattoni dello standard PDF

Un file PDF è sostanzialmente un file di testo, inteso come sequenza di caratteri e separatori di linea (ASCII 13 o ASCII 10), di tipo strutturato, ovvero in cui le informazioni assumono un particolare significato in quanto inserite in strutture che rispettano una particolare sintassi.

Gli elementi di base sono gli oggetti “obj”, che all’occorrenza possono contenere sequenze dati “stream”, dizionari “dictonary” o altro. Un oggetto può rappresentare una pagina, un’immagine, una sequenza grafica, ecc. Ogni oggetto, racchiuso tra le parole chiavi obj e endobj è identificato da un numero e da una revisione (per gli scopi di questo articolo non prenderemo in considerazione le modifiche di file creati in precedenza, ovvero quelli che vengono definiti aggiornamenti incrementali, quindi tutti i nostri oggetti avranno come numero di revisione 0).

Per maggiori approfondimenti, vedere la sezione Conoscere il formato PDF.

Altri elementi dello standard

La sintassi per gli altri elementi di base che lo standard prevede, è la seguente.

Valori logici

i valori ammessi sono true e false.

Valori numerici

possono essere interi o reali e le rappresentazioni ammesse sono del tipo

123
43445
+17 
.98
0
34.5 
-3.62 
+123.6 
-4. 
-.002
0.0

(non sono ammesse rappresentazioni in basi diverse, esadecimali o altro, e non sono ammesse rappresentazioni esponenziali tipo 6.02E23).

Stringhe di testo

Le stringhe sono delle sequenze di byte (0-255). Ed è possibile rappresentarle in due diversi modi: come una sequenza di caratteri, racchiusa da parentesi ( ); come una sequenza di dati esadecimali, racchiusa tra parentesi angolari < >; Esempio:

(questa è una stringa)
<4E6F762073686D6F7A206B6120706F702E>

Array

le liste di dati (array), sono delle sequenze, di valori di qualsiasi tipo, racchiuse da parentesi quadre [];

[549 3.14 false (stringa) /Objname]

Aggiornamenti incrementali

La struttura dello standard PDF prevede la possibilità che un documento sia oggetto di aggiornamenti incrementali, nel senso che è possibile modificare un documento PDF, semplicemente aggiungendo alla fine del file originale le nuove definizioni dei soli oggetti modificati, una nuova sezione XREF, per i soli oggetti modificati e una nuova sezione TRAILER che, oltre alle nuove informazioni aggiornate, abbia anche un riferimento alla sezione TRAILER precedente. Nell’esempio che segue, vengono ridefiniti gli oggetti 2, 7 e 8 e la sezione TRAILER prevede un riferimento (/Prev) alla sezione precedente:

2 0 obj
... ... ...
endobj

7 0 obj
... ... ...
endobj

8 0 obj
... ... ...
endobj

xref
2 1
0000001800 65535 f
7 2
0000001978 00000 n
8 3 0000001992 00000 f
trailer
<< /Size 12
/Root 1 0 R
/Prev 1855
>>
startxref
2027
%%EOF

Questo comporta che nella nostra analisi della sezione TRAILER e della sezione XREF, dovremo prevedere la possibilità che esistano degli aggiornamenti incrementali e che quindi occorra analizzare a ritroso tutte le versioni esistenti. Riferimenti indiretti Lo standard PDF prevede che alcuni oggetti (come ad esempio i dizionari) possano essere indicati in modo implicito o con riferenti indiretti. Quando è necessario effettuare un riferimento ad un oggetto, definito altrove nel documento, la sintassi prevede di indicare il suo numero e la revisione seguiti dalla lettera R. Esempio:

.. ..
/BS 4 0 R
.. ..
4 0 obj
<< /Type /Border
/W 2
/S /B
>>
endobj

Diversamente, l’oggetto potrebbe essere esplicitamente definito. Esempio:

.. ..
/BS <<
/Type /Border
/W 2
/S /B >>
.. ..

Ai fini della nostra analisi, non importa capire se una soluzione sia più ottimizzata dell’altra per velocità, memoria occupata, o altro, ma ci serve sapere che ogni volta che ci aspettiamo di trovare una definizione, potremmo trovare al suo posto un riferimento indiretto, che dovremo risolvere. Password e protezioni Com’è forse noto i documenti PDF posso essere protetti da password per far sì che il contenuto sia ad accesso limitato. Il sistema di protezione è tale che, sebbene la struttura del file rimane leggibile ed in chiaro e quindi sono ancora distinguibili gli elementi di base (oggetti, dizionari, stream, …), tutti i contenuti sono cifrati secondo un particolare algoritmo. Esula dal limitato spazio a nostra disposizione analizzare il tipo di algoritmo utilizzato e quindi, per le finalità di questo articolo, ci interessa semplicemente capire se il documento è protetto o no. Questa informazione è reperibile nella sezione TRAILER verificando la presenza della chiave /Encrypt, che se presente, è associato al riferimento indiretto dell’oggetto che descrive il tipo di protezione adottata, le password (non in chiaro) e le limitazioni imposte. Esempio:

trailer
<< /Size 10
/Info 10 0 R
/Root 9 0 R
/Encrypt 7 0 R
>>
startxref
2759
%%EOF

Analisi di un file PDF

Per non appesantire e allungare troppo il contenuto dell’articolo, eviterò di riportare qui il codice VB (per chi è interessato lo trova nella sezione software), limitandomi a presentare pezzi di pseudo-codice che all’occorrenza mi serviranno per spiegare meglio qualche concetto.

Passo 1

troviamo la sezione TRAILER Dovrebbe essere chiaro, da quanto fin qui scritto, che il primo passo consiste nel leggere un sufficiente numero di byte dalla fine del file per trovare la posizione della sezione TRAILER e la posizione della sezione XREF. La procedura, scritta in una pseudo-codice, dovrebbe essere simile a questa:

testo = LeggiFile(ultimi_caratteri)
p_trailer = Posizione(testo, “trailer”)
p_start = Posizione(testo, “startxref”)
p_end = Posizione(testo, “%%EOF”)
p_xref = Valore(testo[p_start, p_end])
trailer = Leggifile(p_trailer, p_start)

Dalla sezione TRAILER, possiamo ricavare il numero degli oggetti presenti nella XREF, l’oggetto /Info ed eventualmente la presenza (e la posizione) di un’altra sezione TRAILER:

n_object = Valore(trailer, “/Size”)
o_prev = Valore(trailer, “/Prev”)
o_info = Valore(trailer, “/Info”)
o_info = Valore(trailer, “/Root”)
o_encrypt = Valore(trailer, “/Encrypt”)

Se la chiave /Encrypt è valorizzata, la nostra analisi deve cessare, perché, per quanto già scritto, vuol dire che il file è protetto da password e che quindi le informazione non sono in chiaro. Se la chiave /Prev è valorizzata, vuol dire che esiste un’altra sezione TRAILER che bisogna analizzare. Nota la posizione della sezione XREF, possiamo ricavare la posizione assoluta di ogni singolo oggetto, rispetto al primo byte del file, leggendo la tabella XREF.

Passo 2

Analizziamo la tabella XREF La tabella XREF sarà utile per l’analisi di tutto il documento: per questo motivo possiamo memorizzare le informazioni presenti in un array, in cui l’indice sarà il numero dell’oggetto e il valore sarà la sua posizione assoluta nel file, con una procedura del tipo:

n_obj = LeggiNumeroOggetti
Dimensiona TabXREF(n_obj)
Per i = 1 fino a n_obj
  TabXREF(i) = LeggiPosizione

Se nell’analisi della sezione TRAILER abbiamo trovato che esiste una sezione XREF precedente (ovvero ci troviamo di fronte ad un file PDF modificato), sarà necessario, ricorsivamente (in quanto non è noto a priori quanti livelli di modifica sono stati apportati), valutare le altre tabelle, per trovare i valori degli oggetti non già definiti nelle tabelle precedenti, in quanto occorre ricordarsi che le modifiche incrementali vengono valutate dalla più recente alla più vecchia.

Passo 3

Interpretiamo il contenuto degli oggetti Nota la posizione assoluta della definizione di ogni oggetto nel file, per leggerne il contenuto, sarà sufficiente creare una procedura che ci permetta di catturarne il contenuto del file in una variabile, a partire dalla posizione memorizzata nella tabella TabXREF e fino alla fine dell’oggetto stesso, ovvero fino al tag endobj. Le informazioni generali del documento sono presenti nel dizionario a cui fa riferimento la chiave /Info. In corrispondenza delle varie chiavi, sono presenti delle stringhe con titolo, autore, soggetto, la data di creazione e così via. Esempio:

10 0 obj
<< /Title (titolo)
/Author (autore)
/Creator (creatore)
/Producer (software)
/CreationDate (D:20080623182428Z+01'00')
/Subject (soggetto)
/Keywords (parole) 
>>
endobj

Passo 4

contiamo e troviamo le pagine Per valutare il numero delle pagine e il loro contenuto, occorre analizzare il dizionario a cui fa riferimento la chiave /Root e all’interno di questo trovare l’oggetto a cui fa riferimento la chiave /Pages. Esempio:

9 0 obj
<< /Type /Catalog
/Pages 1 0 R
/Metadata 7 0 R
>>
endobj
.. .. .. .. .. .. .. .. ..
1 0 obj
<< /Type /Pages
/Count 3
/Kids [5 0 R 20 0 R 11 0 R]
>>
endobj

Nel dizionario Pages, la chiave /Count restituisce il numero delle pagine (nell’esempio 3) e la chiave /Kids contiene un’array di riferimenti agli oggetti che descrivono, nell’ordine, le singole pagine (nell’esempio gli oggetti 5, 20 e 11) .

Passo 5

Valutiamo il contenuto delle pagine L’analisi della chiave /Kids del passo precedente, ci ha permesso di ottenere una lista degli oggetti che descrivono le singole pagine del documento. Se vogliamo valutarne il contenuto, occorre soffermarci sulle caratteristiche degli oggetti, dei dizionari e degli stream che servono a descrivere una pagina. Se ad esempio, il file contiene queste informazioni

5 0 obj
<< /Type /Page
/Parent 1 0 R
/Contents 6 0 R
/MediaBox [0 0 596 842]
/Resources 2 0 R
>>
endobj
6 0 obj
<< /Filter [/ASCIIHexDecode]
/Length 234
>>
stream
3120302030202D3120302038343220636D200D0D30203020302
072670D30203020302052470D30203020302072670D42540D2F
417269616C20313420546620313420544C0D3120302030202D3
12032382E3334362032382E33343620546D0D2848656C6C6F29
20546A0D45540D3020302030207267
endstream

L’oggetto 5, a cui fa riferimento la chiave /Kids, è un dizionario che oltre ad altri riferimenti, contiene informazioni relative alla dimensione della pagina (chiave /MediaBox a cui è associato un array di valori numerici, X1, Y1, X2, Y2, che indicano rispettivamente le coordinate dei due spigoli opposti nel sistema di coordinate standard a 72 punti per pollice), informazioni sul dizionario che contiene gli elementi comuni utilizzati (chiave /Resources che fa riferimento ad un dizionario, che ad esempio, elenca i font utilizzati) e, la cosa a cui siamo più interessati, l’oggetto che contiene il contenuto vero e proprio della pagina (chiave /Contents).

L’oggetto a cui fa riferimento la chiave /Contents contiene un dizionario e uno stream: il dizionario specifica gli attributi della sequenza di dati contenuti nello strema. In particolare, per l’esempio mostrato, la chiave /Length indica che la lunghezza della sequenza, e la chiave /Filter associata ad un array, o ad un singolo valore, indica il tipo di rappresentazione dei dati (il filtro appunto).

Conclusioni

Per ulteriori dettagli, vi rimando alla lettura del codice che potete scaricare dal link segnalato, che non fa altro che implementare quanto fin qui abbiamo visto. Scritto volutamente in un linguaggio che fosse il più semplice possibile, il codice non fa uso di alcuna particolare funzione oltre il puro linguaggio basic. Per quanti volessero cimentarsi modificando il software proposto, potrebbe essere interessante valutare come estrapolare da un documento PDF degli elementi particolari, come immagini, testo, e così via, o come sfruttare questi concetti per modificare un documento PDF.