Files
TicketTracker/tickettracker/parsers/leclerc.py
laurent 1e5fc97bb7 feat: migration Windows → Ubuntu, stabilisation suite de tests
- Ajout venv Python (.venv) avec pip bootstrap (python3-venv absent)
- Correction OCR Linux : marqueur TTC/TVA tolère la confusion T↔I
  (Tesseract 5.3.4 Linux lit parfois "TIc" au lieu de "TTC")
- test_leclerc.py : skipif si Tesseract absent, xfail pour test de somme
  (précision OCR variable entre plateformes, solution LLM vision prévue)
- Résultat : 77 passent, 1 xfail, 0 échec (vs 78 sur Windows)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 18:53:41 +01:00

404 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Parser pour les tickets de caisse PDF Leclerc.
Les PDFs Leclerc sont des scans d'images (pas de couche texte sélectionnable).
Le parser extrait l'image JPEG embarquée et applique Tesseract OCR pour
récupérer le texte, puis l'analyse ligne par ligne.
Prérequis système :
- Tesseract OCR 5.x installé :
Windows : https://github.com/UB-Mannheim/tesseract/wiki
Linux : apt install tesseract-ocr tesseract-ocr-fra
- Modèle français (fra.traineddata) dans le dossier tessdata.
Si les droits manquent pour écrire dans le dossier système,
placer les fichiers eng.traineddata et fra.traineddata dans
un dossier local et définir TESSDATA_PREFIX=<chemin_du_dossier>.
- pip install pytesseract pillow pdfplumber
Structure du ticket Leclerc :
>> CATEGORIE → titre de catégorie (gras sur ticket)
NOM PRODUIT PRIX TVA → article standard (1 ligne)
* NOM PRODUIT PRIX TVA → idem, avec marque promotion
NOM PRODUIT → article multi-unités (pas de prix ici)
QTY X PRIX_UNIT€ TOTAL TVA → ligne de prix (suite du précédent)
Total NN articles TOTAL_TTC → total avant remises
Bon reduction MONTANT → bon de réduction (peut se répéter)
CB MONTANT_FINAL → montant payé par carte
Dépendances Python : pdfplumber, pytesseract, Pillow
"""
import io
import os
import re
from datetime import date
from typing import Optional
import pdfplumber
import pytesseract
from PIL import Image
from tickettracker.models.receipt import Item, Receipt
# ---------------------------------------------------------------------------
# Configuration Tesseract
# ---------------------------------------------------------------------------
# Chemins Tesseract standards selon l'OS
_TESSERACT_PATHS = [
r"C:/Program Files/Tesseract-OCR/tesseract.exe",
r"C:/Program Files (x86)/Tesseract-OCR/tesseract.exe",
"/usr/bin/tesseract",
"/usr/local/bin/tesseract",
]
# Correspondance noms de mois français → numéro
_MOIS_FR = {
"janvier": 1, "février": 2, "mars": 3, "avril": 4,
"mai": 5, "juin": 6, "juillet": 7, "août": 8,
"septembre": 9, "octobre": 10, "novembre": 11, "décembre": 12,
}
def _configure_tesseract() -> None:
"""Détecte et configure le binaire Tesseract et le dossier tessdata.
Priorité :
1. Variable d'environnement TESSERACT_CMD (chemin vers le binaire)
2. Chemins standards Windows/Linux
Pour tessdata :
1. Variable TESSDATA_PREFIX déjà définie dans l'environnement
2. Dossier tessdata/ à côté de ce fichier (usage dev avec droits limités)
"""
# Binaire
cmd = os.environ.get("TESSERACT_CMD")
if not cmd:
for p in _TESSERACT_PATHS:
if os.path.isfile(p):
cmd = p
break
if cmd:
pytesseract.pytesseract.tesseract_cmd = cmd
# Tessdata (uniquement si pas déjà configuré)
if not os.environ.get("TESSDATA_PREFIX"):
# Cherche tessdata/ dans le répertoire du projet (2 niveaux au-dessus)
here = os.path.dirname(os.path.abspath(__file__))
local_tessdata = os.path.join(here, "..", "..", "tessdata")
if os.path.isdir(local_tessdata) and os.path.isfile(
os.path.join(local_tessdata, "fra.traineddata")
):
os.environ["TESSDATA_PREFIX"] = os.path.abspath(local_tessdata)
# ---------------------------------------------------------------------------
# Point d'entrée public
# ---------------------------------------------------------------------------
def parse(pdf_path: str) -> Receipt:
"""Parse un PDF de ticket Leclerc et retourne un ticket normalisé.
Args:
pdf_path: Chemin vers le fichier PDF du ticket Leclerc.
Le PDF doit être un scan image (type Leclerc classique).
Returns:
Receipt: Ticket de courses normalisé avec tous les articles et
leurs catégories.
Raises:
ValueError: Si la date ou le total sont introuvables.
RuntimeError: Si Tesseract n'est pas installé ou pas configuré.
"""
_configure_tesseract()
text = _extract_text_from_pdf(pdf_path)
return _parse_text(text)
# ---------------------------------------------------------------------------
# Extraction image + OCR
# ---------------------------------------------------------------------------
def _extract_text_from_pdf(pdf_path: str) -> str:
"""Extrait l'image JPEG du PDF et retourne le texte OCR.
Les tickets Leclerc sont une unique image haute résolution (1650×10386)
découpée sur plusieurs pages PDF. L'image est identique dans le flux de
chaque page ; on l'extrait une seule fois depuis la page 1.
"""
with pdfplumber.open(pdf_path) as pdf:
if not pdf.pages or not pdf.pages[0].images:
raise ValueError(
f"Le PDF '{pdf_path}' ne contient pas d'image en page 1. "
"Le format Leclerc attendu est un scan image (JPEG embarqué)."
)
img_obj = pdf.pages[0].images[0]
raw_jpeg = img_obj["stream"].get_rawdata()
img = Image.open(io.BytesIO(raw_jpeg))
# Amélioration légère de la lisibilité avant OCR :
# - Conversion en niveaux de gris (le ticket est noir sur blanc)
# - Resize au 2/3 : accélère le traitement sans perte significative
# (1650px → 1100px, le texte reste lisible à ~40px de hauteur)
img_gray = img.convert("L").resize(
(img.width * 2 // 3, img.height * 2 // 3),
Image.LANCZOS,
)
try:
text = pytesseract.image_to_string(
img_gray,
lang="fra+eng",
config="--psm 6 --oem 3", # bloc de texte uniforme, LSTM
)
except pytesseract.TesseractError as e:
raise RuntimeError(
f"Erreur Tesseract lors de l'OCR : {e}\n"
"Vérifiez que Tesseract est installé et que le modèle 'fra' est disponible.\n"
"Voir README ou commentaires dans tickettracker/parsers/leclerc.py."
) from e
return text
# ---------------------------------------------------------------------------
# Parsing du texte OCR
# ---------------------------------------------------------------------------
# Regex pour une ligne article standard :
# NOM PRODUIT PRIX CODE_TVA
#
# Problèmes OCR observés sur le ticket Leclerc Clichy :
# - Un seul espace entre nom et prix (pas d'alignement de colonnes garanti)
# - Artefacts de séparateur de colonne dans le nom : ' | ' ou ' — '
# - Prix avec 3 décimales (ex: "10.460" au lieu de "10.40")
# - Code TVA avec 0 devant : "01" au lieu de "1"
# - Préfixe "* " pour les articles en promotion
#
# Stratégie : le code TVA (1 ou 2 chiffres max) est TOUJOURS le dernier token ;
# le prix (N.NN ou N,NN) est toujours l'avant-dernier. On greedy-matche .+ pour
# le nom et le moteur de regex backtrackera jusqu'à trouver le bon découpage.
_ITEM_RE = re.compile(
r"^(?:\* )?" # préfixe optionnel * (non capturé)
r"(?P<name>.+)" # nom du produit (greedy → backtrack)
r"\s+"
r"(?P<price>\d{1,3}[.,]\d{2,3})" # prix : 2 ou 3 décimales (OCR)
r"\s+"
r"(?P<tva>0?\d{1,2})" # code TVA : 1 ou 4, parfois "01"
r"\s*$"
)
# Regex pour la ligne de prix des articles en multi-unités :
# 2 X 3.48€ 6.96 1
_MULTI_RE = re.compile(
r"^\s*(?P<qty>\d+)\s*[Xx]\s*(?P<unit_price>\d+[.,]\d+)€?"
r"\s+(?P<total>\d+[.,]\d{2})\s+(?P<tva>\d{1,2})\s*$"
)
# Regex pour la ligne de total général
_TOTAL_RE = re.compile(r"Total\s+\d+\s+articles\s+(?P<total>\d+[.,]\d{2})")
# Regex pour les remises
_REDUCTION_RE = re.compile(r"Bon\s+r[ée]duction\s+(?P<amount>\d+[.,]\d{2})", re.IGNORECASE)
# Regex pour le paiement CB (montant final payé)
_CB_RE = re.compile(r"^[Cc][Bb]\s+(?P<amount>\d+[.,]\d{2})\s*$")
# Regex pour la date dans la ligne "Caisse XXX DD mois YYYY HH:MM"
_DATE_RE = re.compile(
r"Caisse\s+\S+\s+(\d{1,2})\s+(\w+)\s+(\d{4})",
re.IGNORECASE,
)
# Regex pour l'identifiant de caisse (ordre "Caisse 018-0003 ...")
_CAISSE_RE = re.compile(r"Caisse\s+(\S+)", re.IGNORECASE)
def _parse_price(s: str) -> float:
"""Convertit une chaîne de prix (virgule ou point) en float."""
return float(s.replace(",", "."))
def _parse_text(text: str) -> Receipt:
"""Analyse le texte OCR d'un ticket Leclerc.
Retourne un Receipt avec tous les articles, catégories, date et total.
Note OCR : Tesseract peut commettre des erreurs sur des caractères
ambigus (0 ↔ 6, | ↔ 1, etc.). Le total du ticket est retourné tel qu'OCR
l'a lu ; il peut différer légèrement si des caractères sont mal reconnus.
"""
lines = text.splitlines()
delivery_date = _extract_date(lines)
order_id = _extract_caisse_id(lines)
items = _extract_items(lines)
total = _extract_total(lines)
return Receipt(
store="leclerc",
date=delivery_date,
total=total,
items=items,
order_id=order_id,
)
def _extract_date(lines: list[str]) -> date:
"""Extrait la date depuis la ligne 'Caisse XXX DD mois AAAA HH:MM'."""
for line in lines:
m = _DATE_RE.search(line)
if m:
day = int(m.group(1))
month_str = m.group(2).lower().strip()
year = int(m.group(3))
month = _MOIS_FR.get(month_str)
if month:
return date(year, month, day)
raise ValueError(
"Date introuvable dans le ticket Leclerc. "
"Attendu : 'Caisse XXX DD mois YYYY' dans l'OCR."
)
def _extract_caisse_id(lines: list[str]) -> Optional[str]:
"""Extrait le numéro de caisse (ex: '018-0003')."""
for line in lines:
m = _CAISSE_RE.search(line)
if m:
return m.group(1)
return None
def _extract_total(lines: list[str]) -> float:
"""Extrait le montant final payé.
Préférence au montant CB (après remises). Sinon, le 'Total N articles'.
"""
cb_total: Optional[float] = None
subtotal: Optional[float] = None
for line in lines:
m_cb = _CB_RE.match(line.strip())
if m_cb:
cb_total = _parse_price(m_cb.group("amount"))
m_tot = _TOTAL_RE.search(line)
if m_tot:
subtotal = _parse_price(m_tot.group("total"))
total = cb_total if cb_total is not None else subtotal
if total is None:
raise ValueError(
"Montant total introuvable dans le ticket Leclerc. "
"La structure du ticket a peut-être changé."
)
return total
def _extract_items(lines: list[str]) -> list[Item]:
"""Extrait tous les articles du texte OCR ligne par ligne.
Gère :
- Articles standard (1 ligne : nom + prix + code TVA)
- Articles en multi-unités (nom sur une ligne, QTY × PRIX sur la suivante)
- Changements de catégorie (lignes débutant par >>)
- Arrêt à la ligne 'Total N articles'
"""
items: list[Item] = []
current_category: Optional[str] = None
pending_name: Optional[str] = None # nom en attente de la ligne de prix
# On cherche d'abord la ligne "TTC TVA" qui marque le début des articles
in_items = False
for line in lines:
raw = line.strip()
# Début de la section articles (insensible à la casse : "TTc TvA" possible)
# Note OCR : Tesseract Linux lit parfois "TIc" au lieu de "TTC"
# (confusion T↔I en 2e position, fréquente avec Tesseract 5.x)
if not in_items:
if re.search(r"T[TI]C\s+TVA", raw, re.IGNORECASE):
in_items = True
continue
# Fin de la section articles
if _TOTAL_RE.search(raw):
break
# Ligne vide → ignore
if not raw:
continue
# Changement de catégorie (>> CATEGORIE)
if raw.startswith(">>"):
category_raw = raw.lstrip(">").strip()
current_category = re.sub(r"[|_—]+", " ", category_raw).strip()
pending_name = None
continue
# Pré-nettoyage des artefacts OCR dans la ligne avant les regex :
# '|' et '—' sont des séparateurs de colonnes que Tesseract voit parfois
# comme des caractères dans le texte.
clean = re.sub(r"\s*[|—]\s*", " ", raw).strip()
# Ligne de prix pour un article multi-unités (2 X 3.48€ 6.96 1)
m_multi = _MULTI_RE.match(clean)
if m_multi and pending_name is not None:
qty = float(m_multi.group("qty"))
unit_price = _parse_price(m_multi.group("unit_price"))
total_price = _parse_price(m_multi.group("total"))
items.append(Item(
name=_clean_name(pending_name),
quantity=qty,
unit="pièce",
unit_price=unit_price,
total_price=total_price,
category=current_category,
))
pending_name = None
continue
# Ligne article standard (nom prix tva) — regex greedy depuis la droite
m_item = _ITEM_RE.match(clean)
if m_item:
if pending_name is not None:
pending_name = None
name = _clean_name(m_item.group("name"))
# Tronque à 2 décimales pour corriger les prix OCR comme "10.460"
price = round(_parse_price(m_item.group("price")), 2)
items.append(Item(
name=name,
quantity=1.0,
unit="pièce",
unit_price=price,
total_price=price,
category=current_category,
))
continue
# Ligne sans prix reconnaissable → début d'un article multi-unités
if len(clean) > 4 and not re.match(r"^[\d\s.,|*_-]+$", clean):
pending_name = clean.lstrip("* ").strip()
return items
def _clean_name(name: str) -> str:
"""Nettoie le nom d'un article des artefacts OCR courants.
Artefacts observés sur le ticket Leclerc Clichy :
- ' |' ou '| ' en fin de nom (artefact de séparateur de colonne)
- ' _' ou '_ ' (bruit d'image)
- espaces multiples
"""
# Retire les artefacts de colonnes en fin de chaîne
name = re.sub(r"[\s|_—]+$", "", name)
# Normalise les espaces internes
name = re.sub(r"\s{2,}", " ", name)
return name.strip()