- XKCD, Good code.
Per concludere nel miglior modo possibile questa serie di articoli (qui la prima e la seconda parte), cosa ci può essere di meglio di un po’ di codice?
Estrarre il testo da un file PDF
Cominciamo dallo script in R, pdf2csv.R
, che estrae il testo da un file PDF, (che in questo caso specifico ho usato per estrarre i dati dalla domanda di partecipazione ad un concorso precedente). Qui sotto trovate l’immagine dello script, realizzata con Carbon (perché così è molto più bello), su GitHub c’è il sorgente vero e proprio, per chi voglia provare ad usarlo.
Per eseguire lo script è necessario aver installato sul proprio computer, non importa se è un Mac o un PC con Linux o Windows, l’ambiente R (in questo momento è disponibile la versione 4.0.3), meglio ancora se accompagnato da RStudio Desktop, che è di gran lunga il migliore sistema integrato di sviluppo (IDE) che abbia mai usato, oltre che uno strumento efficacissimo per affacciarsi all’uso di R.
Il codice è molto semplificato, ho tolto tutto ciò che non è strettamente necessario a far funzionare lo script. La chiave di tutto è la libreria pdftools per R. Di librerie per estrarre dati dai file PDF ne ho provate moltissime, sia per R che per Python, ma pdftools
le batte tutte per potenza, semplicità e velocità. Ci sono dei tool che convertono un PDF in testo al ritmo di una pagina al minuto, pdftools
riesce a convertire (molto bene, peraltro) un file di 400 pagine come questo in appena 5-6 secondi. C’è altro da aggiungere?
Lo script può essere utilizzato dalla linea di comando (per capirci, dal Terminale), lasciandolo esattamente com’è ed eseguendo il comando pdf2csv.R
seguito dal nome dal file da convertire (se il nome del file contiene degli spazi va scritto fra virgolette),
./pdf2csv.R file-da-convertire.pdf
che produrrà due file .csv
contenenti il testo estratto dal file PDF. Il primo, con lo stesso nome del file di partenza, ha le righe numerate e cerca di riprodurre per quanto è possibile il layout del file originale. Nel secondo, salvato con il suffisso -clean
, mancano i numeri di linea e vengono rimossi tutti gli spazi in eccesso, rendendolo più adatto ad una analisi automatica, in particolare quando il testo si estende per tutta la pagina (il primo file, invece, è molto più utile quando il testo è organizzato in colonne).
Prima di usare per la prima volta pdf2csv.R
bisogna renderlo eseguibile tramite il comando chmod
(ne ho già scritto diffusamente qui).
chmod u+x pdf2csv.R
In alternativa si può lanciare lo script tramite il comando Rscript
installato con R, senza che sia necessario renderlo eseguibile.
Rscript ./pdf2csv.R file-da-convertire.pdf
È preferibile che il file PDF da convertire si trovi nella stessa cartella di pdf2csv.R
. In caso contrario il testo estratto viene comunque salvato nella cartella dove si trova la script (ve l’avevo detto che lo script era molto semplificato!).
Per eseguire pdf2csv.R
all’interno di RStudio bisogna commentare la linea 12
(basta aggiungere un #
all’inizio della riga) e attivare la riga 14 o 15 (ma solo da una delle due) togliendo il #
iniziale. Se si attiva la riga 14, si deve anche modificare la stringa file-da-convertire.pdf
, sostituendola con il nome del file da convertire. Se invece si attiva la riga numero 15, al momento dell’esecuzione dello script comparirà una finestra grafica da cui selezionare il file PDF desiderato.
Nel repository su GitHub di questo articolo ho inserito dei file PDF di complessità crescente con cui fare qualche prova, fra cui un documento di quasi 1000 pagine (un vecchio manuale di riferimento del formato PDF, potevo scegliere qualcosa di diverso?), che può essere utile per valutare la velocità di conversione dello script. Non è necessario farlo a mano, il tempo di esecuzione di un qualunque programma o script si può misurare in modo preciso dal Terminale anteponendo il comando di sistema time
, come mostrato qui sotto.1
time ./pdf2csv.R PDFReference.pdf
Come piccola chicca finale, ho aggiunto al repository su GitHub un file PDF contenente del testo (apparentemente) nascosto, provate a convertirlo e vi accorgerete di quanto sia banale recuperare il testo completo.
Generare automaticamente dei documenti con AWK
Tirar fuori il testo contenuto in un file PDF è quasi sempre solo il primo passo del lavoro, perché quello che vogliamo veramente è filtrare il contenuto del documento mantenendo solo le informazioni che ci interessano. Nel caso specifico, io avevo bisogno di selezionare dalla domanda di concorso precedente solo i dati relativi ad una specifica tipologia di attività (ad esempio tutti gli articoli scientifici pubblicati), salvandoli in un file ad hoc. E, già che c’ero, volevo anche costruire una tabella LaTeX per ciascun articolo. Una cosa abbastanza facile da fare con AWK.
Di AWK ho già parlato tempo fa e non mi ripeterò, dirò solo che è un linguaggio ideale per analizzare un file di testo una riga alla volta, verificando se si presentano determinate condizioni ed eseguendo le operazioni programmate corrispondenti.
Nonostante i suoi tanti pregi, AWK ha una limitazione piuttosto seria: per come è strutturato, AWK deve per forza di cose esaminare tutto il file senza poter tornare indietro, e quindi è piuttosto difficile fargli eseguire delle operazioni basate su condizioni multiple complesse. È molto meglio (quando è possibile) scrivere più script AWK, da eseguire in sequenza sullo stesso file di partenza o sull’output generato dallo script precedente, piuttosto che cercare di combattere con le limitazioni del linguaggio, complicando a dismisura il codice.
In una prima versione di questo articolo avevo pensato di utilizzare un breve estratto della mia domanda di concorso precedente per descrivere il funzionamento degli script in AWK. Ma mentre scrivevo mi sono accorto che il discorso sarebbe stato così specifico da essere quasi inutile. Ho preferito quindi preparare un piccolo file PDF tratto dagli ultimi post pubblicati su Melabit, con l’intestazione in YAML2 di ciascun post seguita dalla prima frase del testo in Markdown e, quando c’è, dal link all’immagine iniziale. L’ho scelto perché la struttura di questo file assomiglia moltissimo a quella della mia domanda di concorso ma, allo stesso tempo, può essere uno schema di partenza applicabile a casi più generali.
Il file PDF si chiama Melabit ultimi post.pdf
e, come gli altri file PDF, è disponibile nel repository su GitHub di questo articolo. Se lo aprite con Anteprima, noterete subito che ci sono delle righe vuote che separano chiaramente un post (nel linguaggio dei database, un record) dall’altro. Ma convertendo il file in testo,
./pdf2csv.R "Melabit ultimi post.pdf"
(le virgolette sono necessarie perché il nome del file contiene degli spazi), le righe vuote scompaiono e le uniche interruzioni presenti nei due file CSV prodotti dallo script di conversione corrispondono al cambio pagina. Non so se questo sia un baco o una caratteristica voluta di pdftools
, ma sta di fatto che è una particolarità con la quale dobbiamo fare i conti se vogliamo analizzare il testo con AWK.
Sembra una sciocchezza, ma senza le giuste interruzioni non è immediato riconoscere la fine di un record prima di iniziare ad esaminare quello successivo, in modo da chiudere correttamente la tabella LaTeX corrispondente al record appena esaminato e ad aprire quella relativa al record successivo. Inoltre, mentre in questo caso specifico la struttura del file PDF è volutamente molto semplice e ripetibile, nella maggior parte dei casi reali il documento da cui estrarre i dati può contenere informazioni strutturate in modi diversi, i campi da analizzare possono essere distribuiti in modo irregolare o mancare del tutto e ci possono essere incongruenze nella loro denominazione. Gestire tutti i casi possibili con un unico script lo renderebbe rapidamente troppo complesso.
Molto meglio affrontare il problema un pezzetto alla volta, utilizzando uno script specifico per ciascun tipo di informazione da estrarre (io ho avuto bisogno di 6 script AWK per eseguire tutto il lavoro di esportazione dei dati, o meglio quasi tutto il lavoro, perché per i casi meno frequenti ho preferito il buon vecchio copia-incolla manuale). In fondo è la stessa logica di Unix, che mette a disposizione un gran numero di strumenti semplici che messi insieme, come tanti mattoncini Lego, riescono a fare cose incredibili.
Un primo script, addblanklines.awk
, può servire per inserire nel file CSV di partenza una riga vuota prima di ogni record (una cosa piuttosto semplice da fare in questo caso, dato che ogni post inizia sempre con la stringa “layout: post”). Lo script, appena quindici linee di codice, lo trovate “in bella” nell’immagine qui sotto (ma anche in questo caso il sorgente è su GitHub).
Bastano solo due linee di codice, la #4 e la #9, per aggiungere le righe vuote al posto giusto. Ma già che ci siamo, è conveniente dare anche una ripulita al file CSV togliendo le righe inutili, come quelle che contengono il numero di pagina o la stringa ---
che segna l’inizio e la fine dell’intestazione in YAML (linee #5 e #12). Eseguendo lo script sul file CSV originale, si ottiene un nuovo file CSV con i vari record ben separati uno dall’altro.
./addblanklines.awk "Melabit ultimi post-clean.csv" > file-con-righe-vuote.csv
Fatto questo, il passo successivo è semplice. Basta scansionare il file CSV appena generato, file-con-righe-vuote.csv
, in cerca della stringa target layout: post
e, ogni volta che se ne trova una, generare una nuova tabella LaTeX riempiendola con i dati tratti dalle voci (o più propriamente campi) successive. Il codice del secondo script, cvs2table.awk
, è visibile nell’immagine qui sotto (mentre il sorgente è sempre su GitHub).
Lo script è relativamente lungo, sono più di 80 linee di codice, compresi commenti e righe vuote, ma una gran parte serve per implementare la funzione (linee #3-25) che riarrangia le informazioni presenti su più linee consecutive del file CSV in modo che vengano stampate su un’unica riga, e per generare la struttura di base del documento LaTeX (linee #35-42 e #83).
Tolte queste, il resto del codice è semplice, si tratta più che altro di scrivere le stringe giuste al momento giusto e di tenere conto dei casi in cui le informazioni si estendono su più linee consecutive (come succede ad esempio alle linee #61-62 e #66-73). Non entrerò nei dettagli di come funziona lo script, questo non è un corso di AWK (né tantomeno di R), basterà per ora dire che è scritto in modo da essere facilmente adattato a gestire esigenze analoghe. Per usarlo, si deve eseguire lo script usando come file di input file-con-righe-vuote.csv
e salvando il risultato dell’elaborazione in un file LaTeX, che qui sotto ho chiamato (con la mia solita scarsa fantasia) ` lista-articoli.tex`.
./cvs2table.awk file-con-righe-vuote.csv > lista-articoli.tex
Mettere tutto insieme
Proviamo allora ad eseguire tutti insieme gli script presentati in questo articolo, in modo da ottenere il risultato finale desiderato. Dobbiamo prima di tutto convertire il file PDF in CSV con
./pdf2csv.R "Melabit ultimi post.pdf"
che genera automaticamente il file “Melabit ultimi post-clean.csv”. Fatto questo, si eseguono in sequenza i due script AWK, salvando l’output del primo in un file intermedio.
./addblanklines.awk "Melabit ultimi post-clean.csv" > file-con-righe-vuote.csv
./cvs2table.awk file-con-righe-vuote.csv > lista-articoli.tex
Il risultato finale è un file LaTeX ben ordinato con una tabella per ogni articolo, come quello mostrato nella figura qui sotto la cui regolarità, messa in evidenza dai colori delle parole chiave, fa pensare ad uno spartito musicale.
Ma ha senso creare un file intermedio solo per trasferire l’output del primo script al secondo? Molto meglio usare il meccanismo di piping tipico in Unix, con il quale si può trasferire automaticamente il risultato dell’esecuzione di un comando all’ingresso di quello successivo, collegandoli con il carattere |
(pipe)?3 Con il piping, i due comandi AWK precedenti possono essere eseguiti uno dopo l’altro in questo modo,
./addblanklines.awk "Melabit ultimi post-clean.csv" | ./cvs2table.awk > lista-articoli.tex
evitando l’uso di un file intermedio. In questo caso non fa molta differenza, ma quando si devono trattare file molto grossi, il piping è molto più efficiente (con i velocissimi dischi SSD odierni non ce ne accorgiamo più, ma ai tempi dei dischi meccanici la scrittura di grossi file sul disco era un vero collo di bottiglia) e, cosa che non guasta mai, evita di intasare il disco rigido con un gran numero di file inutili.
E poi il piping è un meccanismo intrinsecamente elegante, che non a caso è stato adottato anche in alcuni linguaggi di programmazione odierni, come si può vedere nello script R mostrato nella prima parte di questo articolo (linee #24-25 e #35-37), dove il simbolo |
usato in Unix è sostituito dalla strana combinazione di caratteri %>%
, piuttosto fastidiosa da scrivere con una tastiera italiana (io almeno sbaglio sempre qualcosa).
Conclusioni
Chi ha l’occhio allenato si accorgerà facilmente che il file LaTeX risultante contiene alcuni errori piuttosto evidenti. Li ho lasciati apposta non solo per non complicare ulteriormente il codice, ma anche per mostrare quanto sia complicato il lavoro di estrazione automatica dei dati da file strutturati in modo non perfettamente regolare. Non è certo un caso che in questo campo ci sia una grossa attività di ricerca che prova a superare gli ostacoli e a rendere il tutto il più semplice e il più efficiente possibile.
-
Il comando
time
è presente di default nei sistemi operativi Unix come Linux e macOS. Su Windowstime
non esiste, ma si possono usare degli strumenti equivalenti. ↩ -
YAML è un linguaggio di markup particolarmente adatto per definire dei file di configurazione e, in generale, per rappresentare informazioni strutturate in modo semplice e leggibile, molto più facile da usare di strumenti più noti come XML e JSON. ↩
-
Il piping è uno dei meccanismi principali che rendono Unix una specie di Lego informatico. ↩