stock_report_robust / parser /tally_parser.py
iammraat's picture
Update parser/tally_parser.py
6e79682 verified
Raw
History Blame Contribute Delete
7.52 kB
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