commit 63aeef3dd2505a673007003a1162e13c8da06896 Author: Syoul Date: Fri Aug 8 20:53:04 2025 +0200 initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a410c3d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Image Python légère +FROM python:3.11-slim + +# Installer Java (tabula-py utilise tabula-java) +RUN apt-get update && apt-get install -y --no-install-recommends \ + openjdk-17-jre-headless \ + && rm -rf /var/lib/apt/lists/* + +# (Optionnel) encodage propre côté JVM +ENV JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF-8" + +# Installer dépendances Python +RUN pip install --no-cache-dir tabula-py pandas + +# Copier le script dans l'image +WORKDIR /app +COPY convert.py /app/convert.py + +# Répertoire de travail contenant les PDF (sera monté en volume) +WORKDIR /data + +# Lancer le script sur /data +ENTRYPOINT ["python", "/app/convert.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..04b6008 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# 📄 pdf2csv + +Convertisseur de fichiers **PDF** en **CSV** basé sur [tabula-py](https://github.com/chezou/tabula-py) et [pandas](https://pandas.pydata.org/), empaqueté dans une image **Docker** légère. + +Il est conçu pour traiter automatiquement les relevés bancaires PDF (ou autres tableaux PDF similaires) en appliquant des opérations de nettoyage et de fusion avant de produire un fichier CSV unique. + +--- + +## 🚀 Fonctionnalités du script + +Le script `convert.py` : + +1. **Extraction** + - Utilise `tabula-py` pour extraire les tableaux depuis tous les fichiers PDF du dossier `/data`. + - Supporte les PDFs multi-pages. + +2. **Nettoyage des données** + - Supprime les **3 premières lignes** de chaque extrait, jusqu’à la ligne contenant `SOLDE`. + - Supprime **toute ligne dont la première colonne contient `date`** (ligne d’entête répétée). + - Supprime **la ligne contenant `Total des mouvements` ainsi que toutes celles qui suivent**. + +3. **Fusion des documents** + - Fusionne tous les CSV générés en **un seul fichier final**. + - Ajoute **en première ligne** du fichier final l’en-tête `date` (récupéré du premier tableau traité). + +4. **Formatage des valeurs** + - Supprime tous les **points `.`** dans les deux dernières colonnes (pour nettoyer les séparateurs de milliers dans les montants). + +5. **Export** + - Produit un **fichier CSV unique** propre, prêt à être utilisé dans un tableur ou un outil de gestion de budget. + +--- + +## 📦 Construction de l'image + +Clonez ce dépôt puis construisez l'image : + +```bash +git clone https://gitea.example.org/monuser/pdf2csv.git +cd pdf2csv +docker build -t pdf2csv:latest . diff --git a/convert.py b/convert.py new file mode 100644 index 0000000..585415c --- /dev/null +++ b/convert.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +import os +import csv +import pandas as pd +import tabula + +# Mots-clés utilisés pour repérer les zones à supprimer +MOT_DEBUT = "SOLDE" # Supprimer jusqu'à et y compris cette ligne +MOT_FIN = "Total des mouvements" # Supprimer cette ligne et toutes les suivantes +MOT_DATE = "date" # Lignes à ignorer si 1er champ contient ce mot + +# ------------------------------------------------------------------- +# Détecte automatiquement le séparateur d'un CSV à partir d'un échantillon +# ------------------------------------------------------------------- +def detect_delimiter(sample_text): + try: + dialect = csv.Sniffer().sniff(sample_text, delimiters=",;|\t") + return dialect.delimiter + except Exception: + # Fallback : choix heuristique FR (beaucoup de CSV utilisent ;) + return ";" if sample_text.count(";") >= sample_text.count(",") else "," + +# ------------------------------------------------------------------- +# Nettoie un CSV brut issu de Tabula selon les règles demandées +# ------------------------------------------------------------------- +def nettoyer_csv_texte(csv_in_path, csv_out_path): + # Lecture brute du fichier texte (pas encore DataFrame) + with open(csv_in_path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + + # 1️⃣ Supprimer les 3 premières lignes quoi qu'il arrive + lines = lines[3:] + + # 2️⃣ Chercher la première ligne contenant MOT_DEBUT (SOLDE) + idx_debut = None + for i, line in enumerate(lines): + if MOT_DEBUT.lower() in line.lower(): + idx_debut = i + break + if idx_debut is not None: + # Supprimer tout jusqu'à et y compris cette ligne + lines = lines[idx_debut + 1:] + + # 3️⃣ Supprimer à partir de la ligne contenant MOT_FIN (Total des mouvements) + idx_fin = None + for i, line in enumerate(lines): + if MOT_FIN.lower() in line.lower(): + idx_fin = i + break + if idx_fin is not None: + lines = lines[:idx_fin] + + # 4️⃣ Détection du séparateur sur un échantillon + sample = "".join(lines[:20]) + delim = detect_delimiter(sample) + + # 5️⃣ Lecture en mode tolérant avec csv.reader + reader = csv.reader(lines, delimiter=delim) + rows = [row for row in reader] + + # 6️⃣ Normaliser le nombre de colonnes (éviter erreurs si certaines lignes sont plus courtes) + max_cols = max(len(r) for r in rows) if rows else 0 + rows = [r + [""] * (max_cols - len(r)) for r in rows] + + # 7️⃣ Supprimer les lignes dont le premier champ contient "date" (sauf on garde une copie pour l'entête globale) + header_line = None + filtered_rows = [] + for r in rows: + first_col = (r[0] or "").strip().lower() + if MOT_DATE in first_col: + if header_line is None: + header_line = r[:] # garder pour l'entête globale + continue # ne pas inclure cette ligne dans ce fichier final + filtered_rows.append(r) + + # 8️⃣ Fusionner les lignes dont la première colonne est vide avec la précédente + merged = [] + for r in filtered_rows: + if (r[0] or "").strip() == "" and merged: + prev = merged[-1] + if len(prev) < len(r): + prev += [""] * (len(r) - len(prev)) + for i in range(len(r)): + if r[i].strip(): + prev[i] = (prev[i] + " " + r[i]).strip() if prev[i] else r[i].strip() + else: + merged.append(r) + + # 9️⃣ Nettoyer les deux dernières colonnes : supprimer les points dans les nombres + if merged: + for r in merged: + if len(r) >= 2: + # Dernière colonne + r[-1] = r[-1].replace(".", "") + if len(r) >= 3: + # Avant-dernière colonne + r[-2] = r[-2].replace(".", "") + + # 🔟 Sauvegarde du fichier nettoyé + with open(csv_out_path, "w", encoding="utf-8", newline="") as f: + writer = csv.writer(f, delimiter=delim) + writer.writerows(merged) + + return header_line # Retourne l'entête détectée pour usage ultérieur + +# ------------------------------------------------------------------- +# Convertit un PDF avec Tabula puis nettoie le CSV +# ------------------------------------------------------------------- +def convertir_et_nettoyer(pdf_path, out_dir="/data"): + base = os.path.splitext(os.path.basename(pdf_path))[0] + csv_brut = os.path.join(out_dir, f"{base}_brut.csv") + csv_final = os.path.join(out_dir, f"{base}_final.csv") + + # Conversion PDF -> CSV brut + tabula.convert_into(pdf_path, csv_brut, output_format="csv", pages="all", lattice=True) + print(f"[✓] Converti : {pdf_path}") + + # Nettoyage du CSV et récupération de l'entête éventuelle + header_line = nettoyer_csv_texte(csv_brut, csv_final) + print(f"[✓] Nettoyé : {csv_final}") + return csv_final, header_line + +# ------------------------------------------------------------------- +# Fusionne plusieurs CSV en un seul avec ajout de l'entête globale +# ------------------------------------------------------------------- +def fusionner_csv(liste_csv, fichier_sortie, header_line): + if not liste_csv: + print("Aucun CSV à fusionner.") + return + dfs = [] + for csv_file in liste_csv: + try: + df = pd.read_csv(csv_file, header=None) + dfs.append(df) + except Exception as e: + print(f"[!] Erreur lecture {csv_file} : {e}") + + if dfs: + final_df = pd.concat(dfs, ignore_index=True) + # Ajout de l'entête en première ligne + if header_line: + header_df = pd.DataFrame([header_line]) + final_df = pd.concat([header_df, final_df], ignore_index=True) + final_df.to_csv(fichier_sortie, index=False, header=False) + print(f"[✓] Fichier fusionné : {fichier_sortie}") + +# ------------------------------------------------------------------- +# Traitement complet : conversion, nettoyage, fusion +# ------------------------------------------------------------------- +def traitement_batch(workdir="/data"): + pdfs = sorted([f for f in os.listdir(workdir) if f.lower().endswith(".pdf")]) + if not pdfs: + print("Aucun PDF trouvé.") + return + + fichiers_finaux = [] + header_global = None + for pdf in pdfs: + final_csv, header_line = convertir_et_nettoyer(os.path.join(workdir, pdf), workdir) + fichiers_finaux.append(final_csv) + if header_line and header_global is None: + header_global = header_line # on garde le premier trouvé + + # Fusion de tous les fichiers nettoyés en un seul + fusionner_csv(fichiers_finaux, os.path.join(workdir, "fusion_total.csv"), header_global) + +# ------------------------------------------------------------------- +if __name__ == "__main__": + traitement_batch("/data")