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. - - - -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**. -  - *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). -  - *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