Spaces:
Sleeping
Sleeping
Download src/ingestion/table_extractor.py from Harshavard21/FinRAG: direct link, hf CLI and curl.
- Browser
- Download file 9.89 kB
-
https://huggingface.co/spaces/Harshavard21/FinRAG/resolve/main/src/ingestion/table_extractor.py
- Command line
-
hf download hf://spaces/Harshavard21/FinRAG/src/ingestion/table_extractor.py
-
curl -L -o table_extractor.py https://huggingface.co/spaces/Harshavard21/FinRAG/resolve/main/src/ingestion/table_extractor.py
9.89 kB
| """ | |
| src/ingestion/table_extractor.py | |
| ================================= | |
| Table extraction from PDFs using pdfplumber. | |
| Why pdfplumber for tables? | |
| - It uses a different algorithm than PyMuPDF — better at detecting cell boundaries | |
| - Handles both bordered AND borderless tables (common in BSE annual reports) | |
| - Returns tables as Python lists of lists → easy to convert to Markdown | |
| What this module does: | |
| - For each page in a PDF, detect and extract all tables | |
| - Convert each table to Markdown format (pipe syntax) | |
| - Clean up merged cells, None values, and whitespace | |
| - Return TableChunk objects with page metadata | |
| Markdown tables are the best format to pass to LLMs because: | |
| - LLMs natively understand | column | syntax | |
| - Preserves row/column structure without confusion | |
| - Much better than CSV (no header ambiguity) or raw text | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Optional | |
| import pdfplumber | |
| from src.utils.logger import logger | |
| # ------------------------------------------------------------------ # | |
| # Data Classes | |
| # ------------------------------------------------------------------ # | |
| class ExtractedTable: | |
| """A single extracted table from a PDF page.""" | |
| markdown: str # Table in Markdown pipe format | |
| page_number: int | |
| table_index: int # Table number on this page (0-indexed) | |
| row_count: int | |
| col_count: int | |
| bbox: Optional[tuple] = None # Bounding box on the page | |
| has_header: bool = True | |
| class PageTables: | |
| """All tables extracted from one page.""" | |
| page_number: int | |
| tables: list[ExtractedTable] = field(default_factory=list) | |
| # ------------------------------------------------------------------ # | |
| # Table Extractor | |
| # ------------------------------------------------------------------ # | |
| class TableExtractor: | |
| """ | |
| Extracts tables from PDF files using pdfplumber and converts to Markdown. | |
| Table extraction settings are tuned for BSE annual report layouts: | |
| - snap_tolerance: handles slightly misaligned cell borders (common in BSE PDFs) | |
| - join_tolerance: merges text fragments within the same cell | |
| - edge_min_length: minimum line length to be considered a table border | |
| """ | |
| # pdfplumber table settings tuned for financial reports | |
| TABLE_SETTINGS = { | |
| "vertical_strategy": "lines_strict", # use actual drawn lines (not text-based) | |
| "horizontal_strategy": "lines_strict", | |
| "snap_tolerance": 5, # pixels — snap nearby lines together | |
| "join_tolerance": 5, # pixels — join nearby text in same cell | |
| "edge_min_length": 25, # ignore tiny lines (< 25px) | |
| "min_words_vertical": 2, # at least 2 words to form vertical boundary | |
| "min_words_horizontal": 1, | |
| "text_tolerance": 3, | |
| "text_x_tolerance": 3, | |
| "text_y_tolerance": 3, | |
| "intersection_tolerance": 3, | |
| "intersection_x_tolerance": 3, | |
| "intersection_y_tolerance": 3, | |
| } | |
| # Fallback settings if strict line detection finds no tables | |
| TABLE_SETTINGS_FALLBACK = { | |
| "vertical_strategy": "text", # fall back to text-based column detection | |
| "horizontal_strategy": "text", | |
| "snap_tolerance": 8, | |
| "join_tolerance": 8, | |
| "edge_min_length": 10, | |
| } | |
| def extract_all_tables( | |
| self, | |
| pdf_path: Path, | |
| pages_to_check: Optional[list[int]] = None, | |
| ) -> dict[int, PageTables]: | |
| """ | |
| Extract all tables from a PDF file. | |
| Args: | |
| pdf_path: Path to the PDF | |
| pages_to_check: Optional list of 1-indexed page numbers to process. | |
| If None, processes all pages. | |
| Returns: | |
| Dict mapping page_number → PageTables | |
| """ | |
| logger.info(f"Extracting tables from: {pdf_path.name}") | |
| results: dict[int, PageTables] = {} | |
| total_tables = 0 | |
| with pdfplumber.open(str(pdf_path)) as pdf: | |
| pages = pdf.pages | |
| if pages_to_check: | |
| # Convert 1-indexed to 0-indexed | |
| pages = [pdf.pages[i - 1] for i in pages_to_check if 0 < i <= len(pdf.pages)] | |
| for page in pages: | |
| page_num = page.page_number # pdfplumber is 1-indexed | |
| page_tables = self._extract_page_tables(page, page_num) | |
| if page_tables.tables: | |
| results[page_num] = page_tables | |
| total_tables += len(page_tables.tables) | |
| logger.info(f"✓ Extracted {total_tables} tables from {pdf_path.name}") | |
| return results | |
| def _extract_page_tables(self, page: pdfplumber.page.Page, page_num: int) -> PageTables: | |
| """Extract all tables from a single page.""" | |
| page_tables = PageTables(page_number=page_num) | |
| # Try strict line-based detection first | |
| tables = page.extract_tables(self.TABLE_SETTINGS) | |
| # If nothing found, try text-based fallback | |
| if not tables: | |
| tables = page.extract_tables(self.TABLE_SETTINGS_FALLBACK) | |
| if not tables: | |
| return page_tables | |
| # Get bounding boxes for tables on this page | |
| table_bboxes = [] | |
| try: | |
| finder = page.find_tables(self.TABLE_SETTINGS) | |
| table_bboxes = [t.bbox for t in finder] | |
| except Exception: | |
| pass | |
| for idx, raw_table in enumerate(tables): | |
| if not raw_table or len(raw_table) < 2: | |
| continue # Skip single-row "tables" (not real tables) | |
| cleaned = self._clean_table(raw_table) | |
| if not cleaned: | |
| continue | |
| markdown = self._table_to_markdown(cleaned) | |
| if not markdown.strip(): | |
| continue | |
| bbox = table_bboxes[idx] if idx < len(table_bboxes) else None | |
| table = ExtractedTable( | |
| markdown=markdown, | |
| page_number=page_num, | |
| table_index=idx, | |
| row_count=len(cleaned), | |
| col_count=max(len(row) for row in cleaned), | |
| bbox=bbox, | |
| has_header=True, | |
| ) | |
| page_tables.tables.append(table) | |
| return page_tables | |
| # ---------------------------------------------------------------- # | |
| # Table Cleaning | |
| # ---------------------------------------------------------------- # | |
| def _clean_table(self, raw_table: list[list]) -> list[list[str]]: | |
| """ | |
| Clean raw table data from pdfplumber. | |
| Handles: | |
| - None values (merged cells) → forward-fill or empty string | |
| - Whitespace normalization | |
| - Empty row removal | |
| - Duplicate header rows | |
| """ | |
| if not raw_table: | |
| return [] | |
| cleaned = [] | |
| for row in raw_table: | |
| # Replace None with empty string | |
| clean_row = [] | |
| for cell in row: | |
| if cell is None: | |
| clean_row.append("") | |
| else: | |
| # Normalize whitespace, remove newlines within cells | |
| cell_str = str(cell).strip() | |
| cell_str = re.sub(r'\s+', ' ', cell_str) | |
| cell_str = cell_str.replace('\n', ' ').replace('\r', '') | |
| clean_row.append(cell_str) | |
| # Skip completely empty rows | |
| if all(c == "" for c in clean_row): | |
| continue | |
| cleaned.append(clean_row) | |
| # Normalize column count (handle ragged tables) | |
| if cleaned: | |
| max_cols = max(len(row) for row in cleaned) | |
| cleaned = [row + [""] * (max_cols - len(row)) for row in cleaned] | |
| return cleaned | |
| # ---------------------------------------------------------------- # | |
| # Markdown Conversion | |
| # ---------------------------------------------------------------- # | |
| def _table_to_markdown(self, table: list[list[str]]) -> str: | |
| """ | |
| Convert a cleaned table (list of lists) to Markdown pipe format. | |
| Example output: | |
| | Revenue | FY2024 | FY2023 | | |
| |---|---|---| | |
| | Net Revenue | ₹240,893 Cr | ₹225,458 Cr | | |
| | Other Income | ₹3,212 Cr | ₹2,890 Cr | | |
| """ | |
| if not table: | |
| return "" | |
| lines = [] | |
| # Header row | |
| header = table[0] | |
| lines.append("| " + " | ".join(self._escape_cell(c) for c in header) + " |") | |
| # Separator row (standard Markdown) | |
| lines.append("|" + "|".join("---" for _ in header) + "|") | |
| # Data rows | |
| for row in table[1:]: | |
| lines.append("| " + " | ".join(self._escape_cell(c) for c in row) + " |") | |
| return "\n".join(lines) | |
| def _escape_cell(self, text: str) -> str: | |
| """Escape pipe characters inside cells so they don't break Markdown table.""" | |
| return text.replace("|", "\\|").strip() | |
| # ---------------------------------------------------------------- # | |
| # Utility: Get table bounding boxes (for PDF parser coordination) | |
| # ---------------------------------------------------------------- # | |
| def get_table_bboxes(self, pdf_path: Path, page_num: int) -> list[tuple]: | |
| """ | |
| Return bounding boxes of all tables on a given page. | |
| Used by the PDF parser to avoid double-extracting table text as paragraphs. | |
| """ | |
| bboxes = [] | |
| with pdfplumber.open(str(pdf_path)) as pdf: | |
| if page_num > len(pdf.pages): | |
| return [] | |
| page = pdf.pages[page_num - 1] | |
| try: | |
| tables = page.find_tables(self.TABLE_SETTINGS) | |
| bboxes = [t.bbox for t in tables] | |
| except Exception: | |
| pass | |
| return bboxes | |