import os import re import hashlib import json import fitz import pandas as pd # Regular expressions XREF_INTERNAL_RE = re.compile(r'\bsection\s+(\d+[A-Za-z]?)\b', re.IGNORECASE) XREF_CROSSACT_RE = re.compile( r'\bsection\s+(\d+[A-Za-z]?)\s+of\s+(?:the\s+)?' r'(Bharatiya Nyaya Sanhita|Bharatiya Nagarik Suraksha Sanhita|' r'Bharatiya Sakshya Adhiniyam|BNS|BNSS|BSA)', re.IGNORECASE, ) class SOPParser: def __init__(self, pdf_path: str): self.act_code = "SOP" self.pdf_path = pdf_path def parse_sop_index(self, doc) -> list[dict]: """ Parses pages 8, 9, and 10 of the PDF to extract index items, handling multi-line topics and mapping printed page numbers to PDF pages. """ raw_lines = [] for p_idx in [7, 8, 9]: page = doc[p_idx] blocks = page.get_text('dict')['blocks'] spans = [] for b in blocks: if 'lines' in b: for l in b['lines']: for s in l['spans']: txt = s['text'].strip() if txt: # Exclude footer at the bottom (y > 765) y = (s['bbox'][1] + s['bbox'][3]) / 2 if y > 765: continue spans.append({ 'text': txt, 'bbox': s['bbox'] }) # Group spans by y coordinate (with 4.0 tolerance) lines_dict = {} for s in spans: y = (s['bbox'][1] + s['bbox'][3]) / 2 found = False for ly in lines_dict: if abs(ly - y) < 4.0: lines_dict[ly].append(s) found = True break if not found: lines_dict[y] = [s] sorted_y = sorted(lines_dict.keys()) for y in sorted_y: line_spans = lines_dict[y] line_spans.sort(key=lambda s: s['bbox'][0]) # Segment spans into topic parts and page parts based on x coordinate (threshold 440) topic_spans = [s for s in line_spans if s['bbox'][0] < 440] page_spans = [s for s in line_spans if s['bbox'][0] >= 440] topic_text = ' '.join([s['text'] for s in topic_spans]).strip() page_text = ' '.join([s['text'] for s in page_spans]).strip() if not topic_text or topic_text == 'INDEX' or topic_text == 'Sl.No. TOPIC': continue raw_lines.append((topic_text, page_text)) index_items = [] current_item = None for topic_text, page_text in raw_lines: m = re.match(r'^(\d+)\.\s*(.*)', topic_text) if m: if current_item: index_items.append(current_item) sl_no = int(m.group(1)) topic = m.group(2).strip() current_item = { 'sl_no': sl_no, 'topic': topic, 'page_str': page_text } else: if current_item: current_item['topic'] += ' ' + topic_text if page_text and not current_item['page_str']: current_item['page_str'] = page_text if current_item: index_items.append(current_item) # Parse start/end page numbers (printed pages + 10 = PDF 1-indexed page) for item in index_items: topic = item['topic'] topic = re.sub(r'[\s\u200b\u00a0\ufffd]+', ' ', topic).strip() item['topic'] = topic page_str = item['page_str'] page_str = re.sub(r'[\s\u200b\u00a0\ufffd]+', ' ', page_str).strip() m_range = re.match(r'^(\d+)\s*[\-–—to\s]+\s*(\d+)$', page_str) if m_range: item['start_page'] = int(m_range.group(1)) item['end_page'] = int(m_range.group(2)) else: m_single = re.match(r'^(\d+)$', page_str) if m_single: item['start_page'] = int(m_single.group(1)) item['end_page'] = int(m_single.group(1)) else: item['start_page'] = None item['end_page'] = None return index_items def extract_xrefs(self, text: str) -> list: """ Extracts cross-references to BNS/BNSS/BSA from text. Plain internal 'Section X' references inside SOP map to BNSS. """ xrefs = [] cross_matches = XREF_CROSSACT_RE.findall(text) for num, act in cross_matches: act_clean = act.lower() if "nyaya" in act_clean or "bns" == act_clean: target = f"BNS_S{num}" elif "nagarik" in act_clean or "bnss" == act_clean: target = f"BNSS_S{num}" elif "sakshya" in act_clean or "bsa" == act_clean: target = f"BSA_S{num}" else: target = f"{act.upper()}_S{num}" xrefs.append(target) internal_matches = XREF_INTERNAL_RE.findall(text) for num in internal_matches: already_in_cross = False for target in xrefs: if target.endswith(f"_S{num}"): already_in_cross = True break if not already_in_cross: target = f"BNSS_S{num}" xrefs.append(target) return list(set(xrefs)) def parse(self): doc = fitz.open(self.pdf_path) pages_data = [] lines_data = [] toc_nodes = [] # 1. Parse Index index_items = self.parse_sop_index(doc) # 2. Add root node root_id = "SOP_root" toc_nodes.append({ "section_id": root_id, "act_code": self.act_code, "level": 0, "parent_id": None, "title": "Standard Operating Procedures (SOP) for Police Officers", "chapter_no": None, "section_no": None, "start_page": 1, "end_page": len(doc), "node_type": "root", "cross_references": json.dumps([]), "stable_hash": hashlib.sha1(root_id.encode()).hexdigest() }) # 3. Add front-matter node front_matter_id = "SOP_front_matter" toc_nodes.append({ "section_id": front_matter_id, "act_code": self.act_code, "level": 1, "parent_id": root_id, "title": "Front Matter (Preface, Messages, and Index)", "chapter_no": None, "section_no": None, "start_page": 1, "end_page": 10, "node_type": "front_matter", "cross_references": json.dumps([]), "stable_hash": hashlib.sha1(front_matter_id.encode()).hexdigest() }) # 4. Add SOP nodes from parsed index for item in index_items: sl_no = item['sl_no'] topic = item['topic'] start_p = item['start_page'] + 10 if item['start_page'] is not None else 11 end_p = item['end_page'] + 10 if item['end_page'] is not None else 11 section_id = f"SOP_S{sl_no}" # Map node type if sl_no == 44: node_type = "sop_form" elif sl_no == 45: node_type = "sop_reference" elif sl_no == 47: node_type = "sop_table" else: node_type = "sop_procedure" toc_nodes.append({ "section_id": section_id, "act_code": self.act_code, "level": 1, "parent_id": root_id, "title": topic, "chapter_no": None, "section_no": str(sl_no), "start_page": start_p, "end_page": end_p, "node_type": node_type, "cross_references": json.dumps([]), # Will update after line parsing "stable_hash": hashlib.sha1(section_id.encode()).hexdigest() }) global_line_counter = 1 # 5. Extract pages and lines for p_idx in range(len(doc)): page = doc[p_idx] page_no = p_idx + 1 page_text_raw = page.get_text() blocks = page.get_text("dict")["blocks"] spans = [] for b in blocks: if "lines" in b: for l in b["lines"]: for s in l["spans"]: text = s["text"].strip() if text: spans.append({ "text": text, "bbox": s["bbox"], "font": s["font"], "size": s["size"], "flags": s["flags"] }) # Sort spans top-to-bottom, left-to-right spans.sort(key=lambda s: (round(s["bbox"][1], 1), s["bbox"][0])) # Group spans into lines lines_dict = {} for s in spans: y = (s["bbox"][1] + s["bbox"][3]) / 2 found = False for ly in lines_dict: if abs(ly - y) < 3.0: lines_dict[ly].append(s) found = True break if not found: lines_dict[y] = [s] sorted_y = sorted(lines_dict.keys()) cleaned_lines = [] header_text = None footer_text = None for idx, y_val in enumerate(sorted_y): line_spans = lines_dict[y_val] line_spans.sort(key=lambda s: s["bbox"][0]) dominant_span = max(line_spans, key=lambda s: len(s["text"])) font_name = dominant_span["font"] font_size = round(dominant_span["size"], 1) is_bold = "Bold" in font_name or dominant_span["flags"] & 2 > 0 line_text = " ".join([s["text"] for s in line_spans]).strip() line_bbox = ( min(s["bbox"][0] for s in line_spans), min(s["bbox"][1] for s in line_spans), max(s["bbox"][2] for s in line_spans), max(s["bbox"][3] for s in line_spans) ) # Check for footer page noise y_center = (line_bbox[1] + line_bbox[3]) / 2 if y_center > 765: if "| P a g e" in line_text or re.search(r'^\d+$', line_text): footer_text = line_text continue cleaned_lines.append({ "text": line_text, "bbox": line_bbox, "font_name": font_name, "font_size": font_size, "is_bold": is_bold }) pages_data.append({ "act_code": self.act_code, "page_no": page_no, "page_text_raw": page_text_raw, "header_text": header_text, "footer_text": footer_text }) # Map lines to TOC nodes assigned_section_id = "SOP_root" if page_no <= 10: assigned_section_id = "SOP_front_matter" else: for item in index_items: start_p = item["start_page"] + 10 if item["start_page"] is not None else 11 end_p = item["end_page"] + 10 if item["end_page"] is not None else 11 if start_p <= page_no <= end_p: assigned_section_id = f"SOP_S{item['sl_no']}" break # Fallback for page 238 or any unmapped page above page 10 if assigned_section_id == "SOP_root" and page_no > 10: assigned_section_id = f"SOP_S{index_items[-1]['sl_no']}" for line in cleaned_lines: lines_data.append({ "act_code": self.act_code, "page_no": page_no, "line_no": global_line_counter, "text": line["text"], "bbox": line["bbox"], "font_name": line["font_name"], "font_size": line["font_size"], "is_bold": line["is_bold"], "section_id": assigned_section_id }) global_line_counter += 1 # 6. Extract cross-references per node by scanning lines node_xrefs = {} for line in lines_data: sid = line["section_id"] if sid not in node_xrefs: node_xrefs[sid] = [] xrefs = self.extract_xrefs(line["text"]) node_xrefs[sid].extend(xrefs) for node in toc_nodes: sid = node["section_id"] refs = list(set(node_xrefs.get(sid, []))) node["cross_references"] = json.dumps(refs) page_df = pd.DataFrame(pages_data) line_df = pd.DataFrame(lines_data) toc_df = pd.DataFrame(toc_nodes) schedule_df = pd.DataFrame() return page_df, line_df, toc_df, schedule_df