from __future__ import annotations from dataclasses import dataclass, asdict from pathlib import Path from typing import Optional import re, subprocess, tempfile import pandas as pd import pdfplumber @dataclass class StatementMeta: bank_name: str = "" customer_name: str = "" account_number: str = "" customer_id: str = "" ifsc: str = "" period_from: str = "" period_to: str = "" opening_balance: Optional[float] = None total_debit: Optional[float] = None total_credit: Optional[float] = None closing_balance: Optional[float] = None source_file: str = "" parser_name: str = "" confidence: str = "Medium" def to_dict(self): return asdict(self) STANDARD_COLUMNS = [ "transaction_date", "value_date", "narration", "reference_no", "debit", "credit", "balance", "bank_name", "customer_name", "account_number", "source_file", "source_page", "parser_name" ] def amount(v): if v is None: return None s=str(v).strip().replace('INR','').replace('Rs.','').replace('₹','').replace(',','').replace('+','') s=s.replace('CR','').replace('DR','').strip() if s in ('','-'): return None neg=s.startswith('-') s=s.lstrip('-') try: x=float(s) return -x if neg else x except: return None def norm(s): return re.sub(r'\s+',' ',str(s or '')).strip() def extract_text(path: str|Path) -> str: """Prefer pdftotext layout output; fall back to pdfplumber.""" path=str(path) try: p=subprocess.run(['pdftotext','-layout',path,'-'], capture_output=True, text=True, timeout=120) if p.returncode==0 and len(p.stdout.strip())>50: return p.stdout except Exception: pass parts=[] with pdfplumber.open(path) as pdf: for page in pdf.pages: parts.append(page.extract_text(x_tolerance=1,y_tolerance=3,layout=True) or '') return '\n\f\n'.join(parts) def page_of_line(text: str, position: int) -> int: return text[:position].count('\f')+1 def finalize(df: pd.DataFrame, meta: StatementMeta) -> pd.DataFrame: if df is None or df.empty: return pd.DataFrame(columns=STANDARD_COLUMNS) for c in ['debit','credit','balance']: df[c]=pd.to_numeric(df.get(c),errors='coerce') from .common import parse_flexible_date for c in ['transaction_date','value_date']: values = df.get(c) if values is None: df[c] = pd.NaT else: df[c] = values.map(parse_flexible_date) df['narration']=df.get('narration','').fillna('').map(norm) df['reference_no']=df.get('reference_no','').fillna('').map(norm) df['bank_name']=meta.bank_name df['customer_name']=meta.customer_name df['account_number']=meta.account_number df['source_file']=meta.source_file df['parser_name']=meta.parser_name if 'source_page' not in df: df['source_page']=None for c in STANDARD_COLUMNS: if c not in df: df[c]=None return df[STANDARD_COLUMNS] class BaseParser: bank_name='Unknown' parser_name='BaseParser' @classmethod def detect(cls,text:str)->float: return 0.0 def parse(self,path:str|Path,text:str|None=None): raise NotImplementedError