Spaces:
Running
Running
| import gradio as gr | |
| import pdfplumber | |
| import pandas as pd | |
| import json | |
| import re | |
| import pathlib | |
| import numpy as np | |
| def classify_pdf(pdf_path: str) -> str: | |
| """ | |
| Peeks at the first page of the PDF to determine its origin. | |
| Returns 'MARG', 'TALLY', or 'UNKNOWN'. | |
| """ | |
| with pdfplumber.open(pdf_path) as pdf: | |
| if len(pdf.pages) == 0: | |
| return "UNKNOWN" | |
| first_page = pdf.pages[0] | |
| words = first_page.extract_words(keep_blank_chars=False) | |
| if not words: | |
| return "UNKNOWN" | |
| text_stream = " ".join([w["text"].lower() for w in words]) | |
| # --- 1. Check for Explicit Metadata/Branding --- | |
| if "marg erp" in text_stream or "margcompusoft" in text_stream: | |
| return "MARG" | |
| if "tally prime" in text_stream or "tally solutions" in text_stream: | |
| return "TALLY" | |
| # --- 2. Check Structural Vocabulary (Heuristics) --- | |
| marg_score = 0 | |
| tally_score = 0 | |
| # Marg Keywords (Strictly Marg-specific financial headers) | |
| # Removed "batch", "exp", and "hsn" as they are shared with Tally | |
| marg_keywords = ["s.no.", "mrp", "p.rate", "total stock", "op.stock", "cl.stock"] | |
| for kw in marg_keywords: | |
| if kw in text_stream: | |
| marg_score += 1 | |
| # Tally Keywords (Hierarchical accounting) | |
| # Added "godown" which is highly specific to Tally | |
| tally_keywords = ["particulars", "opening balance", "inwards", "outwards", "closing balance", "grand total", "godown"] | |
| for kw in tally_keywords: | |
| if kw in text_stream: | |
| tally_score += 1 | |
| # Look for Marg's distinct dashed lines | |
| dashed_lines = sum(1 for w in words if "----" in w["text"] or "====" in w["text"]) | |
| if dashed_lines > 3: | |
| marg_score += 2 | |
| # Check for explicit Tally report titles combined with Particulars | |
| if "stock summary" in text_stream and "particulars" in text_stream: | |
| tally_score += 2 | |
| # --- 3. Final Decision --- | |
| if marg_score > tally_score and marg_score >= 1: | |
| return "MARG" | |
| elif tally_score > marg_score and tally_score >= 1: | |
| return "TALLY" | |
| # Check for explicit drawn tables (Vanilla Grids) | |
| if len(first_page.find_tables()) > 0: | |
| return "GENERIC" | |
| return "UNKNOWN" | |
| def handle_parsing(file_obj): | |
| if file_obj is None: | |
| return None, {"error": "Please upload a document."} | |
| try: | |
| file_path = file_obj.name | |
| ext = pathlib.Path(file_path).suffix.lower() | |
| # ========================================== | |
| # ROUTE 1: PDF HANDLING | |
| # ========================================== | |
| if ext == ".pdf": | |
| file_type = classify_pdf(file_path) | |
| if file_type == "MARG": | |
| data = parse_universal_marg_pdf(file_path) | |
| elif file_type == "TALLY": | |
| data = parse_tally_pdf(file_path) | |
| elif file_type == "GENERIC": | |
| data = parse_generic_pdf(file_path) | |
| else: | |
| return None, {"error": "Format could not be identified as Marg, Tally, or a standard lined table."} | |
| state = {"format": file_type, "data": data} | |
| return state, data | |
| # ========================================== | |
| # ROUTE 2: SPREADSHEET HANDLING | |
| # ========================================== | |
| elif ext in [".csv", ".xlsx", ".xls"]: | |
| # Read the file blindly first to classify it | |
| if ext == ".csv": | |
| df = pd.read_csv(file_path, header=None) | |
| else: | |
| df = pd.read_excel(file_path, header=None) | |
| file_type = classify_dataframe(df) | |
| if file_type == "MARG": | |
| data = parse_marg_spreadsheet(df) | |
| state = {"format": "MARG", "data": data} | |
| return state, data | |
| elif file_type == "TALLY": | |
| # We will add parse_tally_spreadsheet() here next! | |
| return None, {"error": "Tally Spreadsheet parser coming soon!"} | |
| else: | |
| return None, {"error": "Spreadsheet format could not be identified as Marg or Tally."} | |
| else: | |
| return None, {"error": "Unsupported file type. Please upload a PDF, CSV, or Excel file."} | |
| except Exception as e: | |
| return None, {"error": f"Failed to parse file: {str(e)}"} | |
| def classify_dataframe(df: pd.DataFrame) -> str: | |
| """Scans the first 15 rows of a dataframe for Tally/Marg signatures.""" | |
| # Convert the top portion to a single lowercase string for easy keyword matching | |
| top_rows_text = df.head(15).to_string().lower() | |
| if "marg erp" in top_rows_text or "margcompusoft" in top_rows_text: | |
| return "MARG" | |
| if "tally prime" in top_rows_text or "tally solutions" in top_rows_text: | |
| return "TALLY" | |
| # Heuristics | |
| if "particulars" in top_rows_text and "godown" in top_rows_text: | |
| return "TALLY" | |
| if "s.no" in top_rows_text and "mrp" in top_rows_text and "p.rate" in top_rows_text: | |
| return "MARG" | |
| return "GENERIC" | |
| import pandas as pd | |
| import numpy as np | |
| def parse_marg_spreadsheet(df: pd.DataFrame) -> list: | |
| """ | |
| Parses a raw Marg Excel/CSV DataFrame into the Universal JSON Tree structure. | |
| """ | |
| # 1. Identify the actual table header row | |
| # Marg exports often have 3-5 rows of metadata at the top before the columns start. | |
| header_row_index = -1 | |
| target_headers = {'description', 'particulars', 'item', 'product', 'qty', 'rate', 'mrp'} | |
| # Scan the first 20 rows to find where the actual table begins | |
| for idx, row in df.head(20).iterrows(): | |
| # Convert row values to lowercase strings to check against our targets | |
| row_str_set = set(str(val).lower().strip() for val in row.values if pd.notna(val)) | |
| # If we find at least 2 common Marg headers, this is our header row | |
| if len(target_headers.intersection(row_str_set)) >= 2: | |
| header_row_index = idx | |
| break | |
| if header_row_index == -1: | |
| return [] # Could not find valid table headers | |
| # 2. Reshape the DataFrame | |
| # Set the found row as the column headers and drop everything above it | |
| df.columns = df.iloc[header_row_index].astype(str).str.strip() | |
| df = df.iloc[header_row_index + 1:].reset_index(drop=True) | |
| # Drop completely empty columns and "Unnamed" columns | |
| df = df.loc[:, df.columns.notna() & (df.columns != '') & (~df.columns.str.contains('Unnamed', case=False, na=False))] | |
| # Identify the primary description column | |
| desc_col = next((col for col in df.columns if col.lower() in ['description', 'particulars', 'item', 'product']), None) | |
| if not desc_col: | |
| return [] | |
| # 3. Parse Data into the JSON Tree Structure | |
| data_tree = [] | |
| current_block_name = "Default View" | |
| current_items = [] | |
| for _, row in df.iterrows(): | |
| desc_val = row[desc_col] | |
| # Skip completely empty rows | |
| if pd.isna(desc_val) or str(desc_val).strip() == '': | |
| continue | |
| desc_str = str(desc_val).strip() | |
| desc_lower = desc_str.lower() | |
| # Skip summary lines (Totals) | |
| if "total" in desc_lower or "page" in desc_lower or "grand" in desc_lower: | |
| continue | |
| # Extract row data and check for financials | |
| has_financials = False | |
| row_data = {} | |
| for col in df.columns: | |
| if col != desc_col: | |
| val = row[col] | |
| # If the cell is not empty/NaN | |
| if pd.notna(val) and str(val).strip() != '': | |
| row_data[col] = str(val).strip() | |
| # If any secondary column contains a digit, we consider it a financial/item row | |
| if any(char.isdigit() for char in str(val)): | |
| has_financials = True | |
| # 4. Routing Logic: Is it a Group Header or an Item? | |
| # In Marg, if a row has a Description but NO numbers in other columns, it's a Group Header | |
| is_block_header = (not has_financials and len(row_data) == 0) or (not has_financials and desc_str.isupper()) | |
| if is_block_header: | |
| # Commit the previous block if it has items | |
| if current_items: | |
| data_tree.append({ | |
| "name": current_block_name, | |
| "type": "view", | |
| "items": current_items | |
| }) | |
| current_items = [] # Reset for next block | |
| # Start a new block, cleaning up Marg's "=" or "-" formatting | |
| current_block_name = desc_str.replace("=", "").replace("-", "").strip() | |
| else: | |
| # It's an item row, add it to the current block | |
| current_items.append({ | |
| "particulars": desc_str, | |
| "data": row_data | |
| }) | |
| # Append the final block after the loop finishes | |
| if current_items: | |
| data_tree.append({ | |
| "name": current_block_name, | |
| "type": "view", | |
| "items": current_items | |
| }) | |
| return data_tree | |
| def midpoint(w: dict) -> float: | |
| return (w["x0"] + w["x1"]) / 2.0 | |
| # ========================================== | |
| # 2. TALLY PARSER ENGINE | |
| # ========================================== | |
| KEYWORDS = ("HSN/SAC:", "Batch:", "Godown:") | |
| TALLY_STOP_WORDS = ("Grand Total", "Closing Stock", "Opening Stock") | |
| NUM_RE = re.compile(r"^-?[\d,]+(\.\d+)?$") | |
| VALID_HEADERS = {"quantity", "qty", "rate", "value", "amount"} | |
| SUPER_HEADERS = {"opening", "inwards", "outwards", "closing"} | |
| CLUSTER_TOL = 3.0 | |
| def is_number(t: str) -> bool: | |
| return bool(NUM_RE.match(t.replace(" ", ""))) | |
| def line_text(words: list) -> str: | |
| return " ".join(w["text"] for w in words) | |
| def has_financial(words: list, dividers: list) -> bool: | |
| if not dividers: return False | |
| for w in words: | |
| if midpoint(w) >= dividers[0]: | |
| if is_number(w["text"]): | |
| return True | |
| return False | |
| def is_keyword_line(words: list) -> bool: | |
| t = line_text(words) | |
| return any(k in t for k in KEYWORDS) | |
| def tally_is_stop_line(words: list) -> bool: | |
| t = line_text(words) | |
| return any(s in t for s in TALLY_STOP_WORDS) | |
| def build_indent_map(blocks: list) -> dict: | |
| raw_x0s = [b["x0"] for b in blocks] | |
| if not raw_x0s: | |
| return {} | |
| unique = sorted(set(raw_x0s)) | |
| clusters = [] | |
| for x in unique: | |
| if clusters and x - clusters[-1] <= CLUSTER_TOL: | |
| continue | |
| clusters.append(x) | |
| indent_map = {} | |
| for x in raw_x0s: | |
| level = min(range(len(clusters)), key=lambda i: abs(clusters[i] - x)) | |
| indent_map[x] = level | |
| return indent_map | |
| def detect_columns(words: list, header_top: float): | |
| anchor_words = [w for w in words if abs(w["top"] - header_top) < 15 and w["text"].lower() in VALID_HEADERS] | |
| anchor_words.sort(key=lambda w: midpoint(w)) | |
| super_words = [w for w in words if header_top - 25 < w["top"] < header_top + 5 and w["text"].lower() in SUPER_HEADERS] | |
| # super_words = [w for w in words if header_top - 25 < w["top"] < header_top - 2 and w["text"].lower() not in VALID_HEADERS and w["text"].lower() != "particulars"] | |
| super_words.sort(key=lambda w: w["x0"]) | |
| dynamic_cols = [] | |
| for aw in anchor_words: | |
| prefix = "" | |
| for i, sw in enumerate(super_words): | |
| if midpoint(aw) >= sw["x0"] - 20: | |
| if i + 1 < len(super_words): | |
| if midpoint(aw) < super_words[i+1]["x0"] - 20: | |
| prefix = sw["text"] + " " | |
| break | |
| else: | |
| prefix = sw["text"] + " " | |
| break | |
| label = (prefix + aw["text"]).strip().title() | |
| dynamic_cols.append({"label": label, "center": midpoint(aw)}) | |
| if not dynamic_cols: | |
| dynamic_cols = [ | |
| {"label": "Quantity", "center": 300}, | |
| {"label": "Rate", "center": 380}, | |
| {"label": "Value", "center": 460} | |
| ] | |
| dividers = [dynamic_cols[0]["center"] - 30] | |
| for i in range(len(dynamic_cols) - 1): | |
| dividers.append((dynamic_cols[i]["center"] + dynamic_cols[i+1]["center"]) / 2.0) | |
| return dynamic_cols, dividers | |
| def parse_tally_pdf(pdf_path: str) -> list: | |
| full_tree = [] | |
| with pdfplumber.open(pdf_path) as pdf: | |
| for page in pdf.pages: | |
| words = page.extract_words(keep_blank_chars=False) | |
| if not words: continue | |
| header_keywords = {"quantity", "qty", "rate", "value", "amount", "particulars"} | |
| header_candidates = [w["top"] for w in words if w["text"].lower() in header_keywords] | |
| header_top = min(header_candidates) if header_candidates else 80.0 | |
| columns, dividers = detect_columns(words, header_top) | |
| lines = {} | |
| for w in words: | |
| if w["top"] < header_top + 2: continue | |
| y = round(w["top"] / 3) * 3 | |
| lines.setdefault(y, []).append(w) | |
| blocks = [] | |
| current = None | |
| def commit(blk): | |
| if blk: blocks.append(blk) | |
| for y in sorted(lines.keys()): | |
| lw = sorted(lines[y], key=lambda w: w["x0"]) | |
| if not lw: continue | |
| if tally_is_stop_line(lw): | |
| commit(current) | |
| current = None | |
| break | |
| fin = has_financial(lw, dividers) | |
| kw = is_keyword_line(lw) | |
| first_x0 = lw[0]["x0"] | |
| p = [] | |
| col_bins = [[] for _ in columns] | |
| for w in lw: | |
| cx = midpoint(w) | |
| if cx < dividers[0]: | |
| p.append(w["text"]) | |
| else: | |
| placed = False | |
| for i in range(len(dividers) - 1): | |
| if cx < dividers[i+1]: | |
| col_bins[i].append(w["text"]) | |
| placed = True | |
| break | |
| if not placed: | |
| col_bins[-1].append(w["text"]) | |
| if fin or kw: | |
| commit(current) | |
| current = {"x0": first_x0, "particulars": p, "financials": col_bins} | |
| else: | |
| current_is_anchor = ( | |
| current is not None and | |
| (any(len(bin) > 0 for bin in current["financials"]) | |
| or is_keyword_line([{"text": t} for t in current["particulars"]])) | |
| ) | |
| same_indent = (current is not None and abs(first_x0 - current["x0"]) <= CLUSTER_TOL) | |
| if current_is_anchor and same_indent: | |
| current["particulars"].extend([w["text"] for w in lw]) | |
| else: | |
| commit(current) | |
| current = {"x0": first_x0, "particulars": p, "financials": [[] for _ in columns]} | |
| commit(current) | |
| if not blocks: continue | |
| indent_map = build_indent_map(blocks) | |
| page_nodes = [] | |
| hierarchy = [] | |
| for blk in blocks: | |
| name = " ".join(blk["particulars"]).strip() | |
| if not name: continue | |
| level = indent_map.get(blk["x0"], 0) | |
| node = {"name": name, "level": level, "type": "group", "children": []} | |
| stock_data = {} | |
| has_stock = False | |
| for i, col in enumerate(columns): | |
| val = " ".join(blk["financials"][i]).strip() | |
| if val: | |
| stock_data[col["label"]] = val | |
| has_stock = True | |
| if has_stock: node["stock"] = stock_data | |
| if "HSN/SAC:" in name: node["type"] = "hsn_detail" | |
| elif name.startswith("Batch:"): node["type"] = "batch_detail" | |
| elif name.startswith("Godown:"): node["type"] = "godown_detail" | |
| elif has_stock: node["type"] = "item" | |
| while len(hierarchy) > level: hierarchy.pop() | |
| if not hierarchy: | |
| page_nodes.append(node) | |
| else: | |
| hierarchy[-1].setdefault("children", []).append(node) | |
| if node["type"] in ("group", "item"): | |
| hierarchy.append(node) | |
| full_tree.extend(page_nodes) | |
| return full_tree | |
| # ========================================== | |
| # 3. MARG PARSER ENGINE | |
| # ========================================== | |
| MARG_HEADERS = { | |
| "s.no.", "s.no", "description", "product", "item", "particulars", | |
| "packing", "pack", "batch", "btch", "exp", "expiry", | |
| "hsn", "sac", "sku", "code", "unit", "gst%", "tax", | |
| "op.stock", "opening", "purchase", "receipt", | |
| "sale", "outwards", "cl.stock", "closing", "total", "stock", | |
| "qty", "free", "rate", "p.rate", "mrp", "value", "amount" | |
| } | |
| def marg_is_stop_line(words: list) -> bool: | |
| text = " ".join(w["text"] for w in words).lower() | |
| return "grand total" in text or "page total" in text or text.startswith("total") | |
| def parse_universal_marg_pdf(pdf_path: str) -> list: | |
| data_tree = [] | |
| with pdfplumber.open(pdf_path) as pdf: | |
| for page_num, page in enumerate(pdf.pages): | |
| words = page.extract_words(keep_blank_chars=False) | |
| if not words: continue | |
| y_buckets = {} | |
| for w in words: | |
| y_b = round(w["top"] / 4) * 4 | |
| if w["text"].lower() in MARG_HEADERS: | |
| y_buckets.setdefault(y_b, []).append(w) | |
| if not y_buckets: continue | |
| best_y = max(y_buckets.keys(), key=lambda y: len(y_buckets[y])) | |
| if len(y_buckets[best_y]) < 2: continue | |
| header_top = min([w["top"] for w in y_buckets[best_y]]) | |
| anchor_words = [w for w in words if abs(w["top"] - header_top) < 15 and w["text"].lower() in MARG_HEADERS] | |
| anchor_words.sort(key=lambda w: midpoint(w)) | |
| columns = [] | |
| current_label = anchor_words[0] | |
| for w in anchor_words[1:]: | |
| if w["x0"] - current_label["x1"] < 15: | |
| current_label["text"] += " " + w["text"] | |
| current_label["x1"] = w["x1"] | |
| else: | |
| columns.append({"label": current_label["text"].title(), "center": midpoint(current_label), "x0": current_label["x0"]}) | |
| current_label = w | |
| columns.append({"label": current_label["text"].title(), "center": midpoint(current_label), "x0": current_label["x0"]}) | |
| desc_index = 0 | |
| for i, col in enumerate(columns): | |
| if col["label"].lower() in {"description", "product", "item", "particulars"}: | |
| desc_index = i | |
| break | |
| dividers = [] | |
| for i in range(desc_index): dividers.append((columns[i]["center"] + columns[i+1]["center"]) / 2.0) | |
| if desc_index + 1 < len(columns): dividers.append(columns[desc_index + 1]["x0"] - 15) | |
| for i in range(desc_index + 1, len(columns) - 1): dividers.append((columns[i]["center"] + columns[i+1]["center"]) / 2.0) | |
| if not dividers: dividers = [9999] | |
| lines = {} | |
| for w in words: | |
| if w["top"] < header_top + 10: continue | |
| if "-" * 5 in w["text"] or "=" * 5 in w["text"]: | |
| if len(w["text"].replace("=", "").replace("-", "").strip()) == 0: continue | |
| y = round(w["top"] / 3) * 3 | |
| lines.setdefault(y, []).append(w) | |
| current_block_name = f"Report View {page_num + 1}" | |
| current_item = None | |
| for y in sorted(lines.keys()): | |
| lw = sorted(lines[y], key=lambda w: w["x0"]) | |
| if not lw: continue | |
| if marg_is_stop_line(lw): break | |
| main_divider = dividers[desc_index] if len(dividers) > desc_index else dividers[0] | |
| fin_words = [w["text"] for w in lw if midpoint(w) >= main_divider] | |
| has_grid_data = any(any(char.isdigit() or char == '-' for char in text) for text in fin_words) | |
| col_bins = [[] for _ in columns] | |
| for w in lw: | |
| cx = midpoint(w) | |
| placed = False | |
| for i in range(len(dividers)): | |
| if cx < dividers[i]: | |
| col_bins[i].append(w["text"]) | |
| placed = True | |
| break | |
| if not placed: | |
| col_bins[-1].append(w["text"]) | |
| text_str = " ".join(col_bins[desc_index]).strip() | |
| pre_desc_text = "" | |
| for i in range(desc_index): pre_desc_text += " ".join(col_bins[i]).strip() | |
| is_block_header = text_str.isupper() and ("=" in text_str or "LIMITED" in text_str or "PHARMA" in text_str) | |
| if is_block_header: | |
| if current_item: | |
| block_node = next((n for n in data_tree if n["name"] == current_block_name), None) | |
| if not block_node: | |
| block_node = {"name": current_block_name, "type": "view", "items": []} | |
| data_tree.append(block_node) | |
| block_node["items"].append(current_item) | |
| current_item = None | |
| current_block_name = text_str.replace("=", "").replace("-", "").strip() | |
| continue | |
| grid_dict = {} | |
| for i, col in enumerate(columns): | |
| if i == desc_index: continue | |
| val = " ".join(col_bins[i]).strip() | |
| if val: grid_dict[col["label"]] = val | |
| starts_new_item = False | |
| if pre_desc_text: starts_new_item = True | |
| elif has_grid_data and current_item and any(k in current_item["data"] for k in grid_dict.keys() if k != "Unit"): | |
| starts_new_item = True | |
| elif not current_item and (text_str or has_grid_data): | |
| starts_new_item = True | |
| if starts_new_item: | |
| if current_item: | |
| block_node = next((n for n in data_tree if n["name"] == current_block_name), None) | |
| if not block_node: | |
| block_node = {"name": current_block_name, "type": "view", "items": []} | |
| data_tree.append(block_node) | |
| block_node["items"].append(current_item) | |
| current_item = {"particulars": text_str, "data": grid_dict} | |
| else: | |
| if current_item: | |
| if text_str: | |
| current_item["particulars"] = (current_item["particulars"] + " " + text_str) if current_item["particulars"] else text_str | |
| if grid_dict: current_item["data"].update(grid_dict) | |
| if current_item: | |
| block_node = next((n for n in data_tree if n["name"] == current_block_name), None) | |
| if not block_node: | |
| block_node = {"name": current_block_name, "type": "view", "items": []} | |
| data_tree.append(block_node) | |
| block_node["items"].append(current_item) | |
| return data_tree | |
| # ========================================== | |
| # NEW: GENERIC LINED TABLE PARSER | |
| # ========================================== | |
| def parse_generic_pdf(pdf_path: str) -> list: | |
| """Extracts standard tables with explicit drawn borders.""" | |
| data_tree = [] | |
| with pdfplumber.open(pdf_path) as pdf: | |
| for page_num, page in enumerate(pdf.pages): | |
| # Native extraction using drawn lines | |
| tables = page.extract_tables() | |
| for table_idx, table in enumerate(tables): | |
| if not table or len(table) < 2: | |
| continue | |
| # Clean up None types and newlines | |
| cleaned_table = [ | |
| [str(cell).replace('\n', ' ').strip() if cell else "" for cell in row] | |
| for row in table | |
| ] | |
| headers = cleaned_table[0] | |
| items = [] | |
| # Build the JSON structure to match our standard format | |
| for row in cleaned_table[1:]: | |
| row_data = {} | |
| for col_idx, cell in enumerate(row): | |
| header_name = headers[col_idx] if col_idx < len(headers) and headers[col_idx] else f"Column_{col_idx+1}" | |
| row_data[header_name] = cell | |
| items.append({ | |
| "particulars": row[0] if row else "", | |
| "data": row_data | |
| }) | |
| data_tree.append({ | |
| "name": f"Page {page_num + 1} - Lined Table {table_idx + 1}", | |
| "type": "generic_table", | |
| "items": items, | |
| "raw_table": cleaned_table # We pass this for easy Pandas rendering | |
| }) | |
| return data_tree | |
| # ========================================== | |
| # 4. GRADIO APP & UI LOGIC | |
| # ========================================== | |
| # def handle_parsing(pdf_file): | |
| # if pdf_file is None: | |
| # return None, {"error": "Please upload a PDF document."} | |
| # try: | |
| # # 1. Route the document | |
| # file_type = classify_pdf(pdf_file.name) | |
| # # 2. Extract based on format | |
| # if file_type == "MARG": | |
| # data = parse_universal_marg_pdf(pdf_file.name) | |
| # state = {"format": "MARG", "data": data} | |
| # elif file_type == "TALLY": | |
| # data = parse_tally_pdf(pdf_file.name) | |
| # state = {"format": "TALLY", "data": data} | |
| # elif file_type == "GENERIC": | |
| # data = parse_generic_pdf(pdf_file.name) | |
| # state = {"format": "GENERIC", "data": data} | |
| # else: | |
| # return None, {"error": "Format could not be identified as Marg, Tally, or a standard lined table."} | |
| # return state, data | |
| # except Exception as e: | |
| # return None, {"error": f"Failed to parse PDF: {str(e)}"} | |
| # Recursive flattener for displaying Tally Hierarchies in a DataFrame | |
| def flatten_tally_tree(node, rows_list, all_keys): | |
| # Use Unicode non-breaking spaces (\u00A0) so the web UI doesn't collapse them | |
| indent_spacing = "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0" # 6 spaces per level | |
| indent = indent_spacing * node.get("level", 0) | |
| prefix = "β³ " if node.get("level", 0) > 0 else "" | |
| name = indent + prefix + node.get("name", "") | |
| row_dict = {"Particulars": name} | |
| stock = node.get("stock", {}) | |
| for k, v in stock.items(): | |
| row_dict[k] = v | |
| if k not in all_keys: | |
| all_keys.append(k) | |
| rows_list.append(row_dict) | |
| for child in node.get("children", []): | |
| flatten_tally_tree(child, rows_list, all_keys) | |
| # UI Layout | |
| custom_theme = gr.themes.Soft(primary_hue="blue") | |
| with gr.Blocks(title="Universal Accounting Parser", theme=custom_theme) as app: | |
| parsed_data_state = gr.State(None) | |
| gr.Markdown( | |
| """ | |
| # π Universal Tally & Marg Extractor | |
| Upload a stock report from either **Tally ERP** or **Marg ERP**. | |
| Our router will detect the source system automatically, you can download the json. | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| # pdf_input = gr.File(label="Upload PDF Report", file_types=[".pdf"]) | |
| # Change this in your UI block: | |
| pdf_input = gr.File(label="Upload Report", file_types=[".pdf", ".csv", ".xlsx", ".xls"]) | |
| parse_button = gr.Button("Extract Tables", variant="primary") | |
| # --- ADD THIS EXAMPLES BLOCK --- | |
| gr.Examples( | |
| examples=[ | |
| ["examples/extreme_stress_test.pdf"], # Replace with your exact filename | |
| ["examples/marg_enterprise_simulation (1).pdf"], # Replace with your exact filename | |
| ["examples/tally_stress_test.pdf"] # Replace with your exact filename | |
| ], | |
| inputs=pdf_input, | |
| label="Or try one of these examples:" | |
| ) | |
| # ------------------------------- | |
| with gr.Column(scale=3): | |
| with gr.Tabs(): | |
| # TAB 1: DYNAMIC UI | |
| with gr.Tab("Visual Tables (UI)"): | |
| def render_dynamic_tables(state_dict): | |
| if not state_dict: | |
| gr.Markdown("*(Upload a document to generate tables)*") | |
| return | |
| doc_format = state_dict.get("format") | |
| data_tree = state_dict.get("data", []) | |
| gr.Markdown(f"### π Detected System: **{doc_format}**") | |
| gr.HTML("<hr/>") | |
| if doc_format == "MARG": | |
| for block in data_tree: | |
| gr.Markdown(f"#### π {block.get('name', 'Report View')}") | |
| if not block.get('items'): continue | |
| all_data_keys = [] | |
| for item in block['items']: | |
| for k in item.get('data', {}).keys(): | |
| if k not in all_data_keys: all_data_keys.append(k) | |
| columns = [] | |
| if "S.No." in all_data_keys: | |
| columns.append("S.No.") | |
| all_data_keys.remove("S.No.") | |
| columns.append("Description / Particulars") | |
| columns.extend(all_data_keys) | |
| rows = [] | |
| for item in block['items']: | |
| row = [] | |
| if "S.No." in columns: row.append(item.get('data', {}).get("S.No.", "")) | |
| row.append(item.get('particulars', '')) | |
| for k in all_data_keys: row.append(item.get('data', {}).get(k, "")) | |
| rows.append(row) | |
| df = pd.DataFrame(rows, columns=columns) | |
| gr.Dataframe(value=df, interactive=False, label="") | |
| gr.HTML("<br/>") | |
| elif doc_format == "GENERIC": | |
| for block in data_tree: | |
| gr.Markdown(f"#### π {block.get('name')}") | |
| raw_table = block.get('raw_table', []) | |
| if len(raw_table) > 1: | |
| # Create DataFrame directly from the raw extracted table | |
| df = pd.DataFrame(raw_table[1:], columns=raw_table[0]) | |
| gr.Dataframe(value=df, interactive=False, label="") | |
| gr.HTML("<br/>") | |
| elif doc_format == "TALLY": | |
| all_data_keys = [] | |
| rows_dict_list = [] | |
| # Flatten the recursive Tally tree | |
| for node in data_tree: | |
| flatten_tally_tree(node, rows_dict_list, all_data_keys) | |
| if not rows_dict_list: | |
| gr.Markdown("*No items found.*") | |
| return # <--- CHANGED FROM continue TO return | |
| # Detect if this is a 12-column (Super Header) or 4-column layout | |
| super_categories = ["Opening", "Inwards", "Outwards", "Closing"] | |
| has_super_headers = any(any(k.startswith(sup) for k in all_data_keys) for sup in super_categories) | |
| # Use Gradio CSS variables so it looks perfect in both Light and Dark mode | |
| html = "<div style='overflow-x: auto;'><table style='width: 100%; border-collapse: collapse; font-family: sans-serif; font-size: 14px; text-align: left; border: 1px solid var(--border-color-primary);'>" | |
| html += "<thead>" | |
| if has_super_headers: | |
| # Row 1: Super Headers (Colspan) | |
| html += "<tr style='background-color: var(--background-fill-secondary);'>" | |
| html += "<th rowspan='2' style='padding: 12px; border: 1px solid var(--border-color-primary); min-width: 300px;'>Particulars</th>" | |
| for sup in super_categories: | |
| sub_cols = [k for k in all_data_keys if k.startswith(sup)] | |
| if sub_cols: | |
| html += f"<th colspan='{len(sub_cols)}' style='padding: 8px; border: 1px solid var(--border-color-primary); text-align: center; font-weight: bold;'>{sup} Balance</th>" | |
| html += "</tr>" | |
| # Row 2: Sub Headers | |
| html += "<tr style='background-color: var(--background-fill-secondary);'>" | |
| for sup in super_categories: | |
| sub_cols = [k for k in all_data_keys if k.startswith(sup)] | |
| for k in sub_cols: | |
| sub_name = k.replace(sup + " ", "") | |
| html += f"<th style='padding: 8px; border: 1px solid var(--border-color-primary);'>{sub_name}</th>" | |
| html += "</tr>" | |
| else: | |
| # Standard Flat Headers (for 4-column reports) | |
| html += "<tr style='background-color: var(--background-fill-secondary);'>" | |
| html += "<th style='padding: 12px; border: 1px solid var(--border-color-primary);'>Particulars</th>" | |
| for k in all_data_keys: | |
| html += f"<th style='padding: 8px; border: 1px solid var(--border-color-primary);'>{k}</th>" | |
| html += "</tr>" | |
| html += "</thead><tbody>" | |
| # Populate Data Rows with Indentations | |
| for row in rows_dict_list: | |
| html += "<tr style='border-bottom: 1px solid var(--border-color-primary);'>" | |
| html += f"<td style='padding: 8px; border-right: 1px solid var(--border-color-primary); white-space: pre;'>{row.get('Particulars', '')}</td>" | |
| if has_super_headers: | |
| for sup in super_categories: | |
| sub_cols = [k for k in all_data_keys if k.startswith(sup)] | |
| for k in sub_cols: | |
| html += f"<td style='padding: 8px; border-right: 1px solid var(--border-color-primary);'>{row.get(k, '')}</td>" | |
| else: | |
| for k in all_data_keys: # <--- FIXED TYPO HERE (was all_keys) | |
| html += f"<td style='padding: 8px; border-right: 1px solid var(--border-color-primary);'>{row.get(k, '')}</td>" | |
| html += "</tr>" | |
| html += "</tbody></table></div>" | |
| # Render the raw HTML block instead of the DataFrame | |
| gr.HTML(html) | |
| # TAB 2: RAW JSON | |
| with gr.Tab("Raw JSON Data"): | |
| json_output = gr.JSON(label="Structured JSON Output") | |
| parse_button.click( | |
| fn=handle_parsing, | |
| inputs=pdf_input, | |
| outputs=[parsed_data_state, json_output] | |
| ) | |
| if __name__ == "__main__": | |
| app.launch() | |