fix: corrige la perte de lots quand une page coupe le tableau des locataires
All checks were successful
Build and Publish Docker Image / Build App Image (push) Successful in 1m32s
Build and Publish Docker Image / Build Summary (push) Successful in 4s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 08:16:27 +02:00
parent 8972bcaa06
commit 0e30ed8501

View File

@@ -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)