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