| import re |
| import json |
| import tempfile |
| from datetime import datetime |
| from typing import List, Dict, Any, Optional |
|
|
| import gradio as gr |
| import pdfplumber |
|
|
| DATE_PATTERNS = [ |
| (re.compile(r"\b(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})\b"), "dmy_num"), |
| (re.compile(r"\b([A-Za-z]{3})[/-](\d{1,2})[/-](\d{4})\b"), "mon_d_y"), |
| (re.compile(r"\b(\d{1,2})[/-]([A-Za-z]{3})[/-](\d{4})\b"), "d_mon_y"), |
| (re.compile(r"\b(\d{1,2})-([A-Za-z]{3})-(\d{2,4})\b"), "d_mon_y"), |
| ] |
|
|
| MONTHS = { |
| "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, |
| "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12, |
| } |
|
|
| SKIP_WORDS = [ |
| "opening balance", "page total", "closing balance", "statement of account", |
| "txn date", "value date", "narration", "particulars", "withdrawals", "deposits", |
| "balance", "account number", "branch name", "ifsc", "customer id", |
| ] |
|
|
|
|
| def clean_text(x: Any) -> str: |
| if x is None: |
| return "" |
| s = str(x).replace("\n", " ").replace("\r", " ") |
| s = re.sub(r"\s+", " ", s).strip() |
| return s |
|
|
|
|
| def fix_number_spaces(s: str) -> str: |
| s = clean_text(s) |
| |
| s = re.sub(r"(?<=\d)\s+(?=\d(?:\.\d+)?\b)", "", s) |
| return s |
|
|
|
|
| def amount_value(s: Any) -> str: |
| s = fix_number_spaces(str(s or "")) |
| s = re.sub(r"\b(Cr|Dr)\b", "", s, flags=re.I).strip() |
| m = re.search(r"-?\d[\d,]*\.?\d*", s) |
| if not m: |
| return "" |
| try: |
| return f"{float(m.group(0).replace(',', '')):.2f}" |
| except Exception: |
| return m.group(0).replace(',', '') |
|
|
|
|
| def balance_value(s: Any) -> str: |
| s = fix_number_spaces(str(s or "")) |
| sign = "" |
| msign = re.search(r"\b(Cr|Dr)\b", s, flags=re.I) |
| if msign: |
| sign = msign.group(1).title() |
| amt = amount_value(s) |
| return f"{amt} {sign}".strip() if amt else "" |
|
|
|
|
| def parse_date(text: str, default_year: Optional[int] = None) -> str: |
| text = clean_text(text) |
| for rx, kind in DATE_PATTERNS: |
| m = rx.search(text) |
| if not m: |
| continue |
| try: |
| if kind == "dmy_num": |
| d, mo, y = m.groups() |
| y = int(y) |
| if y < 100: |
| y += 2000 |
| return datetime(y, int(mo), int(d)).strftime("%Y-%m-%d") |
| if kind == "mon_d_y": |
| mo, d, y = m.groups() |
| return datetime(int(y), MONTHS[mo.lower()], int(d)).strftime("%Y-%m-%d") |
| if kind == "d_mon_y": |
| d, mo, y = m.groups() |
| y = int(y) |
| if y < 100: |
| y += 2000 |
| return datetime(y, MONTHS[mo.lower()], int(d)).strftime("%Y-%m-%d") |
| except Exception: |
| pass |
| return "" |
|
|
|
|
| def is_skip_row(text: str) -> bool: |
| t = text.lower() |
| return any(w in t for w in SKIP_WORDS) |
|
|
|
|
| def normalize_headers(row: List[str]) -> List[str]: |
| return [clean_text(c).lower().replace(" ", "_").replace("/", "_") for c in row] |
|
|
|
|
| def parse_known_table(headers: List[str], rows: List[List[str]]) -> List[Dict[str, str]]: |
| out = [] |
| h = headers |
|
|
| def idx(*names): |
| for n in names: |
| for i, col in enumerate(h): |
| if n in col: |
| return i |
| return -1 |
|
|
| date_i = idx("txn_date", "date") |
| narr_i = idx("narration", "particular", "description") |
| debit_i = idx("debit", "withdrawal") |
| credit_i = idx("credit", "deposit") |
| bal_i = idx("balance") |
|
|
| if date_i < 0 or narr_i < 0 or bal_i < 0 or (debit_i < 0 and credit_i < 0): |
| return [] |
|
|
| pending = None |
| for row in rows: |
| row = [clean_text(c) for c in row] |
| txt = " ".join(row) |
| if not txt or is_skip_row(txt): |
| continue |
| date = parse_date(row[date_i] if date_i < len(row) else "") |
| narr = row[narr_i] if narr_i < len(row) else "" |
| debit = amount_value(row[debit_i]) if debit_i >= 0 and debit_i < len(row) else "" |
| credit = amount_value(row[credit_i]) if credit_i >= 0 and credit_i < len(row) else "" |
| bal = balance_value(row[bal_i]) if bal_i < len(row) else "" |
|
|
| if date and (debit or credit): |
| if pending: |
| out.append(pending) |
| pending = { |
| "date": date, |
| "narration": narr, |
| "voucher_type": "Payment" if debit else "Receipt", |
| "amount": debit or credit, |
| "closing_balance": bal, |
| } |
| elif pending and narr: |
| pending["narration"] = clean_text(pending["narration"] + " " + narr) |
| if bal and not pending.get("closing_balance"): |
| pending["closing_balance"] = bal |
| if pending: |
| out.append(pending) |
| return out |
|
|
|
|
| def parse_text_lines(text: str) -> List[Dict[str, str]]: |
| out = [] |
| pending = None |
| lines = [clean_text(x) for x in text.splitlines() if clean_text(x)] |
| for line in lines: |
| low = line.lower() |
| if is_skip_row(line): |
| continue |
| date = parse_date(line) |
| nums = re.findall(r"(?:\d[\d,]*\.?\d*)\s*(?:Cr|Dr)?", fix_number_spaces(line), flags=re.I) |
| |
| if date and len(nums) >= 2: |
| if pending: |
| out.append(pending) |
| bal = balance_value(nums[-1]) |
| amt = amount_value(nums[-2]) |
| |
| before_bal = line[: line.rfind(nums[-1]) if nums[-1] in line else len(line)] |
| vtype = "Payment" if re.search(r"\bDr\b", before_bal, re.I) or " to " in low or low.startswith("to ") else "Receipt" |
| narration = line |
| for rx, _ in DATE_PATTERNS: |
| narration = rx.sub("", narration, count=1) |
| narration = clean_text(narration) |
| pending = { |
| "date": date, |
| "narration": narration, |
| "voucher_type": vtype, |
| "amount": amt, |
| "closing_balance": bal, |
| } |
| elif pending and not date: |
| pending["narration"] = clean_text(pending["narration"] + " " + line) |
| if pending: |
| out.append(pending) |
| return out |
|
|
|
|
| def extract_pdf(pdf_path: str, max_pages: int = 0) -> Dict[str, Any]: |
| transactions = [] |
| pages_done = 0 |
| debug = [] |
| with pdfplumber.open(pdf_path) as pdf: |
| pages = pdf.pages[:max_pages] if max_pages and max_pages > 0 else pdf.pages |
| for pno, page in enumerate(pages, start=1): |
| pages_done += 1 |
| page_txns = [] |
| try: |
| tables = page.extract_tables() or [] |
| for table in tables: |
| if not table or len(table) < 2: |
| continue |
| header_row_idx = None |
| for i, r in enumerate(table[:4]): |
| joined = " ".join(clean_text(c) for c in r).lower() |
| if ("date" in joined and ("debit" in joined or "withdraw" in joined) and ("credit" in joined or "deposit" in joined)): |
| header_row_idx = i |
| break |
| if header_row_idx is not None: |
| headers = normalize_headers(table[header_row_idx]) |
| rows = table[header_row_idx + 1:] |
| page_txns.extend(parse_known_table(headers, rows)) |
| except Exception as e: |
| debug.append(f"Page {pno} table error: {e}") |
|
|
| if not page_txns: |
| try: |
| text = page.extract_text(x_tolerance=1, y_tolerance=3) or "" |
| page_txns.extend(parse_text_lines(text)) |
| except Exception as e: |
| debug.append(f"Page {pno} text error: {e}") |
|
|
| transactions.extend(page_txns) |
|
|
| |
| final = [] |
| seen = set() |
| for t in transactions: |
| if not t.get("date") or not t.get("amount"): |
| continue |
| t["narration"] = clean_text(t.get("narration", "")) |
| key = (t["date"], t["narration"], t["amount"], t.get("closing_balance", "")) |
| if key not in seen: |
| seen.add(key) |
| final.append(t) |
|
|
| return { |
| "success": True, |
| "total_transactions": len(final), |
| "pages_processed": pages_done, |
| "transactions": final, |
| "debug": debug[:20], |
| } |
|
|
|
|
| def gradio_extract(file, max_pages): |
| if file is None: |
| return {"success": False, "error": "Upload PDF file"}, "" |
| result = extract_pdf(file.name, int(max_pages or 0)) |
| return result, json.dumps(result.get("transactions", []), indent=2, ensure_ascii=False) |
|
|
|
|
| def build_ui(): |
| with gr.Blocks(title="Free Bank Statement PDF to JSON") as demo: |
| gr.Markdown("# Free Bank Statement PDF → Transaction JSON\nFast test app for Hugging Face Spaces. Output fields: `date`, `narration`, `voucher_type`, `amount`, `closing_balance`.") |
| with gr.Row(): |
| pdf = gr.File(label="Upload Bank Statement PDF", file_types=[".pdf"]) |
| max_pages = gr.Number(label="Max pages for testing (0 = all)", value=0, precision=0) |
| btn = gr.Button("Extract Transactions", variant="primary") |
| output = gr.JSON(label="Full Response") |
| tx_json = gr.Code(label="Transactions JSON", language="json") |
| btn.click(gradio_extract, inputs=[pdf, max_pages], outputs=[output, tx_json]) |
| return demo |
|
|
| if __name__ == "__main__": |
| build_ui().queue(default_concurrency_limit=2).launch(server_name="0.0.0.0", server_port=7860) |
|
|