Spaces:
Running
Running
| import pdfplumber | |
| import re | |
| def midpoint(w: dict) -> float: | |
| return (w["x0"] + w["x1"]) / 2.0 | |
| # ========================================== | |
| 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 |