FinRAG / src /ingestion /table_extractor.py
Harshavard21's picture
Initial commit
454f118
Raw History Blame Contribute Delete
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
# ------------------------------------------------------------------ #
@dataclass
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
@dataclass
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