Extraire un tableau d’un PDF pour importer les données

J’ai recommencé à tenir mes comptes dans l’excellent GnuCash. Comme je n’ai pas le temps (ni l’envie) de tout saisir pour ensuite rapprocher les comptes, j’importe les données depuis les fichiers téléchargés sur Internet et je me contente d’affecter les revenus et dépenses, et vérifier que tout est normal.

Mais tous les organismes ne fournissent pas de fichiers directement exploitables. Beaucoup se contentent de fichiers PDF…

J’ai donc écrit un script bash (et GNU awk et sed), se basant sur pdftohtml, un outil disponible via Xpdf ou Poppler.

Ce script prend en paramètre (« -f ») un fichier PDF et en extrait les données tabulaires pour les mettre au format TSV. Le résultat est affiché sur la sortie standard (ce qui permet d’enchaîner un traitement supplémentaire) ou écrit dans un fichier (avec le paramètre « -t »). Optionnellement, les « . » peuvent être remplacés par des « , » dans les nombres (option « -c »), ou réciproquement (option « -d ») ; les blancs superflus peuvent aussi être retirés (option « -b »). L’option « -g » permet d’utiliser Zenity pour demander le chemin du fichier à écrire, ce qui permet par exemple de créer une action personnalisée dans le gestionnaire de fichiers.

Avec quelques-uns de mes fichiers et des fichiers que j’ai empruntés pour mes tests, j’ai pu constater que globalement ce script fonctionne. Il part cependant du principe que les données tabulaires sont majoritaires dans le document. Ainsi, avec une facture de Gandi, contenant un tableau d’une ligne seulement, ce sont les mauvaises données qui sont extraites. En revanche, j’ai pu extraire les bonnes données de fichiers PDF issus de Mercer, de Bouygues Telecom, de Oney, etc. Le résultat est correct mais moins intéressant à partir de fichiers PDF dont la mise en page est complexe ou étrange à la base, comme SFR ou la CPAM

À noter : le but de ce script étant d’importer les données dans un logiciel qui les retraite, il ne cherche qu’à récupérer les données de base. Aucun effort n’a été fait pour récupérer les totaux, sous-totaux, intitulés, etc.

Voici le code :

#!/bin/bash
#[-b remove leading and trailing blanks from fields]
#[-c convert dots to commas ]
#[-d convert commas to dots ]
# -f FILE from this PDF file
#[-g to the TSV file chosen through the zenity GUI ]
#[-t FILE to this TSV file (else standard output) ]

TRIM=
CONV=
FROM=
TO=
DATA=/tmp/pdfdata_$RANDOM_$$.xml

while getopts bcdf:gt: arg; do case "$arg" in
b) TRIM=y ;;
c) CONV=c ;;
d) CONV=d ;;
f) FROM="$OPTARG" ;;
g) TO="$(zenity --file-selection --save --confirm-overwrite --file-filter='*.tsv')" ;;
t) TO="$OPTARG" ;;
esac; done

[ -f "$FROM" ] || exit 1
[ -n "$TO" ] && { exec >"$TO" || exit 2; }
trap "rm -f ${DATA}*" EXIT

# get the raw data in XML (with text coordinates)
pdftohtml -noframes -xml -i "$FROM" "$DATA" >/dev/null

# pre-format by removing the XML
sed -ri '
s#<[^<>"]*>##g
s#[a-z]+="([0-9]+)"#\t\1\t#g
s#[[:blank:]]*\t>?#\t#g
s#&lt;#<#g
s#&gt;#>#g
s#&amp;#\&#g
' "$DATA"

# extract the coordinates and the text
gawk -F$'\t' '
$1=="<page"{page=$2}
$1=="<text"{printf("%d%04d\t%d\t%d\t%d\t%d\t%s\n",page,$2,$3,$4,$5,$6,$7)}
' "$DATA" >"$DATA.tmp"
mv -f "$DATA.tmp" "$DATA"

# filter out non-table contents and some PDF garbage
arraycells=$(cut -d$'\t' -f5 "$DATA" | sort | uniq -c | sort -k1,1nr | head -n1 | gawk '{print $2}')
sort -t$'\t' -k1,2n "$DATA" | gawk -F$'\t' -vfilter=$arraycells '
$5!=filter {next}
($1 $2)==prevtl {next}
nprev>1 {print prev}
nprev==1 && $1==prevt{print prev}
$1==prevt {nprev=nprev+1}
$1!=prevt {nprev=1;prevt=$1}
{prev=$0;prevtl=($1 $2)}
END {if(nprev>1)print prev}
' >"$DATA.tmp"
mv -f "$DATA.tmp" "$DATA"

# detect columns, then format the output
sort -t$'\t' -k2,2n -k3,3nr "$DATA" | gawk -F$'\t' '
function min(a,b) {return (a<b?a:b)}
function max(a,b) {return (a>b?a:b)}
function output(c){printf("%s\t%s\t%s\n",$1,c,$6)}
BEGIN {minx[0]=0;delete minx[0]}
{
col=1
while(1){
if(col>length(minx)){
minx[col]=$2
maxx[col]=($2+$3)
output(col)
break
}else if(($2+$3)>minx[col] && $2<maxx[col]){
minx[col]=min(minx[col],$2)
maxx[col]=max(maxx[col],$2+$3)
output(col)
break
}else
col=col+1
}
}
' \
| sort -t $'\t' -k1,1n -k2,2nr | gawk -F$'\t' '
function output() {for(i in x)printf("%s\t",x[i]);printf("\n")}
$1==prevt {x[$2]=$3}
$1!=prevt && NR!=1{output()}
$1!=prevt {delete x;for(i=1;i<=$2;i++)x[i]=(i==$2?$3:"");prevt=$1}
END {if(NR>1)output()}
' \
| if [ "$CONV" == 'c' ]; then
sed -r 's#([0-9]+)\.([0-9]+)#\1,\2#g'
elif [ "$CONV" == 'd' ]; then
sed -r 's#([0-9]+),([0-9]+)#\1.\2#g'
else
cat
fi \
| if [ -n "$TRIM" ]; then
sed -r 's#^ +|(\t) +#\1#g;s# +$| +(\t)#\1#g'
else
cat
fi

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.

La discussion continue ailleurs

URL de rétrolien : http://yalis.fr/cms/index.php/trackback/83

Fil des commentaires de ce billet