OCR multi-format (liste + detail) + matching accents + debug

This commit is contained in:
2026-06-30 09:21:46 +02:00
parent 3cc6ede572
commit 458f80f201
2 changed files with 132 additions and 85 deletions
+8
View File
@@ -44,6 +44,14 @@
Voir <a href="/admin/compta/depense/">dépenses</a> · <a href="/admin/compta/facture/">factures</a>.</div> Voir <a href="/admin/compta/depense/">dépenses</a> · <a href="/admin/compta/facture/">factures</a>.</div>
{% endif %} {% endif %}
{% if ocr_debug %}
<details class="card">
<summary style="cursor:pointer;color:var(--muted)">Texte lu par l'OCR (debug — {{ ocr_debug|length }} ligne(s))</summary>
<pre style="white-space:pre-wrap;font-size:12px;color:var(--muted);margin-top:8px">{% for l in ocr_debug %}{{ l }}
{% endfor %}</pre>
</details>
{% endif %}
{% if not lignes %} {% if not lignes %}
<form method="post" enctype="multipart/form-data" class="card" id="srcForm"> <form method="post" enctype="multipart/form-data" class="card" id="srcForm">
{% csrf_token %} {% csrf_token %}
+124 -85
View File
@@ -1,11 +1,18 @@
""" """
Import d'opérations : par CSV (collé/uploadé) ou par OCR d'une capture d'écran Import d'opérations : CSV (collé/uploadé) ou OCR d'une capture d'écran.
(pytesseract). Dans les deux cas, un aperçu ÉDITABLE permet de corriger avant
d'enregistrer (l'OCR n'est jamais parfait). OCR multi-format :
- LISTE : le tableau d'opérations (date empilée à gauche, lignes). Découpage
ancré sur le montant ; la date est reportée d'une ligne à l'autre
(Indy ne l'affiche qu'une fois par jour). « UE » => intracom.
- DÉTAIL : la fiche d'une opération (Libellé / Date / Montant / Ventilation).
Extraction au mieux (les petits libellés gris sont mal lus).
Dans tous les cas : aperçu ÉDITABLE + panneau de debug OCR.
""" """
import csv import csv
import io import io
import re import re
import unicodedata
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from datetime import datetime, date as date_cls from datetime import datetime, date as date_cls
@@ -20,6 +27,7 @@ MOIS = {"janvier": 1, "février": 2, "fevrier": 2, "mars": 3, "avril": 4, "mai":
"juin": 6, "juillet": 7, "août": 8, "aout": 8, "septembre": 9, "juin": 6, "juillet": 7, "août": 8, "aout": 8, "septembre": 9,
"octobre": 10, "novembre": 11, "décembre": 12, "decembre": 12} "octobre": 10, "novembre": 11, "décembre": 12, "decembre": 12}
AMOUNT_RE = re.compile(r"^-?\d[\d .]*[.,]\d{2}$") AMOUNT_RE = re.compile(r"^-?\d[\d .]*[.,]\d{2}$")
DATE_RE = re.compile(r"^\d{2}/\d{2}/\d{4}$")
def _dec(v, d="0"): def _dec(v, d="0"):
@@ -27,8 +35,7 @@ def _dec(v, d="0"):
s = str(v or d) s = str(v or d)
for ch in (" ", " ", " "): for ch in (" ", " ", " "):
s = s.replace(ch, "") s = s.replace(ch, "")
s = s.replace(",", ".").strip() return Decimal(s.replace(",", ".").strip() or d)
return Decimal(s or d)
except (InvalidOperation, AttributeError): except (InvalidOperation, AttributeError):
return Decimal(d) return Decimal(d)
@@ -43,12 +50,6 @@ def _date(s):
return None return None
def _activite(s):
s = (s or "").strip().lower()
return {"vente": "vente", "service_bic": "service_bic", "service_bnc": "service_bnc",
"bic": "service_bic", "bnc": "service_bnc"}.get(s, "")
def _regime(s): def _regime(s):
s = (s or "").strip().lower() s = (s or "").strip().lower()
if s in ("intracom", "intracommunautaire", "ue"): if s in ("intracom", "intracommunautaire", "ue"):
@@ -58,11 +59,21 @@ def _regime(s):
return RegimeTva.NATIONALE return RegimeTva.NATIONALE
def _noacc(s):
return "".join(c for c in unicodedata.normalize("NFD", (s or "").lower())
if unicodedata.category(c) != "Mn")
def _match_cat(text, cats): def _match_cat(text, cats):
low = (text or "").lower() low = _noacc(text)
for nom_low, c in cats.items(): items = [(_noacc(nom), c) for nom, c in cats.items()]
for nom_low, c in items:
if nom_low and nom_low in low: if nom_low and nom_low in low:
return c return c
for nom_low, c in items:
mots = [m for m in re.split(r"[ ,/.]+", nom_low) if len(m) > 4]
if mots and mots[0] in low:
return c
return None return None
@@ -73,14 +84,12 @@ def _row(dt, libelle, montant, taux=Decimal("0"), regime=RegimeTva.NATIONALE,
typ = "Mouvement neutre" if neutre else "Vente" typ = "Mouvement neutre" if neutre else "Vente"
else: else:
typ = "Dépense neutre" if neutre else "Dépense" typ = "Dépense neutre" if neutre else "Dépense"
return { return {"n": n, "date": dt.isoformat() if dt else "", "libelle": libelle,
"n": n, "date": dt.isoformat() if dt else "", "libelle": libelle, "categorie_id": categorie.pk if categorie else "",
"categorie_id": categorie.pk if categorie else "", "taux": taux, "regime": regime, "montant": montant, "type": typ}
"taux": taux, "regime": regime, "montant": montant, "type": typ,
}
# ---- Parsing CSV -------------------------------------------------------- # ---- CSV ----------------------------------------------------------------
def _parse_csv(text, cats): def _parse_csv(text, cats):
text = text.lstrip("") text = text.lstrip("")
@@ -101,43 +110,33 @@ def _parse_csv(text, cats):
cat = cats.get((c[2] or "").strip().lower()) cat = cats.get((c[2] or "").strip().lower())
lignes.append(_row(dt, (c[1] or "").strip() or "(sans libellé)", montant, lignes.append(_row(dt, (c[1] or "").strip() or "(sans libellé)", montant,
_dec(c[4]), _regime(c[5]), cat, n)) _dec(c[4]), _regime(c[5]), cat, n))
return lignes, erreurs return lignes, erreurs, []
# ---- Parsing OCR -------------------------------------------------------- # ---- OCR ----------------------------------------------------------------
def _ocr_lang(): def _ocr_words(image_file):
try:
import pytesseract
return "fra" if "fra" in pytesseract.get_languages(config="") else None
except Exception:
return None
def _parse_ocr(image_file, annee, cats):
try: try:
import pytesseract import pytesseract
from pytesseract import Output from pytesseract import Output
from PIL import Image from PIL import Image
except Exception: except Exception:
return [], ["OCR indisponible (pytesseract / Pillow non installés)."] return None, ["OCR indisponible (pytesseract / Pillow non installés)."]
try: try:
img = Image.open(image_file) img = Image.open(image_file)
except Exception: except Exception:
return [], ["Image illisible."] return None, ["Image illisible."]
if img.width < 1000: if img.width < 1000:
ratio = 1400 / img.width r = 1400 / img.width
img = img.resize((int(img.width * ratio), int(img.height * ratio))) img = img.resize((int(img.width * r), int(img.height * r)))
kwargs = {"output_type": Output.DICT}
lang = _ocr_lang()
if lang:
kwargs["lang"] = lang
try: try:
data = pytesseract.image_to_data(img, **kwargs) lang = "fra" if "fra" in pytesseract.get_languages(config="") else None
kw = {"output_type": Output.DICT}
if lang:
kw["lang"] = lang
data = pytesseract.image_to_data(img, **kw)
except Exception as ex: except Exception as ex:
return [], [f"OCR impossible ({ex}). Vérifie l'installation de Tesseract, " return None, [f"OCR impossible ({ex}). Vérifie Tesseract, ou utilise le CSV."]
f"ou utilise l'import CSV."]
words = [] words = []
for i in range(len(data["text"])): for i in range(len(data["text"])):
t = data["text"][i].strip() t = data["text"][i].strip()
@@ -146,58 +145,99 @@ def _parse_ocr(image_file, annee, cats):
except (ValueError, TypeError): except (ValueError, TypeError):
conf = -1 conf = -1
if t and conf > 20: if t and conf > 20:
words.append((data["top"][i], data["left"][i], t)) words.append({"x": data["left"][i],
words.sort() "yc": data["top"][i] + data["height"][i] / 2, "t": t})
return words, []
visual_rows, cur, cur_top = [], [], None
for top, left, t in words: def _debug_lines(words):
if cur_top is None or abs(top - cur_top) <= 18: out, cur, top = [], [], None
cur.append((left, t)) for w in sorted(words, key=lambda w: (w["yc"], w["x"])):
cur_top = top if cur_top is None else cur_top if top is None or abs(w["yc"] - top) <= 14:
cur.append(w); top = w["yc"] if top is None else top
else: else:
visual_rows.append(cur); cur = [(left, t)]; cur_top = top out.append(" ".join(x["t"] for x in sorted(cur, key=lambda x: x["x"])))
cur = [w]; top = w["yc"]
if cur: if cur:
visual_rows.append(cur) out.append(" ".join(x["t"] for x in sorted(cur, key=lambda x: x["x"])))
return out
lignes = []
for n, row in enumerate(visual_rows, start=1): def _parse_liste(words, annee, cats):
toks = [t for _, t in sorted(row)] anchors = sorted([w for w in words if "%" not in w["t"] and AMOUNT_RE.match(w["t"])],
dt = None key=lambda w: w["yc"])
for i, t in enumerate(toks): lignes, last = [], None
if t.isdigit() and i + 1 < len(toks): for n, a in enumerate(anchors, start=1):
m = MOIS.get(toks[i + 1].lower().strip(".,")) band = sorted([w for w in words if abs(w["yc"] - a["yc"]) <= 22], key=lambda w: w["x"])
if m and 1 <= int(t) <= 31: day = mois = None
dt = date_cls(annee, m, int(t)); break
montant, amount_idx = None, None
for i in range(len(toks) - 1, -1, -1):
if "%" not in toks[i] and AMOUNT_RE.match(toks[i]):
montant = _dec(toks[i]); amount_idx = i; break
taux = Decimal("0") taux = Decimal("0")
for t in toks: regime = RegimeTva.NATIONALE
reste = []
for w in band:
t = w["t"]
if w["x"] < 150 and t.isdigit() and 1 <= int(t) <= 31:
day = int(t); continue
if w["x"] < 150 and t.lower().strip(".,") in MOIS:
mois = MOIS[t.lower().strip(".,")]; continue
if "%" in t: if "%" in t:
taux = _dec(t.replace("%", "")) taux = _dec(t.replace("%", "")); continue
if dt is None or montant is None or montant == 0: if t.upper() == "UE":
continue regime = RegimeTva.INTRACOM; continue
reste, skip = [], False if w is a or (t == a["t"] and w["x"] == a["x"]):
for i, t in enumerate(toks): continue
if i == amount_idx or "%" in t or t.upper() == "TVA": if t.upper() == "TVA":
continue continue
if t.isdigit() and i + 1 < len(toks) and toks[i + 1].lower().strip(".,") in MOIS:
skip = True; continue
if skip and t.lower().strip(".,") in MOIS:
skip = False; continue
reste.append(t) reste.append(t)
if day and mois:
dt = date_cls(annee, mois, day); last = dt
else:
dt = last
texte = " ".join(reste).strip() texte = " ".join(reste).strip()
cat = _match_cat(texte, cats) cat = _match_cat(texte, cats)
libelle = texte libelle = texte
if cat: if cat:
libelle = re.sub(re.escape(cat.nom), "", texte, flags=re.I).strip(" -·,") libelle = re.sub(re.escape(cat.nom), "", texte, flags=re.I).strip(" -·,")
lignes.append(_row(dt, libelle or texte or "(sans libellé)", montant, taux, lignes.append(_row(dt, libelle or texte or "(sans libellé)", _dec(a["t"]),
RegimeTva.NATIONALE, cat, n)) taux, regime, cat, n))
erreurs = [] if lignes else [ return lignes
"Aucune ligne exploitable détectée. Réessaie avec une capture plus nette, "
"ou utilise le CSV."]
return lignes, erreurs def _parse_detail(words, cats):
dtok = next((w for w in words if DATE_RE.match(w["t"])), None)
dt = _date(dtok["t"]) if dtok else None
amounts = [w for w in words if "%" not in w["t"] and AMOUNT_RE.match(w["t"])]
montant = _dec(amounts[0]["t"]) if amounts else Decimal("0")
y_ref = dtok["yc"] if dtok else 99999
haut_gauche = [w for w in words if w["x"] < 450 and w["yc"] < y_ref
and w["t"].lower() not in ("informations", "date", "libellé", "libelle")]
libelle = " ".join(w["t"] for w in sorted(haut_gauche, key=lambda w: (w["yc"], w["x"])))
libelle = libelle.strip(" '\"") or "(à compléter)"
droite = [w for w in words if w["x"] > 480 and not AMOUNT_RE.match(w["t"]) and "%" not in w["t"]
and w["t"].lower() not in ("ventilation(s)", "ventilation", "catégorie",
"categorie", "montant", "ttc", "tva")]
texte_cat = " ".join(w["t"] for w in sorted(droite, key=lambda w: (w["yc"], w["x"])))
cat = _match_cat(texte_cat, cats)
if montant == 0:
return []
return [_row(dt, libelle, montant, Decimal("0"), RegimeTva.NATIONALE, cat, 1)]
def _parse_ocr(image_file, annee, cats):
words, erreurs = _ocr_words(image_file)
if words is None:
return [], erreurs, []
debug = _debug_lines(words)
allt = " ".join(w["t"] for w in words).lower()
is_detail = ("ventilation" in allt) or ("informations" in allt and "montant" in allt)
if is_detail:
lignes = _parse_detail(words, cats); fmt = "détail"
else:
lignes = _parse_liste(words, annee, cats); fmt = "liste"
if not lignes:
erreurs.append(f"Format « {fmt} » détecté mais aucune ligne exploitable. "
f"Regarde le texte OCR ci-dessous et complète à la main, "
f"ou utilise le CSV.")
return lignes, erreurs, debug
# ---- Création ----------------------------------------------------------- # ---- Création -----------------------------------------------------------
@@ -224,7 +264,6 @@ def _creer(entreprise, dt, libelle, montant, taux, regime, categorie):
prix_unitaire_ht=ht, taux_tva=taux) prix_unitaire_ht=ht, taux_tva=taux)
f.recompute_totaux() f.recompute_totaux()
return "vente" return "vente"
# montant_tva est calculé automatiquement dans Depense.save() depuis le taux.
Depense.objects.create(entreprise=entreprise, date=dt, libelle=libelle, Depense.objects.create(entreprise=entreprise, date=dt, libelle=libelle,
categorie=categorie, regime_tva=regime, categorie=categorie, regime_tva=regime,
taux_tva=taux, montant_ttc=-montant) taux_tva=taux, montant_ttc=-montant)
@@ -272,18 +311,18 @@ def import_csv(request):
ctx["resultat"] = compteur ctx["resultat"] = compteur
return render(request, "compta/import_csv.html", ctx) return render(request, "compta/import_csv.html", ctx)
# Aperçu : CSV ou OCR
image = request.FILES.get("image") image = request.FILES.get("image")
if image: if image:
annee = int(request.POST.get("annee") or date_cls.today().year) annee = int(request.POST.get("annee") or date_cls.today().year)
lignes, erreurs = _parse_ocr(image, annee, cats) lignes, erreurs, debug = _parse_ocr(image, annee, cats)
ctx["source"] = "ocr" ctx["source"] = "ocr"
ctx["ocr_debug"] = debug
else: else:
texte = request.POST.get("texte", "") texte = request.POST.get("texte", "")
fichier = request.FILES.get("fichier") fichier = request.FILES.get("fichier")
if fichier: if fichier:
texte = fichier.read().decode("utf-8-sig", "ignore") texte = fichier.read().decode("utf-8-sig", "ignore")
lignes, erreurs = _parse_csv(texte, cats) lignes, erreurs, debug = _parse_csv(texte, cats)
ctx["texte"] = texte ctx["texte"] = texte
ctx["source"] = "csv" ctx["source"] = "csv"
ctx["lignes"] = lignes ctx["lignes"] = lignes