404 lines
14 KiB
Python
404 lines
14 KiB
Python
|
|
"""
|
|||
|
|
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()
|