-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathextractor.py
More file actions
225 lines (202 loc) · 13.1 KB
/
extractor.py
File metadata and controls
225 lines (202 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
import os
import io
import json
import pdfplumber
from flask import Flask, request, jsonify
app = Flask(__name__)
# --- Fonction Principale d'Extraction ---
# !!! ATTENTION : Les paramètres de Bounding Box (bbox_offset_x, bbox_width, bbox_height) sont des estimations !!!
# !!! Ils devront être ajustés précisément pour chaque champ en fonction de la mise en page de VOTRE PDF !!!
def find_text_and_extract_value(page, label_text, bbox_offset_x=5, bbox_width=200, bbox_height=15, below_offset_y=0):
"""
Trouve un libellé sur la page et extrait le texte dans une zone relative.
Args:
page: Objet page de pdfplumber.
label_text: Le texte exact du libellé à rechercher.
bbox_offset_x: Décalage horizontal (depuis la fin du libellé) pour démarrer la zone de valeur.
bbox_width: Largeur de la zone de recherche de la valeur.
bbox_height: Hauteur de la zone de recherche de la valeur.
below_offset_y: Décalage vertical (depuis le bas du libellé) si la valeur est en dessous.
Returns:
Le texte extrait ou None si non trouvé.
"""
try:
# Recherche toutes les occurrences du libellé
# words = page.extract_words(keep_blank_chars=True) # Moins fiable pour les phrases
# Recherche basée sur le texte extrait, plus robuste pour les phrases/libellés
full_text_search = page.search(label_text, case_sensitive=False) # Recherche insensible à la casse
if not full_text_search:
# print(f"Libellé non trouvé: '{label_text}'")
return None
# Prend la première occurrence trouvée (ou la plus pertinente si logique ajoutée)
# Note: page.search renvoie un dict par occurrence trouvée
match = full_text_search[0]
label_x0, label_top, label_x1, label_bottom = match['x0'], match['top'], match['x1'], match['bottom']
# Définir la zone de recherche pour la valeur
# Si below_offset_y > 0, on cherche en dessous, sinon à droite
if below_offset_y > 0:
value_x0 = label_x0 # Commence à la même position x que le label
value_top = label_bottom + below_offset_y
value_x1 = label_x0 + bbox_width # Largeur définie
value_bottom = value_top + bbox_height
else:
# Recherche à droite
value_x0 = label_x1 + bbox_offset_x # Commence un peu après la fin du libellé
value_top = label_top # Sur la même ligne verticale
value_x1 = value_x0 + bbox_width
value_bottom = label_bottom # Même hauteur que le libellé (ou value_top + bbox_height)
# Créer la bounding box pour le crop
value_bbox = (value_x0, value_top, value_x1, value_bottom)
# Cropper la page pour isoler la zone de la valeur
cropped_page = page.crop(value_bbox)
# Extraire le texte de la zone croppée
extracted_text = cropped_page.extract_text(x_tolerance=2, y_tolerance=2) # Tolérances pour mieux joindre les mots
if extracted_text:
# Nettoyer le texte (supprimer espaces superflus, retours à la ligne)
cleaned_text = " ".join(extracted_text.split()).strip()
# print(f" -> Trouvé pour '{label_text}': '{cleaned_text}' dans bbox {value_bbox}")
return cleaned_text
else:
# print(f" -> Aucun texte trouvé pour '{label_text}' dans bbox {value_bbox}")
return None
except Exception as e:
print(f"Erreur lors de la recherche/extraction pour '{label_text}': {e}")
return None
# --- Mapping Libellés PDF vers Clés JSON ---
# !!! Ce mapping utilise les libellés exacts que vous avez fournis !!!
# !!! Les paramètres de bbox devront être ajustés pour chaque champ !!!
LABEL_MAPPING = {
# Clé JSON : (Libellé exact dans le PDF, offset_x, width, height, offset_y) # offset_y > 0 pour chercher en dessous
'entryDate' : ("Date d’entrée en relation", 5, 100, 15, 0),
'civility' : ("Civilité", 5, 100, 15, 0),
'fullName' : ("Nom / prénom :", 5, 250, 15, 0), # Zone plus large pour nom+prénom
'birthName' : ("Nom de naissance", 5, 200, 15, 0),
'birthDate' : ("Né(e) le", 5, 100, 15, 0),
'nationality' : ("Nationalité", 5, 150, 15, 0),
'protectionMeasure' : ("Mesure de protection", 5, 150, 15, 0),
'protectionDate' : ("Date de la mesure", 5, 100, 15, 0),
'address' : ("Adresse postale", 5, 300, 30, 2), # Cherche en dessous (offset_y=2) et plus large/haut
'fiscalResidenceCountry' : ("Pays de résidence fiscale", 5, 150, 15, 0), # Le premier ?
'housingStatus' : ("Logement", 5, 150, 15, 0),
'note' : ("Note", 5, 400, 50, 2), # Zone large et haute en dessous
'phoneHome' : ("Domicile", 5, 150, 15, 0), # Section Téléphone
'phoneMobile' : ("Mobile", 5, 150, 15, 0), # Section Téléphone
'phoneWork' : ("Bureau", 5, 150, 15, 0), # Section Téléphone
'email' : ("E-mail", 5, 250, 15, 0),
'maritalStatus' : ("Situation", 5, 150, 15, 0), # Section Situation familiale
'maritalEventDate' : ("Le", 5, 100, 15, 0), # Lequel ? Celui après Mariage/PACS/Divorce ? Préciser le libellé exact si possible
'maritalRegime' : ("Régime / Convention", 5, 200, 15, 0),
'maritalAdvantage' : ("Avantage matrimonial", 5, 200, 15, 0),
'lastSurvivorGift' : ("Donation au dernier vivant", 5, 100, 15, 0), # Section Dispositions...
'sharedGift' : ("Donation-partage", 5, 100, 15, 0),
'otherGifts' : ("Autres donations entre vifs", 5, 100, 15, 0),
'specificLegacies' : ("Legs particuliers", 5, 100, 15, 0),
'universalLegacies' : ("Legs universels", 5, 100, 15, 0),
'otherDispositions' : ("Autres dispositions", 5, 100, 15, 0),
'additionalDetails' : ("Précisions", 5, 300, 30, 2), # Lequel ? Celui après Dispositions ? Cherche en dessous
'dependents' : ("Personnes rattachées", 5, 300, 30, 2), # Cherche en dessous
'children' : ("Enfants", 5, 300, 30, 2), # Cherche en dessous
'socioProfessionalCategory': ("Catégorie socio-professionnelle", 5, 200, 15, 0),
'profession' : ("Profession", 5, 200, 15, 0),
'companyName' : ("Nom de la société", 5, 200, 15, 0),
'seniority' : ("Ancienneté", 5, 100, 15, 0), # Laquelle ? Celle après Nom société ?
'incomeSource' : ("Origine des revenus", 5, 200, 15, 0),
'jobDetails' : ("Précisions", 5, 300, 30, 2), # Lequel ? Celui après Situation pro ? Cherche en dessous
'regFiscalCountry' : ("Pays de résidence fiscale", 5, 150, 15, 0), # Le deuxième ? Section Réglementaire ?
'isUSPerson' : ("Citoyen américain ou résident fiscal américain", 5, 50, 15, 0), # Probablement Oui/Non
'tinCode' : ("Code TIN", 5, 150, 15, 0),
'nifCode' : ("Code NIF", 5, 150, 15, 0),
'isPPE' : ("Êtes-vous politiquement exposé(e)", 5, 50, 15, 0), # Probablement Oui/Non
'ppePerson' : ("Personne exposée", 5, 200, 15, 0),
'ppeReason' : ("Motif", 5, 200, 15, 0),
'ppeCountry' : ("Pays d’exercice du PPE", 5, 150, 15, 0),
'ppeSeniority' : ("Ancienneté", 5, 100, 15, 0), # Celle après PPE ?
'precautionSavings' : ("Épargne de précaution souhaitée", 5, 150, 15, 0),
'objectives' : ("Objectifs", 5, 400, 50, 2), # Zone large en dessous
'savingsTotal' : ("Épargne", 5, 150, 15, 0), # Lequel ? Total ?
'shortTermSavings' : ("Court terme", 5, 150, 15, 0),
'livretA' : ("Livret A", 5, 150, 15, 0),
'currentAccount' : ("Compte courant", 5, 150, 15, 0),
'longTermSavings' : ("Long terme", 5, 150, 15, 0),
'lifeInsurance' : ("Assurance-vie", 5, 150, 15, 0),
'assetsTotal' : ("Total des actifs", 5, 150, 15, 0),
'liabilitiesTotal' : ("Total des passifs", 5, 150, 15, 0),
'workIncome' : ("Revenus d’activité", 5, 150, 15, 0),
'salaries' : ("Salaires", 5, 150, 15, 0),
'totalIncome' : ("Total des revenus", 5, 150, 15, 0),
'totalExpenses' : ("Total des charges", 5, 150, 15, 0),
'debtRatio' : ("Taux d’endettement", 5, 100, 15, 0),
'taxYear' : ("IR acquitté en", 5, 100, 15, 0),
'grossSalary' : ("Total des salaires et assimilés", 5, 150, 15, 0),
'marginalTaxRate' : ("TMI IR", 5, 100, 15, 0),
'grossIncome' : ("Revenu brut global", 5, 150, 15, 0),
'taxableIncome' : ("Revenu imposable", 5, 150, 15, 0),
'referenceIncome' : ("Revenu fiscal de référence", 5, 150, 15, 0),
'baseTaxAmount' : ("Impôt soumis au barème", 5, 150, 15, 0),
'fiscalParts' : ("Nombre de parts fiscales", 5, 100, 15, 0),
'discount' : ("Décote", 5, 100, 15, 0),
'netTaxBeforeAdjustment' : ("Impôt net avant correction", 5, 150, 15, 0),
'unusedRetirementAllowance': ("Plafond épargne retraite non utilisé", 5, 150, 15, 0),
'taxReductions' : ("Réductions d’impôt", 5, 150, 15, 0),
'taxCredits' : ("Crédits d’impôt", 5, 150, 15, 0),
'carryForwardDeficit' : ("Déficit foncier reportable", 5, 150, 15, 0),
'netTaxAmount' : ("Total de l’impôt net", 5, 150, 15, 0),
'deductibleCSG' : ("CSG déductible", 5, 150, 15, 0),
'deductibleCharges' : ("Charges déductibles", 5, 150, 15, 0),
'socialContributions' : ("Prélèvements sociaux", 5, 150, 15, 0),
'builtProperties' : ("Immeubles bâtis", 5, 150, 15, 0), # Section IFI
'nonBuiltProperties' : ("Immeubles non bâtis", 5, 150, 15, 0),
'realEstateRights' : ("Droits immobiliers", 5, 150, 15, 0),
'deductibleDebts' : ("Passifs déductibles", 5, 150, 15, 0),
'ifiTaxBase' : ("Base imposable", 5, 150, 15, 0), # IFI
'ifiTaxRate' : ("TMI IFI", 5, 100, 15, 0),
'ifiReductions' : ("Réductions IFI", 5, 150, 15, 0),
'ifiToPay' : ("IFI net à payer", 5, 150, 15, 0),
}
@app.route('/extract', methods=['POST'])
def extract_pdf_data():
"""
Endpoint pour recevoir un PDF, extraire les données et renvoyer un JSON.
"""
if 'file' not in request.files and not request.data:
return jsonify({"error": "Aucun fichier PDF fourni"}), 400
try:
if 'file' in request.files:
pdf_file = request.files['file']
pdf_stream = io.BytesIO(pdf_file.read())
print(f"Fichier reçu via 'file': {pdf_file.filename}")
else:
# Accepte les données binaires brutes dans le corps de la requête
pdf_stream = io.BytesIO(request.data)
print("Fichier reçu via request body (data)")
extracted_data = {}
with pdfplumber.open(pdf_stream) as pdf:
# Traiter chaque page (ou juste la première si tout est sur une page)
# Pour l'instant, on suppose que tout est sur la première page (index 0)
if not pdf.pages:
return jsonify({"error": "Le PDF ne contient aucune page"}), 400
page = pdf.pages[0]
print(f"Traitement de la page 1 (dimensions: {page.width}x{page.height})")
# Itérer sur le mapping pour extraire chaque champ
for key, params in LABEL_MAPPING.items():
label, offset_x, width, height, offset_y = params
value = find_text_and_extract_value(page, label, offset_x, width, height, offset_y)
extracted_data[key] = value if value is not None else "" # Mettre chaîne vide si non trouvé
print("Extraction terminée.")
# print("Données extraites:", json.dumps(extracted_data, indent=2)) # Pour débogage
return jsonify(extracted_data), 200
except pdfplumber.pdfminer.pdfdocument.PDFEncryptionError:
print("Erreur: Le PDF est protégé par mot de passe.")
return jsonify({"error": "Le PDF est protégé par mot de passe"}), 400
except Exception as e:
print(f"Erreur générale lors du traitement du PDF: {e}")
# Tenter de donner une erreur plus spécifique si possible
if "not a PDF file" in str(e).lower():
return jsonify({"error": "Le fichier fourni n'est pas un PDF valide"}), 400
return jsonify({"error": f"Erreur interne du serveur lors de l'extraction: {e}"}), 500
if __name__ == '__main__':
# Récupérer le port depuis la variable d'environnement PORT, sinon 5001 par défaut
# Railway fournira la variable PORT
port = int(os.environ.get("PORT", 5001))
# Écouter sur 0.0.0.0 pour être accessible depuis l'extérieur du conteneur
app.run(host='0.0.0.0', port=port, debug=False) # Mettre debug=True pour le développement local si besoin