""" 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=. - 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.+)" # nom du produit (greedy → backtrack) r"\s+" r"(?P\d{1,3}[.,]\d{2,3})" # prix : 2 ou 3 décimales (OCR) r"\s+" r"(?P0?\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\d+)\s*[Xx]\s*(?P\d+[.,]\d+)€?" r"\s+(?P\d+[.,]\d{2})\s+(?P\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\d+[.,]\d{2})") # Regex pour les remises _REDUCTION_RE = re.compile(r"Bon\s+r[ée]duction\s+(?P\d+[.,]\d{2})", re.IGNORECASE) # Regex pour le paiement CB (montant final payé) _CB_RE = re.compile(r"^[Cc][Bb]\s+(?P\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()