From e487789fdb845c8a56f8799a0e5773898e7ffbb8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?St=C3=A9phan=20Bernard?= <stephan.bernard@inrae.fr>
Date: Mon, 18 Oct 2021 10:46:16 +0200
Subject: [PATCH] Un premier commit sur la V2 avant de laisser reposer quelques
 temps. Pas encre de sortie html, il faut utiliser la V1.

---
 src/py/README.md     |  727 +------------------------
 src/py/pdf2blocks.py | 1216 +++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 1229 insertions(+), 714 deletions(-)

diff --git a/src/py/README.md b/src/py/README.md
index 6025736..dfb82fa 100644
--- a/src/py/README.md
+++ b/src/py/README.md
@@ -1,5 +1,18 @@
 # Version python de pdf2blocks
 
+## 0 Information de versions
+
+Les résultats de la première version de pdf2blocks sont assez imparfaits,
+notamment dans la reconnaissance des tableaux.
+
+Ceci est une version 2, actuellement en cours de développement.
+**Cette version n'est actuellement pas utilisable.**
+Elle ne produit pas de (encore) sortie html.
+
+Dans les grandes lignes, la version 2 n'utilise plus les blocs de pdftotext.
+Elle ne s'appuie que sur la position de chaque mot et recompose la structure
+des ligne et blocs.
+
 ## 1 Introduction
 
 ### 1.1 Objectif
@@ -20,40 +33,18 @@ On l'exécute depuis la ligne de commande :
 
 >     python pdf2blocks.py /chemin/vers/fichier.pdf
 
-Le résultat est écrit sur la sortie standard. Il est facile de la rediriger
-dans un fichier html.
-
-### 1.3 Exemple de résultat :
-
-    <hr />
-    <header>page 7</header>
-    <h2> Vignoble Beaujolais – Coteaux du
-    Lyonnais</h2>
-    <h3>Données du réseau : 27 parcelles renseignées sur 28</h3>
-    <h3>Stades phénologiques</h3>
-    <h4>Cépages</h4>
-    <p>Gamay</p>
-    <p>Chardonnay</p>
-    <h4>Le plus tardif</h4>
-    <p>baies de la taille d'un pois, les
-    grappes pendent (31)
-    les baies se touchent (31-33)</p>
-    <h4>Majoritaire</h4>
-    <p>les baies se touchent (31-33)</p>
-    <h4>Le plus avancé</h4>
-    <p>fermeture de la grappe (33)</p>
-
-### 1.4 Dépendances
+On peut aussi lui donner la liste des fichiers à traiter sur l'entrée standard :
+
+```sh
+ls /chemin/vers/fichiers.pdf/*.pdf | python pdf2blocks.py
+```
+
+### 1.3 Dépendances
 
 *pdf2blocks* utilise actuellement *pdftotext* et *pdftohtml*, deux outils basés
 sur la librairie poppler, dérivée de Xpdf. Ces deux outils prennent en entrée
 un fichier pdf.
 
-*pdf2blocs* utilise aussi la librairie
-[PyEnchant](https://pyenchant.github.io/pyenchant/index.html) qui lui fournit
-un dictionnaire et des outils de recherche. PyEnchant est distribué sous
-la licence [LGPL](http://www.gnu.org/copyleft/lesser.html).
-
 
 #### 1.4.1 pdftotext
 *pdftotext* est destiné à produire une sortie en texte brut,
@@ -158,680 +149,4 @@ successives sont parfois regroupées dans le même segment de texte.
 
 ## 2 L'algorithme de pdf2blocks
 
-*pdf2bocks* fusionne les blocs issus de *pdftotext* avec les fontes de
-*pdftohtml* pour dériver une liste ordonnée de blocs caractérisés par des
-éléments de la structure logique.
-
-
-### 2.1 pdftotext
-Le programme *pdf2blocks* commence par lancer la commande suivante :
-
-     pdftotext -bbox-layout -eol unix /path/to/file.pdf
-
-Le résultat de cette commande est stocké dans une liste python. Cette liste
-a été nommée ***blocks***. Cette liste est initialisée avec une partie des
-sorties de *pdftotext*.
-
-Cette liste reprend la structure xml de *pdftotext* à partir de la balise
-*block*.
-Les balises *page* de pdftotext ont été remplacées par un attribut associé
-aux éléments de la liste *blocks* donnant le numéro de page.
-Les balises *flow* de *pdftotext* ont aussi été remplacées par un attribut
-pour les éléments cette liste. La valeur de cet attribut est  incrémenté à
-chaque fois que la balise *flow* est rencontrée.
-On peut ainsi identifier les blocs qui font partie d'un même flow.
-
-Les éléments de la liste *blocks* ont donc la structure suivante :
- *blocks*:
-  - **page :** Le numéro de page calculé à partir de la sortie de pdftotext.
-  - **flow :** Un identifiant unique pour chaque balise flow calculé à partir
-  de la sortie de pdftotext.
-  -  **x_min**, **x_max**, **y_min** et **y_max** : Les coordonnées du bloc
-  dans la page, données par pdftotext.
-  -  **h_min** et **h_max** : Les hauteurs minimum et maximum des lignes du
-  bloc (calculées à partir des valeurs *height* de chaque ligne du bloc,
-  voir ci-dessous).
-  - **right**, **left**, **top**, **down** : La distance au bloc le plus
-  proche situé respectivement à droite, à gauche, au-dessus et en-dessous
-  du présent bloc. Cette distance est calculée à l'aide des xMin, xMax, ...
-  Elle est négative s'il n'y a pas de bloc à la droite (à la gauche, ...)
-  du bloc.
-  - **right_block**, **left_block**, ... : Désignent le bloc le plus proche
-  à la droite, (à gauche, ....) du bloc. Il s'agit du bloc retenu comme
-  étant la plus proche lors du calcul des distances right, left, ...
-  - **nb_cars** et **nb_words** : Le nombre de caractères et le nombre de
-  mots du bloc (calculés).
-  - **class :** contient le résultat du processus de classification de blocs.
-  La valeur par défaut de cet attribut est BL_UNDEF.
-  - **lines :** Une liste, contenant les lignes ordonnées par pdftotext.
-  C'est, comme *blocks*, une liste de dictionnaires python, contenant :
-     - **text :** Le texte contenu dans cette ligne.  
-       Ce texte est la concaténation du contenu des balises word de
-      *pdftotext*.
-       Il est composé de chaque mot de la ligne séparé d'une espace, sauf si
-       le premier mot est un unique caractère plus grand que les suivants
-       (dans ce cas on considère que c'est un effet de texte
-       et l'espace n'est pas ajoutée).
-     - **height :** La hauteur de ligne calculée correspondant
-       à ```yMax - yMin```, coordonnées de la ligne,
-       renvoyées par *pdftotext*.
-     - **nb_words**, **nb_cars** et **flags** : sont les mêmes que pour les
-       blocs, avec les informations relatives à la ligne.
-     - **words :** Une autre liste de dictionnaires, qui contient :
-          - **text :** le mot contenu dans la balise word de *pdftotext*.
-          - **height :** la hauteur du mot  calculée correspondant
-            à ```yMax - yMin```, coordonnées du mot
-            renvoyées par *pdftotext*.
-
-Lors de la concaténation du contenu des balises word effectuée pour renseigner
-la valeur de lines['text'], un traitement particulier est effectué pour détecter
-et traiter deux cas :
-
-1. Les "L ignes" ayant un premier caractère mis en exergue, qui se retrouve
-  séparé du reste du mot. Dans le cas où la première
-  ligne d'un bloc commence par une balise *word* d'une seule lettre majuscule,
-  on teste si cette lettre est dans le dictionnaire puis si le mot suivant
-  l'est aussi. Dans le cas contraire on teste si la concaténation des deux mots
-  est dans le dictionnaire. Si c'est le cas, alors on garde le mot concaténé.
-  À noter que pour les aulx, 'A' et 'il' sont dans le dictionnaire. Néanmoins,
-  l'absence de sens grammatical d'une phrase commençant par "A il " nous
-  permet de le traiter comme un cas particulier.
-1. Les "T I T R E S" ou lignes mal justifiées dans lesquelles les espacements
-  de caractères sont tels que *pdftotext* insère une espace entre chaque
-  caractère. Ces lignes sont détectées en calculant la moyenne du nombre de
-  lettres par mot. En-dessous d'un seuil (actuellement 3), la proportion de
-  lettres et d'espaces est calculée et doit être supérieure à un seuil
-  (actuellement deux-tiers).
-  Pour les lignes qui correspondent, le traitement repose sur la recherche de
-  deux valeurs d'espacement horizontal, pour tenter de distinguer
-  un espacement entre deux lettres d'un espacement entre deux mots. À l'aide
-  de ces valeurs, la phrase est recomposée en collant les lettres. Ensuite,
-  un comptage des mots présents dans le dictionnaire est effectué sur la ligne
-  ainsi recomposée et est comparé au même comptage effectué sur le ligne
-  renvoyée par *pdftotext*. Le texte ayant la plus forte proportion de mots
-  trouvés dans le dictionnaire est conservé.
-
-### 2.2 pdftohtml
-Ensuite, le programme *pdf2blocks* lance la commande *pdftohtml* :
-
-     pdftohtml -xml -i -stdout /path/to/file.pdf
-
-#### 2.2.1 Les fontes renvoyées par *pdftohtml*
-Les fontes de caractères renvoyées par *pdftohtml* sont stockées dans une
-liste python nommée **fontspec** (du nom de la balise xml associée).
-Les éléments de cette liste sont des dictionnaires python ayant la structure
-suivante :
-
-- **id :** Un identifiant unique pour désigner la fonte.
-- **size :** La taille de la fonte, en "pixels" (px).
-- **family :** Le nom de la fonte, tel que renvoyé par *pdftohtml*,
-  éventuellement suivi d'attributs de style séparés par des virgules.
-  Exemples :
-    - "ABCDEE+Calibri"
-    - "ABCDEE+Calibri,Bold"
-    - "ABCDEE+Calibri,BoldItalic"
-    - "ABCDEE+Symbol"
-- **color :** La couleur du texte, en format html. Exemples :
-    - "#000000"
-    - "#b366b3"
-- **nb_cars** nombre de caractères qui ont cette fonte. Ce nombre n'est pas
-  présent dans le résultat xml de la commande *pdftohtml*. Il est calculé
-  lors de l'extraction du texte.
-
-#### 2.2.2 Le texte renvoyé par la commande *pdftohtml*
-Les segments de texte renvoyés par *pdftohtml* représentent la plupart du
-temps une ligne de texte entière. La liste qui les contient a été nommée
-**segments**. Ses éléments, extraits du résultat xml de *pdftohtml*,
-ont la structure suivante :
-
-- **text :** Le texte du segment, contenu de la balise \<text\> de pdftohtml.
-- **font :** L'identifiant de la fonte pour ce segment.
-- **page :** Le numéro de page du segment.
-- **top**, **left**, **width** et **height** : Les coordonnées du segment de
-  texte dans la page.
-
-### 2.3 Traitements
-À ce point, nous avons trois listes, **blocks**,
-**fontspec** et **segments**, dont la structure est décrite ci-dessus.
-
-La plupart des traitements sont effectués sur la liste **blocks**, qui
-contient une base de la structure logique du document. Le but est
-d'identifier le rôle de chacun des blocs (titre, sous-titre, ...).
-
-La liste **blocks** contient le résultat de la commande *pdftotext*, dont le
-but est de restituer, à partir du document pdf, un texte lisible de haut en bas
-dans une console texte.
-Toutefois, l'ordonnancement des blocs par *pdftotext* s'est révélé peu
-adapté à la lecture des Bulletins de Santé du Végétal. C'est pourquoi
-un ré-ordonnancement des blocs spécifique est effectué par *pdf2blocks*.
-
-Les chapitres suivants décrivent les traitements, effectués séquentiellement,
-sur les données issues des deux commandes *pdftotext* et *pdftohtml*.
-
-#### 2.3.1 Détermination de la taille de la fonte par défaut
-La taille de fonte par défaut est déterminée à partir de la liste **segments**
-et **fontspec**, c'est à dire à partir des sorties de *pdftohtml*.
-
-La taille retenue est celle du plus grand nombre de caractères associés à
-chaque taille de fonte de la liste **fontspec**.
-
-#### 2.3.2 Détection des pieds de page
-
-Les "pieds de page" d’un document sont une zone de texte répétée
-à chaque fin de page contenant par exemple le numéro de la page
-et un texte caractéristique du document comme son titre ou ses auteurs.
-
-Les notes de bas de page ne sont pas considérées comme faisant partie
-des pied de page.
-
-L'algorithme utilise les listes *lines* contenues dans **blocks**.
-Il consiste à tester si la dernière ligne de chaque page
-a le même contenu textuel, en ne considérant que les caractères alphabétiques
-([a-zA-Z]). Les caractères numériques ne sont pas pris en compte afin que
-les numéros de pages puissent être considérés comme des pieds de page.
-
-Tant que des lignes sont détectées comme étant des pieds de page,
-on teste la ligne précédente, et ainsi de suite.
-
-Les lignes détectées comme étant des bas de page ont la valeur
-BL_BOTTOM_PAGE dans leur attribut class.
-
-#### 2.3.3 Détection des en-tête
-
-L'algorithme est similaire à la détection des pieds de page, mais au lieu
-de s'appliquer aux dernières lignes de chaque page, il considère les premières
-lignes.
-
-D'autre part, si le document n'a que deux pages, on regarde si les premières
-lignes de chacune des deux pages sont identiques pour détecter les en-têtes.  
-Si le document a plus de deux pages, on détecte d'abord s'il existe des
-en-têtes à partir de la seconde page et ensuite on teste si il existe
-une en-tête avec le même contenu  dans la première page.
-
-Les lignes détectées comme étant des en-tête ont la valeur BL_TOP_PAGE
-dans leur attribut class.
-
-
-#### 2.3.4 Attribution de fontes aux blocs
-Il s'agit d'attribuer l'*id* d'une fonte de **fontspec** à chaque ligne
-de la liste **blocks**.
-
-L'algorithme consiste, pour chaque ligne de bloc (élément de la liste
-**lines**), à calculer sa
-[distance de Levenshtein](https://en.wikipedia.org/wiki/Levenshtein_distance)
-avec chaque segment de texte (élément de la liste **segments**).
-On attribue alors à la ligne la fonte du segment ayant le meilleur score
-(c'est à dire la distance la plus faible).
-
-Ce calcul de distance est nécessaire du fait de la segmentation des lignes
-renvoyées par *pdftohtml*, qui ne correspondent pas exactement aux lignes
-renvoyées par *pdftotext*. Par ailleurs, l'ordre des lignes dans une même
-page est parfois différent entre les deux outils.
-
-Néanmoins, lorsqu'une ligne est reconnue à l'identique, elle est marquée et
-n'est plus utilisée dans l'algorithme.
-
-À noter que cet algorithme n'est pas du tout optimisé. Il a été écrit pour
-tester le principe, qui semble satisfaisant, mais il nécessite d'être
-réécrit pour que son exécution soit plus rapide.
-
-#### 2.3.5 Ré-ordonnancement des blocs
-Avant de reconstituer la structure du document, un ré-ordonnancement des blocs
-est effectué, en deux temps :
-
-1. détection de colonnes
-1. parcours de l'ensemble des blocs et tenant compte des positions des colonnes
-  suivant le sens de lecture.
-
-Ceci est effectué page par page.
-
-##### 2.3.5.1 Détection de colonnes
-La détection de colonnes ne considère que les blocs :
-
-- de plus de 3 lignes
-  (ce seuil pouvant être modifié dans la constante MIN_LINES_IN_COLUMN_BLOCK),
-- dont la taille de fonte est inférieure ou égale à la taille de fonte
-  par défaut,
-- et dont la longueur de ligne est au moins égal à un seuil
-  (MIN_CAR_IN_COLUMN_BLOCK - actuellement 20), pour les distinguer des colonnes
-  de tableaux.
-
-Sur ces blocs :
-
-1. On cherche le bloc situé le plus à gauche de la page (le plus petit x_min).
-1. On place une verticale V1 à gauche de ce bloc.
-   C'est la verticale de la première colonne.
-3. Puis on parcourt l'ensemble des blocs dont l'arête gauche (x_min) est située
-   à proximité de V1 pour chercher le bloc le moins large (le plus petit x_max).
-4. On pose une seconde verticale V' sur l'arête droite de ce bloc (x_max).
-5. On parcourt l'ensemble des blocs, pour construire l'ensemble des blocs dont
-   l'arête de gauche (x_min) est à droite de V'.
-6. On détermine le bloc le plus à gauche de cet ensemble (le plus petit x_min) .
-7. On place une verticale intitulée V2 sur l'arête gauche de ce bloc (x_min).
-   Il s'agit de la verticale de la seconde colonne.
-
-On recommence le processus de détection d'une autre colonne depuis le point 3
-(en considérant Vi=V2, puis V3, ...), tant qu'il y a des blocs à l'étape 5.
-Toutefois, on considère l'ensmble des blocs dont l'arête gauche est
-à proximité de Vi ou à gauche de Vi pour la détermination de V'.
-
-La dernière verticale est placée au niveau de l'arête droite
-du bloc situé le plus à droite (le plus grand x_max).
-
-Les verticales ainsi définies seront ensuite utilisées pour identifier des
-colonnes. Une colonne est localisée entre deux verticales.
-
-L'ensemble des éléments de la liste *blocks* est enrichi avec deux nouveaux
-attributs :
-
-- **col_min** : le numéro de colonne contenant l'arête gauche du bloc,
-- **col_max** : le numéro de colonne contenant l'arête droite du bloc.
-
-En effet, certains blocs peuvent chevaucher plusieurs colonnes (par exemple
-des titres). Les deux attributs ont la même valeur quand le bloc
-est entièrement contenu dans une seule colonne.
-
-Hypothèse de travail :  
-Si une page contient plusieurs colonnes et qu'un titre chevauche plusieurs
-colonnes, le sens de lecture sera de lire en premier tous les blocs au dessus
-de ce titre sur toutes les colonnes.  
-Si une page contient plusieurs colonnes et que les titres sont totalement
-contenus dans une colonne, le sens de lecture se fera colonne par colonne.
-
-Pour définir le sens de lecture on effectue l'opération suivante :
-les blocs ayant une fonte plus grande que la fonte par défaut (les titres
-potentiels) sont élargis autant que possible vers la droite de la page
-(accroissement de leur x_max) jusqu'à rencontrer un autre bloc
-ou jusqu'au bord de la page. La valeur de *col_max* est ajustée en conséquence.
-
-![figure A](images/Titles.gif)  
-
-Cette opération est nécessaire pour définir le sens de lecture correct
-quand un titre pourrait déborder sur plusieurs colonnes mais qu'il est
-trop court, et de fait entièrement contenu dans une seule colonne.
-
-
-##### 2.3.5.2 Parcours des blocs dans le sens de lecture
-
-L'objectif est d'ordonner les blocs d'une même page dans leur sens de lecture,
-c'est à dire de haut en bas et de gauche à droite.
-
-Une nouvelle liste ordonnée **ordered_blocks** de blocs est créée
-pour une page donnée.
-
-Les en-têtes sont tout d'abord insérées dans la liste **ordered_blocks**
-dans l'ordre donné par *pdftotext*.
-
-Au fur et à mesure que les blocs sont ajoutés dans la liste **ordered_blocks**,
-ils sont retirés des blocs à parcourir (stockés dans la liste **blocks**).
-L'algorithme effectue une boucle jusqu'à épuisement des blocs à parcourir.
-
-On définit une zone rectangulaire à traiter inclue dans la page. Cette zone est
-initialisée à la première colonne (la colonne la plus à gauche).
-
-La boucle principale de l'algorithme effectue les tâches suivantes :
-
-- Recherche du bloc **H** le plus haut dont l'arête gauche est dans la zone ;
-  Ce bloc va définir l'arête haute de la zone.
-  - si aucun bloc n'est trouvé cela veut dire qu'il n'existe pas de bloc
-    dont l'arête gauche se trouve dans la zone. Alors, la zone est élargie
-    vers la droite et une nouvelle recherche est relancée.
-  - si aucun bloc n'est trouvé dans la zone
-    et que la zone comprend la dernière colonne,
-    on élargit la zone (à gauche) à l'ensemble des colonnes.  
-    Ceci est dû aux déplacements de la zone dans le processus de parcours,
-    qui peut avoir décalé la zone vers la droite en laissant des blocs
-    à gauche.
-  - Le cas où l'on ne trouve pas de bloc et que la zone comprend l'ensemble
-    des colonnes signifie la fin de l'algorithme.
-    Il n'y a plus de blocs à traiter.
-- Parmi tous les blocs dont l'arête haute est au même niveau que l'arête haute
-  de **H** (modulo un petit intervalle correspondant à la valeur de
-  VERTICAL_ALIGMENT_THRESHOLD), on cherche le bloc le plus à gauche intitulé
-  'HG' (le bloc le plus haut et le plus à gauche de la zone).
-  **HG** peut être le même bloc que **H**.
-- Étant donné **HG**, trois cas sont considérés :
-  * **A** : Dans le général, à l'exception des cas **B** et **C**,
-    **HG** est ajouté à **ordered_blocks** et la zone est ajustée
-    à celle de **HG** (pour que les blocs situés dans la même colonne que **HG**
-    soient parcourus avant ceux, éventuellement plus hauts, de la colonne
-    suivante).
-  - **B** : Si l'arête droite du bloc **HG** est au delà de l'arête droite de
-    la zone. C'est par exemple le cas d'un titre sur toute la largeur de
-    la page. Dans ce cas, la zone est agrandie jusqu'à l'arête droite de **HG**.
-    Tous les blocs de la zone situé au dessus de **HG** sont insérés
-    dans la liste **ordered_blocks**, avant **HG**.  
-    ![figure B](images/CaseB.gif)  
-    *Dans la figure, le bloc "B" doit être lu avant "T".*
-
-  - **C** : Si il existe un bloc non traité dans une colonne à gauche
-    de la zone à traiter, et dont le haut est au-dessus du bas de **HG**
-    (ce bloc est "au niveau de **HG**").
-    Ceci se produit quand un titre sur plusieurs colonnes
-    ou un bloc situé à droite (à côté d'une photo par exemple) vient d'être
-    ajouté à la liste **ordered_blocks**.  
-    Dans ce cas, on réinitialise la zone à l'ensemble des
-    colonnes (ce qui aura pour effet de traiter en priorité les blocs
-    de gauche).
-    ![figure C](images/CaseC.gif)  
-    *Sur cette figure, le bloc "C" doit être lu avant "D".*
-
-Une fois cette boucle terminée, **ordered_blocks** contient la liste des blocs
-dans un ordre de lecture estimé convenable.
-
-La liste **ordered_blocks** est enrichie des blocs marqués comme pieds de page.
-
-#### 2.3.6 Reconstitution de la structure du document
-
-Les en-tête et pieds de page ont déjà été identifiés.
-
-Les lignes ne comportant aucun caractère alphanumérique sont ignorées.
-
-L'algorithme de reconstitution de la structure du document se fait en deux
-étapes :
-
-- La première étape cherche à classer des blocs en fonction de
-  caractéristiques. Certaines caractéristiques permettent de définir
-  des prototypes.
-  C'est à dire que la classe associée à un prototype est certaine.
-- La seconde vise à identifier les blocs restants par des recherches de
-  similarités avec les prototypes identifiés lors de la première étape.
-
-Avant de rechercher à reconstituer la structure du document, on calcule
-pour chaque bloc la liste des caractéristiques suivantes :
-
-- un booleen HAS_BULLET : s'il existe au moins une ligne du bloc
-  qui ne commence pas par un caractère alphanumérique,
-- un booleen FLAG_VERTICAL lorsque le bloc contient au moins une ligne
-  de plus de 3 caractères plus haute que large,
-- *bloc_size* : le nombre de caractères contenus dans le bloc,
-- *max_line_size* : le plus grand nombre de caractères contenu dans une ligne,
-- *not_numbers* : la proportion de caractères non-numériques dans le bloc,
-- *last_character* : le dernier caractère du bloc (pour tester la présence
-  de ponctuation),
-- *nb_lines* : le nombre de lignes contenu dans le bloc,
-- *font_class* : une caractérisation de la taille de la fonte du bloc, parmi
-  les valeurs FONT_TINY, FONT_SMALL, FONT_DEFAULT, FONT_BIG et FONT_HUGE.
-  Les seuils pour distribuer ces tailles, exprimés en pixels par rapport
-  à la taille de la fonte par défaut, sont donnés dans le tableau
-  FONT_THRESHOLDS (actuellement [ -3, -1, 0, 2, 7]),
-- *alignment* : une caractérisation de la forme du bloc, déterminée
-  pour les blocs de plus de deux lignes. Cet attribut contient l'une des valeurs
-  suivantes :
-  - ALIGN_JUSTIFIED : si le bloc fait au moins 3 lignes et que les
-    arêtes gauches et droites des lignes sont alignées,
-  - ALIGN_LEFT : si les arêtes gauches des lignes sont alignées mais pas les
-    arêtes droites,
-  - ALIGN_RIGHT : si les arêtes droites des lignes sont alignées mais pas
-    les arêtes gauches,
-  - ALIGN_CENTERED : si les milieux de lignes sont alignés mais pas les arêtes
-    gauches et droites,
-  - ALIGN_UNDEF dans tous les autres cas.
-
-L'alignement des blocs s'entend modulo une constante exprimée en pixels
-et nommée VERTICAL_ALIGMENT_THRESHOLD.
-
-
-##### 2.3.6.1 Détermination des classes de blocs par des règles
-
-La caractérisation d'un bloc s'appuie sur une série de règles. Lorsqu'un bloc
-correspond à une des règles, on lui attribue une "classe" (titre, légende,
-paragraphe, tableau, ...). On dira que le bloc est "marqué".
-
-Certaines règles permettent de définir des blocs prototypes. C'est à dire que
-les caractéristiques du bloc permettent de déterminer sa classe sans ambiguïté.
-
-La liste des règles est ordonnée. Lorsqu'une règle permet de marquer
-un bloc, les règles suivantes ne sont pas testées sur ce bloc.
-
-Voici la liste des règles pour déterminer les blocs prototypes  :
-
-1. Des expressions régulières, données dans les listes CREDITS_REGEX,
-  COPYRIGHT_REGEX et CONTACT_REGEX sont testées sur le contenu textuel
-  des blocs. En cas de succès, le bloc est marqué BL_MISC.
-  Il s'agit d'un paragraphe ou d'une note de pas de page identifiant les auteurs
-  du BSV, ou les droits de propriété intellectuelle sur son contenu.
-1. Un bloc sera marqué BL_PARAGRAPH si :
-  - sa police majoritaire est la fonte par défaut,
-  - son dernier caractère est un caractère de ponctuation,
-  - sa plus longue ligne est supérieure à un seuil (PARAGRAH_LINE_SIZE,
-    actuellement 40 caractères),
-  - et s'il n'est ni centré ni aligné à droite.
-1. Si un bloc a une proportion de caractères non-numériques inférieure
-  à un seuil (NUMBERING_THRESHOLD, actuellement 0,3), une taille de fonte
-  ne dépassant pas celle de la police par défaut et des lignes ne dépassant
-  pas un seuil de caractères (TABLE_LINE_SIZE, actuellement 25), alors
-  ce bloc est marqué BL_TABLE.  
-  Lorsqu'un bloc est ainsi marqué, on teste les blocs non marqués qui le
-  précèdent et qui le suivent, en omettant la condition sur le nombre de
-  caractères non-numériques et en ramenant la condition de longueur de ligne
-  à la longueur moyenne de ligne. Ces blocs sont aussi marqués BL_TABLE, et on
-  continue de tester les blocs précédents et suivants tant que la condition
-  est remplie.
-1. Les expressions régulières contenues dans la liste CAPTION_REGEX sont
-  testées ("crédit photo", "photo :", ...). En cas de succès le bloc
-  est marqué BL_CAPTION (légende).
-
-
-Voici la liste des règles pour déterminer d'autres classes sans générer de prototypes  :
-- Les blocs marqués FLAG_VERTICAL sont marqués BL_MISC (miscellanous).
-- Les blocs ne contenant pas de caractère alphanumériques sont ignorés ;
-  il sont marqués BL_IGNORE.
-- Si on ne trouve aucune légende, d'autres règles plus souples sont testées:
-  - la fonte est plus petite que la fonte par défaut,
-  - la longueur des lignes est inférieure à PARAGRAH_LINE_SIZE (40)
-  - le bloc est centré ou aligné à gauche, ou indéterminé.
-  - Les expressions régulières contenues dans LINK_REGEX (http(s)://)
-    des blocs dont la fonte est au moins de la taille de la fonte par défaut
-    sont testées. En cas de succès, le bloc est marqué BL_PARAGRAPH.  
-    Ceci est dû au fait que les liens internet sont souvent en gras, ce qui
-    les classe en titres. Or on peut justement supposer qu'un lien html n'est
-    jamais dans un titre.
-
-Une première série de blocs est classés à l'aide de ces règles.
-
-La hiérarchie des titres est estimée par une série d'opérations décrites
-dans la section suivante.
-
-##### 2.3.6.1.1  Reconnaissance des titres
-
-Les règles qui précèdent s'appliquent, à quelques exceptions près, à des blocs
-dont la taille de fonte de caractères est au plus celle de la fonte par défaut.
-La plupart des blocs ayant une grande fonte ne sont pas marqués à ce stade.
-
-La reconnaissance des titres décrite dans cette section fait partie du processus
-de détermination des classes de blocs par des règles.
-Elle consiste à effectuer séquentiellement
-les opérations suivantes :
-
-- On parcourt les blocs non marqués ayant un nombre de caractères inférieur
-  ou égal à un seuil, TITLE_SIZE_MAX (actuellement 60), ne se terminant pas
-  par un caractère de ponctuation (point, point-virgule, virgule ou
-  point d'exclamation) et ayant :
-  - soit une fonte plus grande que celle par défaut,
-  - soit une fonte de la même taille que celle par défaut mais ayant
-    au moins un attribut de style (gras, italique, ...).
-
-  Lorsqu'un tel bloc est rencontré, on relève sa fonte dans une liste F
-  contenant la succession des fontes de titres.
-- On considère ensuite les premiers éléments de la liste F :
-  dans le cas où la fonte utilisée pour le(s) premier(s) bloc(s) de titre
-  rencontré(s) n'est pas utilisée ailleurs, on considère que ces blocs
-  représentent le titre du document. Il sont alors marqués BL_DOCUMENT_TITLE
-  et cette fonte est retirée de F.
-- À chaque fonte de la liste F, on attribue une ***taille relative*** de fonte,
-  égale à
-      S + 0,5.A - min(0,45 ; 0,1*B/3)
-  où
-  - S est la taille de fonte en pixels,
-  - A est égale à 1 si la fonte a un attribut de style (gras, italique, ...), 0 sinon.
-  - B le nombre de blocs ayant cette fonte.
-
-  La formule privilégie l'hypothèse de la taille de la fonte sur les autres
-  hypothèses. Pour une même taille de fonte, elle privilégie les fontes
-  ayant un attribut (gras, italique, ...) sur celles qui n'en ont pas.
-  Enfin, les fontes ayant le plus grand nombre d'occurences sont classées
-  après celles, de même taille et même considération de style, qui en ont
-  moins.   
-  Ces hypothèses sont discutables ; elles donnent toutefois des résultats
-  plutôt acceptables sur les BSV.
-
-- Les fontes de titre sont ensuite triées par ordre décroissant de leur taille
-  relative. On note C ce classement.
-- Un traitement destiné à augmenter le niveau des titres est ensuite effectué
-  sur C. Il consiste à mettre au même rang deux fontes successives de C
-  pour lesquelles il n'y a aucune succession dans F, et a augmenter
-  en conséquence le classement des fontes de C qui les suivent.
-
-  Par exemple; si l'on a la structure suivante (où le numéro de fonte
-  correspond à son ordre dans C) :
-    - Titre de fonte 1
-      - Titre de fonte 2
-      - Titre de fonte 2
-    - Titre de fonte 1
-      - Titre de fonte 3
-      - Titre de fonte 3
-
-  alors on considère que les titres de fonte 3 sont aux même niveau que
-  les titres de fonte 2. Il sont donc mis au même rang (2) dans le classement C.
-- Les blocs de titre (ceux dont la fonte a été relevée pour constituer
-  le tableau F) sont marqués de BL_TITLE_1 (h1) à BL_TITLE_5 (h5)
-  en fonction du rang de leur fonte après le traitement précédent.
-  Si ce rang est supérieur à 5, il a été choisi d'affecter la valeur
-  BL_TITLE_5.  
-
-  Le marquage des blocs de titre donne lieu à la génération de prototypes
-  pour les cinq classes de titre.
-
-
-##### 2.3.6.3  Classification des blocs restant
-
-Lors de la première phase (détermination par des règles), des blocs prototypes
-ont été identifiés pour certaines classes. De nouvelles données sont calculées
-pour chaque classe à partir de ces blocs prototype :
-
-- *nb_lines_per_bloc* : le nombre de lignes pour un bloc. Cet attribut est un
-tableau où chaque cellule correspond au nombre de lignes d'un bloc prototype
-donné. La valeur de la cellule du tableau prend l'une des valeurs suivantes :
-  - courts (2 lignes ou moins)
-  - moyens (entre 3 et 5 lignes)
-  - longs (au moins 6 lignes)
-- *block_size* : nombre moyen de caractères pour tous les blocs prototypes
-  de la classe,
-- *font_size* : la taille moyenne de la fonte pour tous les blocs prototypes
-  de la classe,
-- *alignment* : le nombre d'occurences des caractéristiques d'alignement
-  (ALIGN_RIGHT, ...) des blocs prototypes de cette classe,
-- *font* : la liste des fontes utilisées par tous les blocs prototypes
-  de la classe considérée,
-- *max_line_size* : la moyenne des longueurs maximales des lignes
-  par bloc pour tous les blocs prototypes de la classe considérée,
-- *not_numbers* : la proportion moyenne de caractère non-numériques
-  pour tous les blocs prototypes de la classe,
-- *nb_ponctuations* : le nombre de blocs de cette classe se terminant par
-  un caractère de ponctuation.
-
-Dans cette seconde phase, pour chaque bloc B qui n'a pas été marqué, on calcule
-une valeur de similitude avec chacune des classes Ci :
-
-    Sim(B, Ci) = (NLi + BSi + 6.0*FTi + 4.0*FSi + ALi + MLi + POi) / 15.0)
-
-où :
-- NLi (nombre de lignes par bloc) est le nombre de  blocs prototypes
-  de la classe ayant la même longueur que le bloc B (court, moyen, long). Cette
-  valeur est normalisée par rapport au nombre total de blocs prototype
-  pour la classe considérée.
-- BSi (block size) = 1 / max(1 ; | B['block_size'] - Ci['block_size'] |)
-- FTi = 1 si la fonte de B est dans Ci['font'], 0 sinon,
-- FSi (font_size) = 1 / max(1 ; 2.|B['font']['size'] - Pi['font_size']|)
-- ALi (alignment) est le nombre de  blocs prototypes ayant le même alignement
-  que le bloc B (justifié, centré, ...). Cette valeur est normalisée par le
-  nombre total de blocs prototype appartenant à la classe Ci.
-- MLi (max_line_size) = 1 / max(1 ; | B['max_line_size'] - Ci['max_line_size'] |)
-- POi est la proportion de blocs prototypes se terminant (resp. ne se terminant
-  pas) par un caractère de ponctuation si B se termine (resp. ne se termine pas)
-  par un caractère de ponctuation.
-
-Le bloc B se voit affecter la classe Ci pour laquelle la valeur de Sim(B,Ci)
-est la plus élevée. Cette valeur est nommée "score" du bloc.
-
-Une exception a lieu cependant pour les blocs dont les scores les plus élevés
-correspondent à une classe de titre et dont la longueur est supérieure à
-TITLE_SIZE_LIMIT (actuellement 140 caractères).
-Ceux-ci sont marqués BL_PARAGRAPH. Il s'agit en général d'éléments
-(de conclusion par exemple) mis en gras, ou de paragraphes d'une section
-particulière écrits avec une police un peu plus grande (sections "À retenir"
-par exemple).  
-Étant donné que certains blocs de ces sections peuvent toutefois faire moins
-de TITLE_SIZE_LIMIT caractères, une première passe de calcul de score a lieu
-sur l'ensemble des blocs non marqués. Seuls les blocs dont le score les assimile
-à un titre et comprenant plus de TITLE_SIZE_LIMIT caractères sont marqués
-BL_PARAGRAPH et donnent lieu à l'ajustement du prototype BL_PARAGRAPH.
-Une seconde passe de calcul de scores a ensuite lieu pour déterminer le type
-des blocs restants.
-
-
-### Écriture des résultats
-
-La sortie des résultats s'effectue dans un fichier html.
-
-Un paramètre, DEBUG_PRINT, ajoute en commentaire la liste des fontes et
-un attribut permettant d'identifier la fonte de chaque bloc. Un attribut
-score est aussi ajouté aux blocs marqués lors de la deuxième phase.
-
-Un paramètre PRINT_CSS ajoute dans l'entête un lien vers un fichier css
-permettant de distinguer les différentes classes des blocs, et ajoute
-aux blocs un attribut class.
-
-Le tableau qui suit montre la manière dont sont écrites les différentes
-classes de blocs :
-
-|   Classe   |  Balise   |
-| ---------- | --------- |
-| BL_DOCUMENT_TITLE | Balise \<title\> de la partie \<head\> du document html,<br>puis balise \<h1\> dans le \<body\>. |
-| BL_TITLE_1 | \<h1\>      |
-| BL_TITLE_2 | \<h2\>      |
-| BL_TITLE_3 | \<h3\>      |
-| BL_TITLE_4 | \<h4\>      |
-| BL_TITLE_5 | \<h5\>      |
-| BL_TABLE   | \<table\>. Les blocs BL_TABLE successifs constituent chacun une ligne,<br>et chaque ligne de ces blocs constitue une cellule. |
-| BL_CAPTION | \<figure\>\<figcaption\> |
-| BL_BOTTOM_PAGE | \<footer\> |
-| BL_TOP_PAGE    | \<header\> |
-| BL_PARAGRAPH   | \<p\>  |
-| BL_MISC        | \<p\>\<small\>  |
-
-Dans l'écriture du texte en lui-même, un certain nombre de traitements sont
-effectués :
-
-- suppression des césures, en se basant sur le dictionnaire (on teste si un mot
-  est présent dans le dictionnaire avant de supprimer une césure - les césures
-  entre blocs subsistent cependant),
-- remplacement des apostrophes obliques (*quotes* fermantes) par des apostrophes
-  droites ('),
-- remplacement des *doubles-quotes* et des guillemets à la française par des
-  doubles apostrophes droites ("), ce traitement ainsi que le précédent
-  perturbant les outils d'analyse lexicale,
-- suppression des caractères "bizarres" (utilisés notamment pour les puces),
-- suppression des espaces multiples.
-
-##### Remarques
-
-- BL_PARAGRAPH et BL_MISC sont tous deux écrits sous la balise \<p\>.  
-- La reconstitution des tableaux pourrait être enrichie et améliorée.
-  Ceci n'a pas été fait mais peut être envisagé, les algorithmes
-  d'identification des lignes et colonnes étant relativement similaires
-  à des traitements mis en oeuvre dans le reste du document.
-- Pour les en-têtes et les pieds de page de plusieurs lignes, les retour
-  à la ligne sont préservés par le biais de balises \<br\>.
-- Les puces ne sont pas restituées au sein de balises \<ul\> et \<li\>.
-  S'il est aisé d'identifier une ligne qui ne commence pas par un caractère
-  alphanumérique et d'en déduire que ce caractère est une puce, il y a une
-  ambiguïté sur la fin de la puce. Il est difficile de distinguer le texte qui
-  est dans la puce du texte qui est après la puce.  
-  D'autre part le *html* n'accepte pas les balises \<ul\> à l'intérieur des
-  balises \<p\>. Le découpage puces/paragraphe doit donc être très précis.  
-  Parce que ce niveau de précision n'était pas envisageable, les puces sont
-  signalées par un retour à la ligne et un tiret.  
-  Les descriptions (lignes contenant deux points ':') sont aussi précédées
-  d'un retour à la ligne.
+*TODO*
diff --git a/src/py/pdf2blocks.py b/src/py/pdf2blocks.py
index ff23ea6..bf3a2fd 100644
--- a/src/py/pdf2blocks.py
+++ b/src/py/pdf2blocks.py
@@ -1,15 +1,1215 @@
+import xml.etree.ElementTree as ET
+import enchant
+import os
 import sys
+import re
+
+# https://unix.stackexchange.com/questions/238180/execute-shell-commands-in-python
+import subprocess
+
+from io import StringIO
+from p2b_config import *
+
+# For debugging
+import cairo
+
+# Exemple d'utilisation :
+# ls tmp/*.pdf | python -u pdf2test.py
+
+# ================================================================
+# Some new config stuff, to be moved to p2b_config after some
+# cleaning is done.
+# ================================================================
+
+# Note : THR stands for "Threshold".
+
+# --- Used in detect_inner():
+AREA_THR = 0.4  # If a word has more than AREA_THR*<its area> recovering
+                # another word, then its removing is considered.
+
+
+# --- Used in word_rightnbot() :
+RIGHT_W_THR = 0.01 # A word can be considered 'at right of' another one if
+                   # its xmin >= xmax-RIGHT_W_THR of it.
+BOTTOM_W_THR = 0.01 # Same as RIGHT, but bottom
+
+SUB_EXP_MAX_PROP = 0.8 # If a non-properly-aligned right word's size
+                        # is < SUB_EXP_MAX_PROP, then the right link
+                        # is not deleted, considering it's a subscript
+                        # or exponent.
+
+VERTICAL_ALIGN_THR = 0.4 # Threshold for left and right alignments
+
+
+
+# --- Used in glue_upper_artwork() :
+## If an uppercase letter is followed by other characters and has
+## a distance lower than NOT_SPACE_IF_LESS_THAN × its height,
+## then they're concatenated.
+## This has been seriously tested on the test corpuses before
+## its value has been choosen.
+NOT_SPACE_IF_LESS_THAN = 0.175
+
+## The maximum proportion between the 1st letter and the others (letters) :
+## If h1 is the height of a single letter and h2 the height of following letters
+## then they might be concatenated only if (h1/h2) < ARTWORK_CAPS_PROPORTION
+ARTWORK_CAPS_PROPORTION = 0.975
+
+
+# --- Used in get_lines_words() :
+# The precision used to compute space length in a line.
+# WARNING : **CANNOT BE ZERO**
+SPC_PRECISION = 0.4
+
+# --- Used in clean_rightnbot() :
+## Don't keep vertical links longer than MAX_VERTICAL_LINK_PROP * word_height
+MAX_VERTICAL_LINK_PROP = 0.45
+## Remove an unaligned right link, except if distance < HORIZONTAL_SMALL_DIST_IS
+HORIZONTAL_SMALL_DIST_IS = 3.0
+## Equality is not so easy when working on floats.
+## We consider 2 values are equal if difference is less than EQUALITY_THR.
+EQUALITY_THR = 0.01
+## When two words have a distance > HORIZONTAL_TOO_FAR, we're shure they're
+## not in the same block.
+HORIZONTAL_TOO_FAR = 20.0
+
+
+
+# +--------------------------------------------------------------+
+# |                       get_pdftotext                          |
+# +--------------------------------------------------------------+
+# Returns a list of dictionaries :
+# {'number': page number,
+#  'width' : page width,
+#  'height': page height,
+#  'words': a list of dictionaries :
+#      {'text': the text recognized as a word by pdftotext
+#        'xmin', 'xmax', 'ymin', 'ymax': coordinates of a rectangle containing
+#                                        the word, in the page.
+#      }
+# }
+#
+# RK : (0, 0), for xmin, ymax, … is on TOP-LEFT. That means that ymin is the
+# position of the top of text and ymax the position of the bottom.
+def get_pdftotext(filename):
+  # Calls pdftotext and retreive standard output in a string (o)
+  basename = os.path.splitext(filename)[0]
+  cmd = [CMD_PDFTOTEXT, '-bbox', '-eol', 'unix', '%s.pdf' % basename, '-']
+  proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+  o, e = proc.communicate()
+  if (proc.returncode != 0):
+    #print('-S-> Command pdftotext returned an error :')
+    #print('     '  + e.decode('utf8'))
+    return []
+
+  # Parse xml code and create block table.
+  xml = o.decode('utf8')
+  ## Quelques cas particuliers déjà rencontrés :-(
+  xml = re.sub(r">[]<",'>*<', xml)
+  root = None
+  try:
+      root = ET.fromstring(xml)
+  except Exception as e:
+      return []
+  #root = ET.fromstring(xml)
+
+  page_num = 0
+  flow_num = 0
+  pages = []
+  for body in root:
+    if (body.tag.endswith('body')):
+      for doc in body:
+        if (doc.tag.endswith('doc')):
+          for page in doc:
+            if (page.tag.endswith('page')):
+              page_num += 1
+              pages.append({ 'number': page_num,
+                'width' : float(page.get('width' )),
+                'height': float(page.get('height')),
+                'words':[]
+              })
+              for wd in page:
+                if (wd.tag.endswith('word')):
+                    pages[-1]['words'].append({
+                        'text': wd.text.strip(),
+                        'xmin': float(wd.get('xMin')),
+                        'xmax': float(wd.get('xMax')),
+                        'ymin': float(wd.get('yMin')),
+                        'ymax': float(wd.get('yMax')),
+                    })
+  return pages
+
+
+# +--------------------------------------------------------------+
+# |                       get_pdftohtml                          |
+# +--------------------------------------------------------------+
+# Returns a dictionary : {
+#    'pages' : [ {
+#        'number': page number,
+#        'width' :
+#        'height':
+#        'lines' : [ {
+#            'text',
+#            'font': an integer identifiying a font (font_id),
+#            'tags' : { '<tag>' : <number of characters in with this tag>
+#                       The tags are from html : <b>..</b> gives 'b' for '<tag>'
+#                     }
+#            'xmin', 'xmax', 'ymin', 'ymax'
+#            }, ...]
+#    }, ...],
+#    'fonts' : { font_id : { 'size' : (int),
+#                            'family',
+#                            'color',
+#                            'nb_cars' <- 0: I forgot to count it :-D !
+#        }, ...},
+# }
+
+def get_pdftohtml(filename):
+    basename = os.path.splitext(filename)[0]
+    cmd = [CMD_PDFTOHTML, '-xml', '-noroundcoord', '-nodrm', '-i', '-stdout',
+        '%s.pdf' % basename]
+    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    o, e = proc.communicate()
+    if (proc.returncode != 0):
+        #print('-S-> Command pdftohtml returned an error :')
+        #print('     '  + e.decode('utf8'))
+        return None
+
+    # Parse xml code and create block table.
+    xml = o.decode('utf8')
+    root = None
+    try:
+        root = ET.fromstring(xml)
+    except Exception as e:
+        return { 'fonts': [], 'pages': [] }
+
+    fonts = {}
+    pages = []
+    for page in root:
+        if (page.tag.endswith('page')):
+            pages.append({
+              'number': int(page.get('number')),
+              'width' : float(page.get('width'))  - float(page.get('left')),
+              'height': float(page.get('height')) - float(page.get('top' )),
+              'lines' : []
+            })
+            pg = int(page.get('number'))
+            for tg in page:
+                if (tg.tag.endswith('fontspec')):
+                    fonts[tg.get('id')] = {
+                        'size': int(tg.get('size')),
+                        'family': tg.get('family'),
+                        'color': tg.get('color'),
+                        'nb_cars': 0 }
+                elif (tg.tag.endswith('text')):
+                    def recurse_text_tags(tgg, tags_list = {}):
+                        txt = ''
+                        if tgg.text is not None:
+                            txt = tgg.text
+                        if len(tgg) > 0:
+                            for ttt in tgg:
+                                rec = recurse_text_tags(ttt, tags_list)
+                                txt += rec['txt']
+                                tags_list = rec['tags']
+                        if tgg.tag:
+                            if tags_list.get(tgg.tag) is not None:
+                                tags_list[tgg.tag] += len(txt)
+                            else:
+                                tags_list[tgg.tag] = len(txt)
+                        return {'txt':txt, 'tags':tags_list}
+
+                    rec = recurse_text_tags(tg) # tg.text
+                    txt = rec['txt'].strip()
+                    tags = rec['tags']
+                    #print("===> %s %s" % (tags, txt))
+                        # &&&&&&&&&&&&&&&& BUG : Sur 05bsv_jevi
+                        # <text top="898.825020" left="311.475000" width="119.540340" height="16.538580" font="2">
+                        #  <a href="http://polleniz.fr/wp-content/uploads/2018/05/07bsv_jevi_20180517-1.pdf">  </a>N’hésitez  pas  à </text>
+                        # -> ne renvoie pas le "N'hésitez pas".
+                    if len(txt) > 0:
+                        pages[-1]['lines'].append({
+                            'text': txt,
+                            'font': tg.get('font'),
+                            'tags' : tags,
+                            'xmin': float(tg.get('left')),
+                            'xmax': float(tg.get('width')) + float(tg.get('left')),
+                            'ymin': float(tg.get('top')),
+                            'ymax': float(tg.get('top')) + float(tg.get('height')),
+                        })
+
+    return { 'fonts': fonts, 'pages': pages }
+
+
+# +--------------------------------------------------------------+
+# |                          getLst                              |
+# +--------------------------------------------------------------+
+# getLst : "get list (from a dictionary)"
+# Should have wrote this before ; there are so many lines of this code :
+# if dict.get('somekey') is not None:
+#     for element in dict['somekey']:
+#         blablabla
+#
+# …so it's better to make a getLst() function returning an empty list
+# if the key is not defined, so that we can write :
+# for element in getLst(dict, somekey):
+#     blablabla
+# which is shorter and avoid these dummy tabulations.
+def getLst(dict, key):
+    l = dict.get(key)
+    if l is None:
+        return []
+    return l
+
+
+# +--------------------------------------------------------------+
+# |                       append_lines                           |
+# +--------------------------------------------------------------+
+# Just adds the 'lines' list (from pdfto html) to ptxt, but it recomputes
+# all coordinates to make them match page size from pdftotext.
+def append_lines(ptxt, phtml):
+    #print("%d <=> %d" % (len(ptxt), len(phtml)))
+    if len(phtml) == 0:
+        for pt in ptxt:
+            pt['lines'] = []
+        return ptxt
+    for pt in ptxt:
+        for ph in phtml:
+            if ph['number'] == pt['number']:
+                break
+        if ph['number'] == pt['number'] and (ph['width']*ph['height'] != 0.0) :
+            wc = pt['width']  / ph['width']  # wc : width coef
+            hc = pt['height'] / ph['height']
+            pt['lines'] = []
+            for l in ph['lines']:
+                pt['lines'].append({
+                    'text': l['text'],
+                    'font': l['font'],
+                    'xmin': wc * l['xmin'],
+                    'xmax': wc * l['xmax'],
+                    'ymin': hc * l['ymin'],
+                    'ymax': hc * l['ymax'],
+                })
+    return ptxt
+
+
+
+# +--------------------------------------------------------------+
+# |                       detect_inner                           |
+# +--------------------------------------------------------------+
+# For debugging : at one moment during the developpement, we've found that
+# words followed by ponctuation (comma or dot) appear twice, with and without
+# the ponctuation. This have been observed before any particular treatment on
+# ponctuation. So we think pdftotext does that. So this functions have been
+# written to check this.
+# ...and the answer is : YES, IT DOES.
+# So, we have to do a particular treatment to those words.
+def detect_inner(ptxt):
+    AREA_THR = 0.5  # If a word has more than AREA_THR*<his area> recovering
+                    # another word, then its removing is considered.
+    for page in ptxt:
+        to_remove = []
+        for w in getLst(page, 'words'):
+            if w not in to_remove:
+                warea = (w['xmax']-w['xmin'])*(w['ymax']-w['ymin'])
+                for ww in getLst(page, 'words'):
+                    if ww is not w and ww not in to_remove:
+                        # r for "recovering"
+                        rxmin = max(w['xmin'], ww['xmin'])
+                        rxmax = min(w['xmax'], ww['xmax'])
+                        if rxmin < rxmax:
+                            rymin = max(w['ymin'], ww['ymin'])
+                            rymax = min(w['ymax'], ww['ymax'])
+                            if rymin < rymax:
+                                rarea = (rxmax-rxmin)*(rymax-rymin)
+                                wwarea  = (ww['xmax']-ww['xmin'])
+                                wwarea *= (ww['ymax']-ww['ymin'])
+                                if wwarea <= 0.0:
+                                    print("===> Ooops. Area <=0 for word [%s]" % ww['text'])
+                                if (rarea/wwarea) > AREA_THR and wwarea <= warea:
+                                    #print("===> [%s] is to be removed : it recovers [%s]" % (ww['text'], w['text']))
+                                    to_remove.append(ww)
+        for w in to_remove:
+            page['words'].remove(w)
+        page['removed'] = to_remove
+
+
+# +--------------------------------------------------------------+
+# |                      word_rightnbot                          |
+# +--------------------------------------------------------------+
+# Adds a 'right' list for each word, containing dictionaries {'word', 'dist'}.
+# Contains only next word(s) on the right of one word, not all of them.
+# Does also a 'bottom' list, and computes vertical alignments.
+# So, returns, for each element of the 'words' list from pdftotext, the keys :
+# 'right' : [{ 'word' : <another words element>,
+#              'dist' : <the horizontal distance to current word>}, ...]
+# 'bottom' : [{ 'word' : <another words element>,
+#              'dist' : <the vertical distance to current word>}, ...]
+# 'alignment_left' :  <a word below left  aligned to this one, OR None>
+# 'alignment_right' : <a word below right aligned to this one, OR None>
+def word_rightnbot(ptxt):
+    for page in ptxt:
+        for w in getLst(page, 'words'):
+            w['r'] = []
+            w['b'] = []
+            X = w['xmax'] - RIGHT_W_THR
+            Y = w['ymax'] - BOTTOM_W_THR
+            for ww in page['words']:
+                # RQ: Algo en o(n^2) où n est le nombre de mots par page.
+                # Un peu d'optimisation en testant d'abord les Y,
+                # plus discriminants, et en utilisant un elif car un rectangle
+                # ne peut être à la fois à droite et en dessous d'un autre.
+                # En réalité si, si les coins se touchent, mais dans ce cas
+                # l'alignement vertical ne nous intéresse pas.
+
+                # Is ww right of w ?
+                if ww['ymin'] <= w['ymax'] and ww['ymax'] >= w['ymin'] \
+                    and ww['xmin'] >= X :
+                    w['r'].append(ww)
+                # Is it under w ?
+                elif ww['ymin'] >= Y and \
+                    ww['xmin'] <= w['xmax'] and ww['xmax'] >= w['xmin'] :
+                    w['b'].append(ww)
+
+        # w['r'] & w['b'] filled.
+        # Now they're to be converted into w['rigth'] and w['bottom'].
+
+        # 1st, we need to define a recursive function.
+        # Note: the function is the same for right and for bottom. Only the keys
+        # differ, so we need to know the apropriate ones, which can be:
+        # {sk:'r', lk:'right'} or {sk'b', lk:'bottom'}
+        def get_after(w, sk, lk, nb=0):
+            if w.get(sk+lk) is not None:
+                return w[sk+lk]
+            to_visit = [www for www in w[sk]]
+            after_nxt = [www for www in w[sk]]
+            w[sk+lk] = []
+            while len(to_visit) > 0:
+                ww = to_visit.pop(0)
+                if ww not in w[sk+lk]:
+                    w[sk+lk].append(ww)
+                    if ww.get(sk+lk) is not None:
+                        nxt = ww[sk+lk]
+                    else:
+                        nxt = get_after(ww, sk, lk, nb+1)
+                    for wn in nxt:
+                        try: to_visit.remove(wn)
+                        except ValueError as e: pass
+                        try: after_nxt.remove(wn)
+                        except ValueError as e: pass
+                        if wn not in w[sk+lk]:
+                            w[sk+lk].append(wn)
+            del to_visit
+            w[lk] = []
+            for www in after_nxt:
+                w[lk].append({'word': www})
+            del w[sk]
+            del after_nxt
+            return w[sk+lk]
+        # End of recursive function
+
+        for w in page['words']:
+            get_after(w, 'r', 'right')
+            get_after(w, 'b', 'bottom')
+
+        # dist and alignment calculations
+        for w in page['words']:
+            del w['bbottom']
+            del w['rright']
+            #if w.get('r'):
+            #    print("===") # Just to see if that happends… but it shouldn't
+            #    del w['r']   # Well, it seems it doesn't.
+            #if w.get('b'):
+            #    print("===")
+            #    del w['b']
+            for ww in w['right']:
+                ww['dist'] = ww['word']['xmin'] - w['xmax']
+            for ww in w['bottom']:
+                wd = ww['word']
+                ww['dist'] = wd['ymin'] - w['ymax']
+                # Utiliser VERTICAL_ALIGN_THR
+                if abs(w['xmin'] - wd['xmin']) < VERTICAL_ALIGN_THR:
+                    w['alignment_left'] = wd
+                if abs(w['xmax'] - wd['xmax']) < VERTICAL_ALIGN_THR:
+                    w['alignment_right'] = wd
+                #if abs((w['xmax']+w['xmin']-wd['xmax']-wd['xmin'])/2.0) < VERTICAL_ALIGN_THR:
+                #    w['alignment_center'] = wd
+            #for k in ['alignment_left', 'alignment_right', 'alignment_center']:
+            for k in ['alignment_left', 'alignment_right']:
+                if w.get(k) is None:
+                    w[k] = None
+
+
+
+# +--------------------------------------------------------------+
+# |                    glue_upper_artwork                        |
+# +--------------------------------------------------------------+
+# Sometimes titles use a Huge Uppercase followed by the rest of the word
+# in a smaller font. The space between this 1st letter and the rest of word
+# make this recognized by pdftotext as 2 words.
+# This functions tries to detect such artwork, then does a concatenation
+# of them. The height of the result is the 2nd part's height.
+#
+# Precond : needs to be ran after word_rightnbot(ptxt)
+#
+# As a result, it replaces two elements of 'words' list of a page from pdftotex
+# with a newly created one having the structure words have after a call
+# to word_rightnbot().
+# This new word has also a 'glued' key containing the list
+# of the two elements it replaces.
+def glue_upper_artwork(ptxt):
+    found = False
+    for p in ptxt:
+        if p.get('words') is not None:
+            glued_artwork = []
+            new_words = []
+            for wd in p['words']:
+                if len(wd['text']) == 1 and wd['text'].isupper() \
+                  and (wd['ymax'] - wd['ymin']) > 0.0:
+                    h = wd['ymax'] - wd['ymin']
+                    for rr in wd['right']:
+                        if rr['dist'] < NOT_SPACE_IF_LESS_THAN * h:
+                            ww = rr['word']
+                            hh = ww['ymax'] - ww['ymin']
+                            prophhh = float(hh) / float(h)
+                            if ww['text'].isalpha() and \
+                              prophhh < ARTWORK_CAPS_PROPORTION: # OK, we've found one.
+                                ### The output of Plan d'expérience 30/08/2021 :
+                                #print("%s ===> %s - %s → %s" % (os.path.splitext(line)[0],
+                                #                        wd['text'], ww['text'], wd['text']+ww['text']))
+                                found = True
+                                glued_artwork.append({'letter' : wd,
+                                                        'word': ww})
+                                nw = {
+                                    'text' : "%s%s" % (wd['text'], ww['text']),
+                                    'ymin' : ww['ymin'], 'ymax' : ww['ymax'],
+                                    'xmin' : wd['xmin'], 'xmax' : ww['xmax'],
+                                    #'right' : [], 'bottom' : wd['bottom'],
+                                    #'alignment_left': wd['alignment_left'],
+                                    #'alignment_right': ww['alignment_right'],
+                                    'glued' : [wd, ww]
+                                }
+                                #for r in wd['right']:
+                                #    if r is not rr:
+                                #        nw['right'].append(r)
+                                #for r in ww['right']:
+                                #    if r not in nw['right']:
+                                #        nw['right'].append(r)
+                                #for b in ww['bottom']:
+                                #    if r not in nw['bottom']:
+                                #        nw['bottom'].append(r)
+                                new_words.append(nw)
+            for ww in glued_artwork:
+                if ww['letter'] in p['words']:
+                    p['words'].remove(ww['letter'])
+                if ww['word'] in p['words']:
+                    p['words'].remove(ww['word'])
+            for ww in new_words:
+                p['words'].append(ww)
+    if found:
+        for w in p['words']:
+            del(w['right'])
+            del(w['bottom'])
+            del(w['alignment_left'])
+            del(w['alignment_right'])
+        word_rightnbot(ptxt) # OK, that's lazy…
+
+
+# +--------------------------------------------------------------+
+# |                      page_alignments                         |
+# +--------------------------------------------------------------+
+# Adds 'alignment_left' and 'alignment_right' to pages, containing
+# lists of words aligned on left (aligned on right).
+# Doesn't keep when only 2 words aligned.
+# It also adds a 'leftalign' key with a True value to words in a left alignment,
+# and does the same with right alignments.
+#
+# Returns nothing but adds to each element of the list
+# returned by get_pdftotext() the keys :
+# 'alignment_left'  : [[word1, word2, ...], ...].
+#                    Each element of this list is a list a words left-aligned
+# 'alignment_right' : [[word1, word2, ...], ...].
+#                    Each element of this list is a list a words right-aligned
+# Also adds to each word contained in alignment_left a key 'leftalign'
+# which is set to true.
+# The reason is that testing the 'alignment_left' value of a word doesn't work
+# for the last word of an alignment.
+# Does the same with a 'rightalign' key.
+def page_alignments(ptxt):
+    for page in ptxt:
+        one_page_alignment(page)
+
+def one_page_alignment(page):
+      if page.get('words') is not None:
+        # Page alignments
+        ## RQ: alignments_center doesn't make sense for words. Does it ?
+        #for k in ['alignment_left', 'alignment_right', 'alignment_center']:
+        for k in ['alignment_left', 'alignment_right']:
+            page[k] = []
+            for w in page['words']:
+                if w[k] is not None:
+                    al = [w]
+                    ww = w
+                    cont = True
+                    while cont:
+                        ww = ww[k]
+                        if ww[k] is None:
+                            al.append(ww)
+                            page[k].append(al)
+                            cont = False
+                        else:
+                            for aa in page[k]:
+                                if ww == aa[0]:
+                                    al.extend(aa)
+                                    page[k].remove(aa)
+                                    page[k].append(al)
+                                    cont = False
+                                    break
+                            if cont:
+                                al.append(ww)
+            # Remove 2-elements alignments.
+            al = []
+            for aa in page[k]:
+                if len(aa) < 3:
+                    al.append(aa)
+                else:
+                    for w in aa:
+                        w[k[10:]+'align'] = True
+            for aa in al:
+                page[k].remove(aa)
+
+
+##### NOT USED : Has bad results considering only alignments.
+##### May be reused to classify a small column :
+##### is it a bullet or numbering column ? So we keep it.
+## +--------------------------------------------------------------+
+## |                       can_be_bullet                          |
+## +--------------------------------------------------------------+
+## Return true if the string is composed of a single character
+## which is not a nmber or a character, AND it's aligned (left of right)
+#def can_be_bullet(w):
+#    if w.get('alignment_left') is None and w.get('alignment_right') is None:
+#        return False
+#    ch = w['text']
+#    if len(ch) != 1: return False
+#    return not ch.isalnum()
+#
+## +--------------------------------------------------------------+
+## |                      can_be_numbering                        |
+## +--------------------------------------------------------------+
+## Return true if the word can be considered a number of a
+## numbering list, eventually followed by a non-alphanumerical character
+## and maybe a space. It must be aligned (left or right).
+## Those numberings are allowed :
+## 1, 2, 3, ...
+## a, b, c, ... but with a unique character
+## A, B, C, ... (also unique)
+## i, ii, iii, ... but only with i,v and x (including l or c seems long list)
+## I, II, III, ... with the same restriction.
+#ONLYCAP = re.compile('^[A-Z]$')
+#ROMAN_NUMBERS = re.compile('^[IVX]+$')
+#def can_be_numbering(w):
+#    if w.get('alignment_left') is None and w.get('alignment_right') is None:
+#        return False
+#    ch = w['text']
+#    if len(ch) == 0: return False
+#    if not ch[-1].isalnum():
+#        num = ch[:-1].strip()
+#    else:
+#        num = ch.strip().upper()
+#    if num.isdecimal(): return True
+#    if ONLYCAP.match(num): return True
+#    if ROMAN_NUMBERS.match(num): return True
+#    return False
+
+# +--------------------------------------------------------------+
+# |                     get_lines_words                          |
+# +--------------------------------------------------------------+
+# Return a list of dictionaries :
+# - words : the words list, ordered
+# - dists : list of { <distance> : # }, (distances between words, and number)
+# - space : one of the distances, choosen as the "normal" space size.
+#           Can be None if none is chosen (1 word or only different distances)
+def get_lines_words(page):
+    lines = []
+
+    def rec_line(word, lines_list):
+        if word.get('seen') is not None:
+            li = []
+            for aa in lines_list:
+                if word in aa:
+                    ret = aa # [word]
+                    #ret.extend(aa)
+                    lines_list.remove(aa)
+                    #word['seen'] = True
+                    return ret
+        word['seen'] = True
+        ret = [word]
+        right = getLst(word, 'right')
+        if len(right) > 0:
+            for r in right:
+                ttt = rec_line(r['word'], lines_list)
+                #print("*-*-> %s : %s" % (r['word']['text'], len(ttt)))
+                ret.extend(ttt)
+        return ret
+
+    for w in getLst(page, 'words'):
+        if w.get('seen') is None:
+            lines.append(rec_line(w, lines))
+    for w in getLst(page, 'words'):
+        del w['seen']
+
+    def get_horizontal_distances(a_line):
+        SPC_COEF = 1.0 / SPC_PRECISION
+        if len(a_line) < 2:
+            return []
+        dists = {}
+        for i in range(1, len(a_line)):
+            d = round((a_line[i]['xmin'] - a_line[i-1]['xmax']) / SPC_PRECISION)
+            if dists.get(d) is None:
+                dists[d] = 1
+            else:
+                dists[d] += 1
+        ret_dists = {}
+        for d in dists:
+            ret_dists[SPC_PRECISION * float(d)] = dists[d]
+        return ret_dists
+
+    def choose_space_size(distances):
+        if len(distances) == 0:
+            return -1
+        m = max([v for v in distances.values()])
+        if m == 1:
+            return -1.0
+        for d in distances:
+            if distances[d] == m:
+                return d
+        return -2.0 # Should never been reached
+
+    ret = []
+    for l in lines:
+        d = get_horizontal_distances(l)
+        sp = choose_space_size(d)
+        if sp <= 0.0:
+            sp = None
+        ret.append({'words' : l, 'dists' : d, 'space' : sp})
+    return ret
+
+
+
+
+
+# +--------------------------------------------------------------+
+# |                        remove_word                           |
+# +--------------------------------------------------------------+
+# The left, top, … lists'es elements are dictionaries {'word', 'dist'}
+# This function removes an element from such a list having only
+# the word (and not the dist).
+# RQ : We remove all matching occurences if found. Sometimes, detecting
+#      more than one result or no result could be usefull.
+def remove_word(list, word, alert_none = True, alert_multiple = True):
+    l = [x for x in list if x['word'] is word]
+    if len(l) > 1 and alert_multiple :
+        sys.stderr.write("WARNING: Found multiple elements in remove_word()")
+    if len(l) == 0 and alert_none:
+        sys.stderr.write("WARNING: Found no element in remove_word()")
+    for e in l:
+        list.remove(e)
+
+
+# +--------------------------------------------------------------+
+# |                      clean_rightnbot                         |
+# +--------------------------------------------------------------+
+# Cleans right and bottom links, and adds 'left' and 'top' lists.
+# Rules :
+# * For right links :
+#   - Keeps only horizontally aligned (center or bottom),
+#   - except if one distance is quite small (for exponent & indices),
+#   - If a word has two left links, then it'll break all right <-> left
+#     links except the "most aligned" one (the biggest vertical recovery)
+#   - Cuts any right line cutting a left alignment,
+#   - NOT DONE: except if from a unique, left-aligned only-character or number
+# * For bottom links :
+#   - cuts every link with a vertical distance longer than C * h,
+#     where C is a value to be chosen and h the word's height.
+# Does not change the structure
+def clean_rightnbot(ptxt):
+    for page in ptxt:
+
+        ## 1st, we create the 'left' and 'top' lists
+        for w in getLst(page, 'words'):
+            w['top'] = []
+            w['left'] = []
+        for w in getLst(page, 'words'):
+            for dir, opp in zip(['right', 'bottom'], ['left', 'top']):
+                for r in getLst(w, dir):
+                    lw = r['word']
+                    # if lw.get(opp) is None:
+                    #     lw[opp] = []
+                    r['word'][opp].append({'word':w, 'dist':r['dist']})
+
+        for w in getLst(page, 'words'):
+
+            # Clean 'bottom' links
+            h = w['ymax'] - w['ymin']
+            to_remove = []
+            for wb in getLst(w,'bottom'):
+                d = wb['word']['ymin'] - w['ymax']
+                if d > h * MAX_VERTICAL_LINK_PROP:
+                    to_remove.append(wb)
+            for wb in to_remove: # if no 'bottom' key, then to_remove is empty.
+                w['bottom'].remove(wb)
+                remove_word(wb['word']['top'], w)
+
+            # Clean 'right' links
+            to_remove = []
+            for wrw in getLst(w, 'right'):
+                wr = wrw['word']
+                center_diff = abs(wr['ymax']+wr['ymin']-w['ymax']-w['ymin'])/2.0
+                base_diff = abs(wr['ymax'] - w['ymax'])
+                if center_diff > EQUALITY_THR and base_diff > EQUALITY_THR:
+                    if wrw['dist'] > HORIZONTAL_SMALL_DIST_IS:
+                        to_remove.append(wrw)
+                else:
+                    if wrw['dist'] >= HORIZONTAL_TOO_FAR:
+                        to_remove.append(wrw)
+            for wr in to_remove:
+                w['right'].remove(wr)
+                remove_word(wr['word']['left'], w)
+
+        # Clean multiple left links
+        for w in getLst(page, 'words'):
+            if len(w['left']) > 1:
+                # In this case, constructing lines going right from a word
+                # will result in using this word twice in two different lines.
+                # So we'll chose to keep only the "most aligned" word,
+                # which means the biggest vertical recovery.
+                vertical_recovery = -1.0
+                best_left_word = None
+                for wl in w['left']:
+                    ymax = min(w['ymax'], wl['word']['ymax'])
+                    ymin = max(w['ymin'], wl['word']['ymin'])
+                    h = abs(ymax - ymin)
+                    if h > vertical_recovery:
+                        best_left_word = wl
+                if best_left_word is not None:
+                    for wl in w['left']:
+                        if wl is not best_left_word:
+                            remove_word(wl['word']['right'], w)
+                    w['left'] = [best_left_word]
+                else:
+                    sys.stderr.write("STRANGE !!! clean_rightnbot() :")
+                    sys.stderr.write("  Un left d'au moins 2 éléments n'a pas de best_left_word.")
+
+
+        # We call get_lines_words(), which is able to compute the length
+        # of the space character for each line. With this information we're
+        # able to remove fake alignments appearing in the middle of lines.
+        lines = get_lines_words(page)
+
+        chg_align = False
+        for w in getLst(page, 'words'):
+            l = None
+            for ll in lines:
+                if w in ll['words']:
+                    l = ll
+                    break
+            if (l is not None) and (l['space'] is not None):
+                spc = round(l['space']/SPC_PRECISION)
+                to_remove = []
+                for wrw in getLst(w, 'right'):
+                    wr = wrw['word']
+                    dst = round((wr['xmin'] - w['xmax'])/SPC_PRECISION)
+
+                    if w.get('rightalign') is not None:
+                        if dst == spc:
+                            del w['rightalign']
+                            w['alignment_right'] = None
+                            for www in getLst(page, 'words'):
+                                if www.get('alignment_right') is w:
+                                    www['alignment_right'] = None
+                            chg_align = True
+                        #else :
+                            #to_remove.append(wrw)
+                    if wr.get('leftalign') is not None:
+                        if dst == spc:
+                            del wr['leftalign']
+                            wr['alignment_left'] = None
+                            for www in getLst(page, 'words'):
+                                if www.get('alignment_left') is wr:
+                                    www['alignment_left'] = None
+                            chg_align = True
+                        else:
+                            if wrw not in to_remove:
+                                to_remove.append(wrw)
+                for wr in to_remove:
+                    w['right'].remove(wr)
+                    remove_word(wr['word']['left'], w)
+        if chg_align:
+            one_page_alignment(page)
+
+
+
+# +--------------------------------------------------------------+
+# |                         get_blocks                           |
+# +--------------------------------------------------------------+
+# Adds a 'blocks' key to each element of the list returned by get_pdftotext().
+#
+# Precond : needs to be ran after :
+#  - pages_txt = get_pdftotext(line)
+#  - word_rightnbot(pages_txt)
+#  - glue_upper_artwork(pages_txt) # Optional
+#  - page_alignments(pages_txt)
+#  - clean_rightnbot(pages_txt)
+#
+# 'blocks' : [{
+#    'xmin', 'xmax', 'ymin', 'ymax',
+#    'lines' : [{ ### <- Those returned by get_lines_words()
+#        'words' : [<word1>, <word2>, ...],
+#        #### UNDONE 'dists' : { <value>: <number> },
+#        #### UNDONE 'space' : value
+#        }, ...]
+#    }, ...]
+def get_blocks(ptxt):
+    for page in ptxt:
+
+        # From a word, goes recursively on 4 directions to mark
+        # all blocks connected, with the same block value.
+        def mark_recursively(word, value):
+            if word.get('block') is not None:
+                if word['block'] == value:
+                    return
+                sys.stderr.write("BUUUUG !!! getblocks() :\n")
+                sys.stderr.write("  On a un block marqué d'un block qui n'a pas marqué tous ses blocks.\n")
+                sys.stderr.write("[%d/%d] %s\n\n" % (word['block'], value, word['text']))
+                return
+            word['block'] = value
+            for dir in ['left', 'top', 'right', 'bottom']:
+                for w in getLst(word, dir):
+                    mark_recursively(w['word'], value)
+
+        # Create blocks list
+        block_num = 0
+        blks = {}
+        for w in getLst(page, 'words'):
+            if w.get('block') is None:
+                mark_recursively(w, block_num)
+                blks[block_num] = [w]
+                block_num += 1
+            else:
+                blks[w['block']].append(w)
+        blocks = [{} for _ in range(block_num)]
+        page['blocks'] = blocks
+
+        #print("--------------------------------------------------------")
+        #for k in blks:
+        #  #if k == 0:
+        #    for w in blks[k]:
+        #        print("[%2d] %s" % (k, w['text']))
+        #    print()
+        #print("--------------------------------------------------------")
+
+        # return the list of words on right of the one given as parameter,
+        # including it. Has to be recursive because there can be
+        # more than one right word.
+        def recurse_right(word, deep = 0):
+            ## &&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
+            #spc = "                                                                                "[:min(deep,80)]
+            #print("%s->%s" % (spc, word['text']))
+            ## &&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
+            right = getLst(word,'right')
+            ret = [word]
+            for r in right:
+                ret.extend(recurse_right(r['word'], deep+2))
+            return ret
+
+        for block_num in range(len(blocks)):
+            lines = []
+            while len(blks[block_num]) > 0:
+                # Get the upper-lefter word
+                # Algo : on prend le mot le plus haut M,
+                # on fait la liste L de tous les mots dont le haut
+                #     est au-dessus du ymin de M.
+                #     On cherche le plus à gauche (xmin) dans L, puis on ajoute
+                #     ses right dans la ligne et on les retire de L.
+                #     Et on recommence tant qu'il y a des éléments dans L.
+                # Après, on itère tant qu'il reste des mots dans le bloc,
+                # chaque itération étant une nouvelle ligne.
+                ymax = blks[block_num][0]['ymax']
+                ymin = blks[block_num][0]['ymin']
+                for w in blks[block_num][1:]:
+                    if w['ymin'] < ymin:
+                        ymax = w['ymax']
+                        ymin = w['ymin']
+                top_line = []
+                for w in blks[block_num]:
+                    if w['ymin'] < ymax:
+                        top_line.append(w)
+                li = { 'words':[] }
+                while len(top_line) > 0:
+                    wleft = top_line[0]
+                    xmin = wleft['xmin']
+                    for w in top_line[1:]:
+                        if w['xmin'] < xmin:
+                            wleft = w
+                            xmin = w['xmin']
+                    l = recurse_right(wleft)
+                    li['words'].extend(l)
+                    for w in l:
+                        #print("----> [%d] %s" % (block_num, w['text']))
+                        if w in top_line:
+                            top_line.remove(w)
+                        if w in blks[block_num]:
+                            blks[block_num].remove(w)
+                        #else:
+                        #  print("===> [%d] %s" % (block_num, w['text']))
+                        ### Si le else est vérifié, c'est qu'un mot est 'lu'
+                        ### deux fois. Ceci est normalement impossible du fait
+                        ### de la suppression res 'left' multiples dans
+                        ### clean_rightnbot()
+                #print("---> %s" % li)
+                li['xmin'] = min([w['xmin'] for w in li['words']])
+                li['xmax'] = max([w['xmax'] for w in li['words']])
+                li['ymin'] = min([w['ymin'] for w in li['words']])
+                li['ymax'] = max([w['ymax'] for w in li['words']])
+                txt = ''
+                distances = {}
+                # &&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
+                # &&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
+                # BUUUUG : On a des distances négatives…
+                # Ça n'est pas un bug. Il s'agit du cas où on aurait
+                # un mot en "indice" ET un autre en "exposant" (ou en tout cas)
+                # avec le même type de décalage par rapport à la ligne de base.
+                # Dans ce cas, on lit d'abort l'exposant, puis l'indice...
+                # qui commence avant la fin de l'exposant, d'où une distance
+                # négative. À traiter.
+                # &&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
+                # &&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
+                last_w = None
+                for w in li['words']:
+                    txt = "%s %s" % (txt, w['text'])
+                    if last_w is not None:
+                        d = round(float(w['xmin'] - last_w['xmax']) / float(SPC_PRECISION))
+                        if distances.get(d) is None:
+                            distances[d] = 1
+                        else:
+                            distances[d] += 1
+                    last_w = w
+                li['text'] = txt.strip()
+                li['distances'] = distances
+                lines.append(li)
+                ### &&&&&&&&&&&&&&&&&
+                print("%s   - %s" % (li['text'], li['distances']))
+                #for www in li['words']:
+                #    print("   [%.2f; %.2f] %s (%s)" % (www['xmin'], www['xmax'], www['text'], [w['word']['text'] for w in www['right']]))
+                ### &&&&&&&&&&&&&&&&&
+            blocks[block_num]['lines'] = lines
+            for key in ['xmin', 'ymin']:
+                blocks[block_num][key] = min([e[key] for e in lines])
+            for key in ['xmax', 'ymax']:
+                blocks[block_num][key] = max([e[key] for e in lines])
+            print()
+
+
+        #lines = get_lines_words(page)
+
+
+
 
-### Script pour faire tout le corpus :
-# D=/home/phan/Boulot/Ontology/BSV/tmp/Corpus/Tests/GrandesCultures; for i in ${D}/*.pdf; do j=$( basename "$i" | sed -e 's/\.pdf//' ); echo $j; python pdf2blocks.py ${D}/$j > ${D}/${j}.html ; done
 
-from p2b_functions import get_pdf2html
 
 # +--------------------------------------------------------------+
-# |                           main                               |
+# |                         draw_page                            |
 # +--------------------------------------------------------------+
-if (len(sys.argv) < 1):
-    print("-U-> Usage : python pdf2blocks.py <fichier_pdf>")
-    sys.exit(-1)
+def draw_page(path, page):
+    DRAW_BLOCKS = True
+    DRAW_WORDS = True
+    DRAW_REMOVED_WORDS = True
+    DRAW_W_LINKS_H = True
+    DRAW_W_LINKS_V = True
+    DRAW_LINES = True
+    DRAW_ALIGNMENTS_V = True
+    SCALE = 1.0
+
+    h = page['height']
+    w = page['width']
+    surf = cairo.ImageSurface(cairo.FORMAT_ARGB32, int(SCALE * w)+1, int(SCALE * h)+1)
+    ctx = cairo.Context(surf)
+    ctx.set_line_width(1)
+    ctx.set_source_rgb(1, 1, 1)
+    ctx.rectangle(0, 0, SCALE * w, SCALE * h)
+    ctx.fill()
+
+
+    if DRAW_WORDS:
+        ctx.set_source_rgb(1, 0.75, 0.75)
+        if page.get('words') is not None:
+            for wd in page['words']:
+                #if wd.get('glued') is not None:
+                #    ctx.set_source_rgb(1, 0, 0)
+                #    ctx.set_line_width(3)
+                ctx.rectangle(SCALE * wd['xmin'], SCALE * wd['ymax'],
+                    SCALE * (wd['xmax']-wd['xmin']),
+                    SCALE * (wd['ymin']-wd['ymax']))
+                ctx.stroke()
+                #if wd.get('glued') is not None:
+                #    ctx.set_line_width(1)
+                #    ctx.set_source_rgb(1, 0.75, 0.75)
+
+    if DRAW_REMOVED_WORDS:
+        ctx.set_source_rgb(0.25, 1, 1)
+        ctx.set_line_width(1)
+        for wd in getLst(page, 'removed'):
+            ctx.rectangle(SCALE * wd['xmin'], SCALE * wd['ymax'],
+                SCALE * (wd['xmax']-wd['xmin']),
+                SCALE * (wd['ymin']-wd['ymax']))
+            ctx.stroke()
+
+
+    if DRAW_W_LINKS_H:
+        ctx.set_source_rgb(1, 0.5, 1)
+        if page.get('words') is not None:
+            for wd in page['words']:
+                for rrr in wd['right']:
+                    ww = rrr['word']
+                    ctx.move_to(SCALE * wd['xmax'], SCALE * (wd['ymax']+wd['ymin'])/2.0)
+                    ctx.line_to(SCALE * ww['xmin'], SCALE * (ww['ymax']+ww['ymin'])/2.0)
+                    ctx.stroke()
+
+    if DRAW_W_LINKS_V:
+        ctx.set_source_rgb(1, 0.5, 1)
+        if page.get('words') is not None:
+            for wd in page['words']:
+                for rrr in wd['bottom']:
+                    ww = rrr['word']
+                    ctx.move_to(SCALE * (wd['xmax']+wd['xmin'])/2.0, SCALE * wd['ymax'])
+                    ctx.line_to(SCALE * (ww['xmax']+ww['xmin'])/2.0, SCALE * ww['ymin'])
+                    ctx.stroke()
+
+    if DRAW_LINES:
+        ctx.set_source_rgb(0.5, 0.5, 1)
+        if page.get('lines') is not None:
+            for li in page['lines']:
+                ctx.rectangle(SCALE * li['xmin'], SCALE * li['ymax'],
+                    SCALE * (li['xmax']-li['xmin']),
+                    SCALE * (li['ymin']-li['ymax']))
+                ctx.stroke()
+
+    if DRAW_ALIGNMENTS_V:
+        ctx.set_line_width(3)
+        ctx.set_source_rgb(0.25, 0.25, 1)
+        for al in page['alignment_left']:
+            ctx.move_to(SCALE * al[0]['xmin'], SCALE * al[0]['ymin'])
+            ctx.line_to(SCALE * al[-1]['xmin'], SCALE * al[-1]['ymax'])
+            ctx.stroke()
+        ctx.set_source_rgb(0.25, 0.75, 0.75)
+        for al in page['alignment_right']:
+            ctx.move_to(SCALE * al[0]['xmax'], SCALE * al[0]['ymin'])
+            ctx.line_to(SCALE * al[-1]['xmax'], SCALE * al[-1]['ymax'])
+            ctx.stroke()
+        ## Center doesn't make sense on words
+        #ctx.set_source_rgb(0.25, 1, 0.25)
+        #for al in page['alignment_center']:
+        #    ctx.move_to((al[0]['xmax']+al[0]['xmin'])/2.0, al[0]['ymin'])
+        #    ctx.line_to((al[-1]['xmax']+al[-1]['xmin'])/2.0, al[-1]['ymax'])
+        #    ctx.stroke()
+        ctx.set_line_width(1)
+
+    if DRAW_BLOCKS:
+        ctx.set_source_rgb(0.1, 0, 0.1)
+        for bl in getLst(page, 'blocks'):
+            ctx.rectangle(SCALE * bl['xmin'], SCALE * bl['ymax'],
+                SCALE * (bl['xmax'] - bl['xmin']),
+                SCALE * (bl['ymin'] - bl['ymax']))
+            ctx.stroke()
+    # &&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
+
+    print("---> %s/page_%d.png" % (path, page['number']))
+    surf.write_to_png("%s/page_%d.png" % (path, page['number']))
+
+
+
+
+#=====================================================#
+#                                                     #
+#                       M A I N                       #
+#                                                     #
+#=====================================================#
+#if (len(sys.argv) < 1):
+#    print("-U-> Usage : python pdf2test.py <fichier_pdf>")
+#    sys.exit(-1)
+
+if (len(sys.argv) >= 1):
+    if "--help" in sys.argv[1:]:
+        print("Usage : python %s [<file1> [<file2> …]]" % sys.argv[0])
+        print("  If no filename is given, it'll get them from standard input")
+        print("  (so you can pipe the result of a find command for example)")
+        print("")
+        sys.exit(0)
+
+if (len(sys.argv) == 1):
+    inp = sys.stdin
+else:
+    ch = "\n".join(sys.argv[1:])
+    inp = StringIO(ch)
+
+for line in inp:
+    if line[-1] == '\n':
+        line = line[:-1]
+
+    #print("======> %s" % line)
+
+    pages_txt = get_pdftotext(line)
+    detect_inner(pages_txt)
+    word_rightnbot(pages_txt)
+    glue_upper_artwork(pages_txt)
+    page_alignments(pages_txt)
+    clean_rightnbot(pages_txt)
+    get_blocks(pages_txt)
+    pages = pages_txt
+
+    # &&&&&&&&&&&&&&&&  TODO  &&&&&&&&&&&&&&&&&&&
+    # 1. Traiter le non-bug signalé ligne 978
+    # 2. Affecter les fontes aux mots. Attention : pdftohtml "oublie" parfois
+    #    des portions de texte (rare, mais ça arrive)
+    # 3. Reprendre le découpage de blocs :
+    #    - Traiter les "inner blocs" (avec la correspondance de fonte p.ex)
+    #    - Repérer et séparer les inner-titles,
+    #    - ...
+    # N. Nettoyer le code, restructurer, bref, rendre lisible.
+    # &&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
+
+    CALL_PDFTOHTML = False
+
+    if CALL_PDFTOHTML:
+        fonts_n_pages = get_pdftohtml(line)
+        if fonts_n_pages is None:
+            print("### ERREUR pdftohtml (%s)" % line)
+            sys.exit(-2)
+        pages_html = fonts_n_pages['pages']
+        fonts = fonts_n_pages['fonts']
+        del fonts_n_pages
+        pages = append_lines(pages_txt, pages_html)
+
+    CREATE_FILES = True
+
+    if CREATE_FILES:
+        try:
+            os.mkdir(os.path.splitext(line)[0])
+        except Exception as e:
+            pass
+        for p in pages:
+            draw_page(os.path.splitext(line)[0], p)
 
-print(get_pdf2html(sys.argv[1]), end='')
+    ### Test default values
+    #for p in pages:
+    #    if p.get('words') is not None:
+    #        for wd in p['words']:
+    #            if len(wd['text']) == 1 and wd['text'].isupper():
+    #                h = wd['ymax'] - wd['ymin']
+    #                for rr in wd['right']:
+    #                    if rr['dist'] < 0.2*h: # ← 0.1 ou 0.2 ne change rien sur les extraits.
+    #                        ww = rr['word']
+    #                        hh = ww['ymax'] - ww['ymin']
+    #                        if (hh < h):
+    #                            print("%s ===> %s - %s → %s" % (os.path.splitext(line)[0],
+    #                            wd['text'], ww['text'], wd['text']+ww['text']))
-- 
GitLab