feat: migre l'extraction de pdftotext (poppler) vers pdfplumber
Remplace l'appel externe `pdftotext -layout` (poppler-utils) par pdfplumber, une bibliotheque Python pure : plus aucune dependance systeme, ce qui permettra d'empaqueter l'application en executable autonome. - `read_pdf()` retourne un `PdfContent` (texte layout + mots de la premiere page avec coordonnees) ; `extract_text_from_pdf()` est conserve comme wrapper de compatibilite. - Metadonnees : la reference est desormais extraite via les coordonnees geometriques des mots (fiable sur l'en-tete multi-colonnes), regex du destinataire durcie (forme pointee `S.C.I.`). - Operations : reduction des espaces multiples dans les descriptions. - Dockerfile : suppression de poppler-utils. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
from .parsers.locataires import extract_situation_locataires
|
||||
from .parsers.metadata import extract_metadata
|
||||
from .parsers.operations import extract_recapitulatif_operations
|
||||
from .parsers.pdf import extract_text_from_pdf
|
||||
from .parsers.pdf import read_pdf
|
||||
|
||||
|
||||
def extract_compte_rendu(pdf_path: str) -> dict:
|
||||
@@ -22,13 +22,12 @@ def extract_compte_rendu(pdf_path: str) -> dict:
|
||||
- recapitulatif_operations: dépenses et opérations par catégorie
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: Si pdftotext échoue
|
||||
FileNotFoundError: Si le fichier PDF n'existe pas
|
||||
"""
|
||||
text = extract_text_from_pdf(pdf_path)
|
||||
content = read_pdf(pdf_path)
|
||||
|
||||
return {
|
||||
"metadata": extract_metadata(text),
|
||||
"situation_locataires": extract_situation_locataires(text),
|
||||
"recapitulatif_operations": extract_recapitulatif_operations(text),
|
||||
"metadata": extract_metadata(content.text, content.words),
|
||||
"situation_locataires": extract_situation_locataires(content.text),
|
||||
"recapitulatif_operations": extract_recapitulatif_operations(content.text),
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
from .locataires import extract_situation_locataires
|
||||
from .metadata import extract_metadata
|
||||
from .operations import extract_recapitulatif_operations
|
||||
from .pdf import extract_text_from_pdf
|
||||
from .pdf import PdfContent, extract_text_from_pdf, read_pdf
|
||||
|
||||
__all__ = [
|
||||
"PdfContent",
|
||||
"read_pdf",
|
||||
"extract_text_from_pdf",
|
||||
"extract_metadata",
|
||||
"extract_situation_locataires",
|
||||
|
||||
@@ -6,11 +6,36 @@ from ..utils.amounts import parse_amount
|
||||
from ..utils.dates import parse_french_date
|
||||
|
||||
|
||||
def extract_metadata(text: str) -> dict:
|
||||
def _reference_from_words(words: list[dict]) -> str | None:
|
||||
"""Récupère le numéro de référence via les coordonnées des mots.
|
||||
|
||||
Le numéro figure sur la même ligne que le libellé « REFERENCES », à sa
|
||||
droite. L'alignement par espaces n'étant pas fiable sur cet en-tête
|
||||
multi-colonnes, on s'appuie sur la position géométrique des mots.
|
||||
"""
|
||||
labels = [w for w in words if "REFERENCE" in w["text"].upper()]
|
||||
for label in labels:
|
||||
same_line = sorted(
|
||||
(
|
||||
w
|
||||
for w in words
|
||||
if abs(w["top"] - label["top"]) < 5 and w["x0"] > label["x1"]
|
||||
),
|
||||
key=lambda w: w["x0"],
|
||||
)
|
||||
for w in same_line:
|
||||
if re.fullmatch(r"\d{4,}", w["text"]):
|
||||
return w["text"]
|
||||
return None
|
||||
|
||||
|
||||
def extract_metadata(text: str, words: list[dict] | None = None) -> dict:
|
||||
"""Extrait les métadonnées du document.
|
||||
|
||||
Args:
|
||||
text: Texte complet du PDF
|
||||
text: Texte complet du PDF (mise en page préservée)
|
||||
words: Mots de la première page avec coordonnées (optionnel).
|
||||
Utilisé pour fiabiliser les champs d'en-tête multi-colonnes.
|
||||
|
||||
Returns:
|
||||
Dictionnaire contenant:
|
||||
@@ -48,8 +73,10 @@ def extract_metadata(text: str) -> dict:
|
||||
editeur["capital"] = m.group(1).replace(" ", "")
|
||||
|
||||
# Destinataire
|
||||
# On exige la forme pointée « S.C.I. » : pdfplumber peut produire un
|
||||
# « SCI » parasite (sans points) dans l'en-tête, qu'il faut ignorer.
|
||||
destinataire = {"nom": "", "adresse": ""}
|
||||
m = re.search(r"(S\.?C\.?I\.?\s+\w+)", text)
|
||||
m = re.search(r"(S\.C\.I\.\s+[A-Za-zÉÈ][\w\-]*)", text)
|
||||
if m:
|
||||
destinataire["nom"] = m.group(1).strip()
|
||||
|
||||
@@ -65,9 +92,15 @@ def extract_metadata(text: str) -> dict:
|
||||
|
||||
# Document
|
||||
document = {"reference": "", "date": "", "type": "COMPTE RENDU DE GESTION"}
|
||||
m = re.search(r"REFERENCES\s+(\d+)", text)
|
||||
if m:
|
||||
document["reference"] = m.group(1)
|
||||
# En priorité via les coordonnées (fiable sur l'en-tête multi-colonnes),
|
||||
# puis repli sur le texte si les mots ne sont pas fournis.
|
||||
ref = _reference_from_words(words) if words else None
|
||||
if ref is None:
|
||||
m = re.search(r"REFERENCES\s+(\d+)", text)
|
||||
if m:
|
||||
ref = m.group(1)
|
||||
if ref:
|
||||
document["reference"] = ref
|
||||
|
||||
m = re.search(r"Lyon le (\d{2}/\d{2}/\d{4})", text)
|
||||
if m:
|
||||
|
||||
@@ -175,6 +175,9 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
|
||||
if current_fournisseur and description.startswith(current_fournisseur):
|
||||
description = description[len(current_fournisseur) :].strip()
|
||||
|
||||
# Réduire les espaces multiples (séparateurs de colonnes) en un seul
|
||||
description = re.sub(r"\s{2,}", " ", description).strip()
|
||||
|
||||
if description and not description.startswith("Totaux"):
|
||||
# Extraire le numéro de lot depuis la description (ex: M06 -> 0006)
|
||||
lot_numero = _extract_lot_code_from_description(description)
|
||||
|
||||
@@ -1,28 +1,74 @@
|
||||
"""Extraction de texte depuis les PDFs."""
|
||||
"""Extraction de texte et de mots depuis les PDFs via pdfplumber.
|
||||
|
||||
import subprocess
|
||||
pdfplumber est une bibliothèque Python pure : aucune dépendance système
|
||||
(contrairement à l'ancien `pdftotext`/poppler), ce qui permet d'empaqueter
|
||||
l'application en exécutable autonome (Windows notamment).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pdfplumber
|
||||
|
||||
|
||||
@dataclass
|
||||
class PdfContent:
|
||||
"""Contenu extrait d'un PDF.
|
||||
|
||||
Attributes:
|
||||
text: Texte de toutes les pages avec mise en page préservée
|
||||
(équivalent de `pdftotext -layout`). Consommé par les parseurs
|
||||
opérations et locataires.
|
||||
words: Mots de la première page avec leurs coordonnées
|
||||
(``text``, ``x0``, ``x1``, ``top``, ``bottom``). Utilisé par le
|
||||
parseur de métadonnées pour les champs d'en-tête multi-colonnes
|
||||
où l'alignement par espaces n'est pas fiable.
|
||||
"""
|
||||
|
||||
text: str
|
||||
words: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def read_pdf(pdf_path: str) -> PdfContent:
|
||||
"""Lit un PDF et retourne son texte (layout) et les mots de l'en-tête.
|
||||
|
||||
Args:
|
||||
pdf_path: Chemin vers le fichier PDF
|
||||
|
||||
Returns:
|
||||
Un objet :class:`PdfContent`.
|
||||
"""
|
||||
text_parts: list[str] = []
|
||||
header_words: list[dict] = []
|
||||
|
||||
with pdfplumber.open(pdf_path) as pdf:
|
||||
for page_index, page in enumerate(pdf.pages):
|
||||
text_parts.append(page.extract_text(layout=True) or "")
|
||||
# Les métadonnées se trouvent uniquement sur la première page.
|
||||
if page_index == 0:
|
||||
header_words = [
|
||||
{
|
||||
"text": w["text"],
|
||||
"x0": w["x0"],
|
||||
"x1": w["x1"],
|
||||
"top": w["top"],
|
||||
"bottom": w["bottom"],
|
||||
}
|
||||
for w in page.extract_words()
|
||||
]
|
||||
|
||||
return PdfContent(text="\n".join(text_parts), words=header_words)
|
||||
|
||||
|
||||
def extract_text_from_pdf(pdf_path: str) -> str:
|
||||
"""Extrait le texte du PDF via pdftotext.
|
||||
"""Extrait le texte du PDF avec mise en page préservée.
|
||||
|
||||
Nécessite pdftotext (poppler-utils) installé sur le système:
|
||||
- Ubuntu/Debian: sudo apt-get install poppler-utils
|
||||
- macOS: brew install poppler
|
||||
Conserve la signature historique (retourne une chaîne) pour la CLI et
|
||||
les parseurs qui ne consomment que le texte.
|
||||
|
||||
Args:
|
||||
pdf_path: Chemin vers le fichier PDF
|
||||
|
||||
Returns:
|
||||
Texte extrait du PDF avec mise en page préservée
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: Si pdftotext échoue
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["pdftotext", "-layout", pdf_path, "-"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
return read_pdf(pdf_path).text
|
||||
|
||||
Reference in New Issue
Block a user