From 0e30ed8501f171a6bfc1626e66a5df4379fab427 Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Sat, 25 Jul 2026 08:16:27 +0200 Subject: [PATCH] fix: corrige la perte de lots quand une page coupe le tableau des locataires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pdfplumber fragmente parfois le tableau en plusieurs bbox disjoints quand un filet de séparation manque entre deux lots (ex: page 3 du sample), ce qui faisait disparaître silencieusement les lots tombés dans l'interstice ou situés après le fragment ignoré. Corrige aussi la troncature du nom de locataire quand il s'étale sur plusieurs lignes visuelles ou coïncide avec la ligne de période (lot coupé entre deux pages). Co-Authored-By: Claude Sonnet 5 --- .../parsers/locataires_table.py | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/src/plesna_gerance/parsers/locataires_table.py b/src/plesna_gerance/parsers/locataires_table.py index f7ee34a..39e83cd 100644 --- a/src/plesna_gerance/parsers/locataires_table.py +++ b/src/plesna_gerance/parsers/locataires_table.py @@ -88,8 +88,9 @@ def _rows_from_page(page) -> list[dict]: ne contient pas ce tableau. """ # Repérer le tableau « situation locataires » (en-tête commençant par « Locataires »). + candidates = page.find_tables() table = None - for candidate in page.find_tables(): + for candidate in candidates: header = candidate.rows[0].cells if header and header[0] is not None: first = _strip_accents(page.crop(header[0]).extract_text() or "") @@ -109,7 +110,20 @@ def _rows_from_page(page) -> list[dict]: return i return None - words = page.crop(table.bbox).extract_words() + # Quand un filet de séparation manque entre deux lots, pdfplumber détecte + # la table logique en plusieurs fragments dont les bbox se touchent sans + # se recouvrir : une ligne à cheval sur la coupure tombe alors hors des + # deux bbox et serait perdue. On étend donc la zone scannée jusqu'au bas + # du fragment le plus bas partageant la même marge gauche que l'ancre, + # pour balayer une zone continue plutôt que des bbox disjointes. + x0, top, x1, bottom = table.bbox + for candidate in candidates: + if candidate is table: + continue + if abs(candidate.bbox[0] - x0) <= 2: + bottom = max(bottom, candidate.bbox[3]) + + words = page.crop((x0, top, x1, bottom)).extract_words() words.sort(key=lambda w: (round((w["top"] + w["bottom"]) / 2, 1), w["x0"])) # Regrouper les mots en lignes visuelles (clustering vertical tolérant). @@ -271,6 +285,11 @@ def extract_situation_locataires_from_pdf(pdf_path: str) -> list[dict]: _fill_totaux(current, row) continue + # Ligne de total général de fin de tableau (« TOTAUX », tout en + # majuscules) : ne concerne aucun lot, à ignorer. + if loc == "TOTAUX": + continue + # Solde Antérieur (libellé + montant dans la colonne période). if periode.startswith("Solde Antérieur"): montant = _num(periode) @@ -307,14 +326,22 @@ def extract_situation_locataires_from_pdf(pdf_path: str) -> list[dict]: ) continue - # Ligne de période (loyer ou divers). + # Ligne de période (loyer ou divers). Quand l'en-tête du lot est en bas + # d'une page et ses données en haut de la suivante, le nom du locataire + # peut se retrouver sur cette même ligne visuelle (colonne « Locataires » + # non vide) plutôt que sur la ligne d'en-tête du lot. if _PERIODE_RE.search(periode): + if loc: + nom = current["locataire"]["nom"] + current["locataire"]["nom"] = f"{nom} {loc}".strip() if nom else loc _append_periode_line(current, row) continue - # Nom du locataire (cellule « Locataires » seule, nom pas encore trouvé). - if loc and not current["locataire"]["nom"]: - current["locataire"]["nom"] = loc + # Nom du locataire (cellule « Locataires » seule). Peut être réparti sur + # plusieurs lignes visuelles quand le nom est trop long pour la colonne. + if loc: + nom = current["locataire"]["nom"] + current["locataire"]["nom"] = f"{nom} {loc}".strip() if nom else loc if current: situations.append(current)